mirror of
https://github.com/bringout/oca-ocb-web.git
synced 2026-04-19 12:32:05 +02:00
replace stale web_editor with html_editor and html_builder for 19.0
web_editor was removed in Odoo 19.0 and replaced by html_editor
and html_builder. The old web_editor was incorrectly included in
the 19.0 vanilla import.
🤖 assisted by claude
This commit is contained in:
parent
4b94f0abc5
commit
f866779561
1513 changed files with 396049 additions and 358525 deletions
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright 2015-present Chen Fengyuan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
/*!
|
||||
* Cropper.js v1.5.5
|
||||
* https://fengyuanchen.github.io/cropperjs
|
||||
*
|
||||
* Copyright 2015-present Chen Fengyuan
|
||||
* Released under the MIT license
|
||||
*
|
||||
* Date: 2019-08-04T02:26:27.232Z
|
||||
*/
|
||||
|
||||
.cropper-container {
|
||||
direction: ltr;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cropper-container img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
image-orientation: 0deg;
|
||||
max-height: none !important;
|
||||
max-width: none !important;
|
||||
min-height: 0 !important;
|
||||
min-width: 0 !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cropper-wrap-box,
|
||||
.cropper-canvas,
|
||||
.cropper-drag-box,
|
||||
.cropper-crop-box,
|
||||
.cropper-modal {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.cropper-wrap-box,
|
||||
.cropper-canvas {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cropper-drag-box {
|
||||
background-color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cropper-modal {
|
||||
background-color: #000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cropper-view-box {
|
||||
display: block;
|
||||
height: 100%;
|
||||
outline: 1px solid #39f;
|
||||
outline-color: rgba(51, 153, 255, 0.75);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cropper-dashed {
|
||||
border: 0 dashed #eee;
|
||||
display: block;
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.cropper-dashed.dashed-h {
|
||||
border-bottom-width: 1px;
|
||||
border-top-width: 1px;
|
||||
height: calc(100% / 3);
|
||||
left: 0;
|
||||
top: calc(100% / 3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cropper-dashed.dashed-v {
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
height: 100%;
|
||||
left: calc(100% / 3);
|
||||
top: 0;
|
||||
width: calc(100% / 3);
|
||||
}
|
||||
|
||||
.cropper-center {
|
||||
display: block;
|
||||
height: 0;
|
||||
left: 50%;
|
||||
opacity: 0.75;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.cropper-center::before,
|
||||
.cropper-center::after {
|
||||
background-color: #eee;
|
||||
content: ' ';
|
||||
display: block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.cropper-center::before {
|
||||
height: 1px;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
.cropper-center::after {
|
||||
height: 7px;
|
||||
left: 0;
|
||||
top: -3px;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.cropper-face,
|
||||
.cropper-line,
|
||||
.cropper-point {
|
||||
display: block;
|
||||
height: 100%;
|
||||
opacity: 0.1;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cropper-face {
|
||||
background-color: #fff;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.cropper-line {
|
||||
background-color: #39f;
|
||||
}
|
||||
|
||||
.cropper-line.line-e {
|
||||
cursor: ew-resize;
|
||||
right: -3px;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.cropper-line.line-n {
|
||||
cursor: ns-resize;
|
||||
height: 5px;
|
||||
left: 0;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.cropper-line.line-w {
|
||||
cursor: ew-resize;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.cropper-line.line-s {
|
||||
bottom: -3px;
|
||||
cursor: ns-resize;
|
||||
height: 5px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.cropper-point {
|
||||
background-color: #39f;
|
||||
height: 5px;
|
||||
opacity: 0.75;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.cropper-point.point-e {
|
||||
cursor: ew-resize;
|
||||
margin-top: -3px;
|
||||
right: -3px;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.cropper-point.point-n {
|
||||
cursor: ns-resize;
|
||||
left: 50%;
|
||||
margin-left: -3px;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.cropper-point.point-w {
|
||||
cursor: ew-resize;
|
||||
left: -3px;
|
||||
margin-top: -3px;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.cropper-point.point-s {
|
||||
bottom: -3px;
|
||||
cursor: s-resize;
|
||||
left: 50%;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
.cropper-point.point-ne {
|
||||
cursor: nesw-resize;
|
||||
right: -3px;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.cropper-point.point-nw {
|
||||
cursor: nwse-resize;
|
||||
left: -3px;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.cropper-point.point-sw {
|
||||
bottom: -3px;
|
||||
cursor: nesw-resize;
|
||||
left: -3px;
|
||||
}
|
||||
|
||||
.cropper-point.point-se {
|
||||
bottom: -3px;
|
||||
cursor: nwse-resize;
|
||||
height: 20px;
|
||||
opacity: 1;
|
||||
right: -3px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.cropper-point.point-se {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.cropper-point.point-se {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.cropper-point.point-se {
|
||||
height: 5px;
|
||||
opacity: 0.75;
|
||||
width: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.cropper-point.point-se::before {
|
||||
background-color: #39f;
|
||||
bottom: -50%;
|
||||
content: ' ';
|
||||
display: block;
|
||||
height: 200%;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: -50%;
|
||||
width: 200%;
|
||||
}
|
||||
|
||||
.cropper-invisible {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cropper-bg {
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
|
||||
}
|
||||
|
||||
.cropper-hide {
|
||||
display: block;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.cropper-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cropper-move {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.cropper-crop {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.cropper-disabled .cropper-drag-box,
|
||||
.cropper-disabled .cropper-face,
|
||||
.cropper-disabled .cropper-line,
|
||||
.cropper-disabled .cropper-point {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,14 @@
|
|||
Copyright 2014-2016 Rodrigo Fernandes https://rtfpessoa.github.io/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||
persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
||||
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
1
odoo-bringout-oca-ocb-html_editor/html_editor/static/lib/diff2html/diff2html.min.css
vendored
Normal file
1
odoo-bringout-oca-ocb-html_editor/html_editor/static/lib/diff2html/diff2html.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
odoo-bringout-oca-ocb-html_editor/html_editor/static/lib/diff2html/diff2html.min.js
vendored
Normal file
1
odoo-bringout-oca-ocb-html_editor/html_editor/static/lib/diff2html/diff2html.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,358 @@
|
|||
/**
|
||||
* vkBeautify - javascript plugin to pretty-print or minify text in XML, JSON, CSS and SQL formats.
|
||||
*
|
||||
* Version - 0.99.00.beta
|
||||
* Copyright (c) 2012 Vadim Kiryukhin
|
||||
* vkiryukhin @ gmail.com
|
||||
* http://www.eslinstructor.net/vkbeautify/
|
||||
*
|
||||
* Dual licensed under the MIT and GPL licenses:
|
||||
* http://www.opensource.org/licenses/mit-license.php
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*
|
||||
* Pretty print
|
||||
*
|
||||
* vkbeautify.xml(text [,indent_pattern]);
|
||||
* vkbeautify.json(text [,indent_pattern]);
|
||||
* vkbeautify.css(text [,indent_pattern]);
|
||||
* vkbeautify.sql(text [,indent_pattern]);
|
||||
*
|
||||
* @text - String; text to beatufy;
|
||||
* @indent_pattern - Integer | String;
|
||||
* Integer: number of white spaces;
|
||||
* String: character string to visualize indentation ( can also be a set of white spaces )
|
||||
* Minify
|
||||
*
|
||||
* vkbeautify.xmlmin(text [,preserve_comments]);
|
||||
* vkbeautify.jsonmin(text);
|
||||
* vkbeautify.cssmin(text [,preserve_comments]);
|
||||
* vkbeautify.sqlmin(text);
|
||||
*
|
||||
* @text - String; text to minify;
|
||||
* @preserve_comments - Bool; [optional];
|
||||
* Set this flag to true to prevent removing comments from @text ( minxml and mincss functions only. )
|
||||
*
|
||||
* Examples:
|
||||
* vkbeautify.xml(text); // pretty print XML
|
||||
* vkbeautify.json(text, 4 ); // pretty print JSON
|
||||
* vkbeautify.css(text, '. . . .'); // pretty print CSS
|
||||
* vkbeautify.sql(text, '----'); // pretty print SQL
|
||||
*
|
||||
* vkbeautify.xmlmin(text, true);// minify XML, preserve comments
|
||||
* vkbeautify.jsonmin(text);// minify JSON
|
||||
* vkbeautify.cssmin(text);// minify CSS, remove comments ( default )
|
||||
* vkbeautify.sqlmin(text);// minify SQL
|
||||
*
|
||||
*/
|
||||
|
||||
(function() {
|
||||
|
||||
function createShiftArr(step) {
|
||||
|
||||
var space = ' ';
|
||||
|
||||
if ( isNaN(parseInt(step)) ) { // argument is string
|
||||
space = step;
|
||||
} else { // argument is integer
|
||||
switch(step) {
|
||||
case 1: space = ' '; break;
|
||||
case 2: space = ' '; break;
|
||||
case 3: space = ' '; break;
|
||||
case 4: space = ' '; break;
|
||||
case 5: space = ' '; break;
|
||||
case 6: space = ' '; break;
|
||||
case 7: space = ' '; break;
|
||||
case 8: space = ' '; break;
|
||||
case 9: space = ' '; break;
|
||||
case 10: space = ' '; break;
|
||||
case 11: space = ' '; break;
|
||||
case 12: space = ' '; break;
|
||||
}
|
||||
}
|
||||
|
||||
var shift = ['\n']; // array of shifts
|
||||
for(ix=0;ix<100;ix++){
|
||||
shift.push(shift[ix]+space);
|
||||
}
|
||||
return shift;
|
||||
}
|
||||
|
||||
function vkbeautify(){
|
||||
this.step = ' '; // 4 spaces
|
||||
this.shift = createShiftArr(this.step);
|
||||
};
|
||||
|
||||
vkbeautify.prototype.xml = function(text,step) {
|
||||
|
||||
var ar = text.replace(/>\s{0,}</g,"><")
|
||||
.replace(/</g,"~::~<")
|
||||
.replace(/\s*xmlns\:/g,"~::~xmlns:")
|
||||
.replace(/\s*xmlns\=/g,"~::~xmlns=")
|
||||
.split('~::~'),
|
||||
len = ar.length,
|
||||
inComment = false,
|
||||
deep = 0,
|
||||
str = '',
|
||||
ix = 0,
|
||||
shift = step ? createShiftArr(step) : this.shift;
|
||||
|
||||
for(ix=0;ix<len;ix++) {
|
||||
// start comment or <![CDATA[...]]> or <!DOCTYPE //
|
||||
if(ar[ix].search(/<!/) > -1) {
|
||||
str += shift[deep]+ar[ix];
|
||||
inComment = true;
|
||||
// end comment or <![CDATA[...]]> //
|
||||
if(ar[ix].search(/-->/) > -1 || ar[ix].search(/\]>/) > -1 || ar[ix].search(/!DOCTYPE/) > -1 ) {
|
||||
inComment = false;
|
||||
}
|
||||
} else
|
||||
// end comment or <![CDATA[...]]> //
|
||||
if(ar[ix].search(/-->/) > -1 || ar[ix].search(/\]>/) > -1) {
|
||||
str += ar[ix];
|
||||
inComment = false;
|
||||
} else
|
||||
// <elm></elm> //
|
||||
if( /^<\w/.exec(ar[ix-1]) && /^<\/\w/.exec(ar[ix]) &&
|
||||
/^<[\w:\-\.\,]+/.exec(ar[ix-1]) == /^<\/[\w:\-\.\,]+/.exec(ar[ix])[0].replace('/','')) {
|
||||
str += ar[ix];
|
||||
if(!inComment) deep--;
|
||||
} else
|
||||
// <elm> //
|
||||
if(ar[ix].search(/<\w/) > -1 && ar[ix].search(/<\//) == -1 && ar[ix].search(/\/>/) == -1 ) {
|
||||
str = !inComment ? str += shift[deep++]+ar[ix] : str += ar[ix];
|
||||
} else
|
||||
// <elm>...</elm> //
|
||||
if(ar[ix].search(/<\w/) > -1 && ar[ix].search(/<\//) > -1) {
|
||||
str = !inComment ? str += shift[deep]+ar[ix] : str += ar[ix];
|
||||
} else
|
||||
// </elm> //
|
||||
if(ar[ix].search(/<\//) > -1) {
|
||||
str = !inComment ? str += shift[--deep]+ar[ix] : str += ar[ix];
|
||||
} else
|
||||
// <elm/> //
|
||||
if(ar[ix].search(/\/>/) > -1 ) {
|
||||
str = !inComment ? str += shift[deep]+ar[ix] : str += ar[ix];
|
||||
} else
|
||||
// <? xml ... ?> //
|
||||
if(ar[ix].search(/<\?/) > -1) {
|
||||
str += shift[deep]+ar[ix];
|
||||
} else
|
||||
// xmlns //
|
||||
if( ar[ix].search(/xmlns\:/) > -1 || ar[ix].search(/xmlns\=/) > -1) {
|
||||
str += shift[deep]+ar[ix];
|
||||
}
|
||||
|
||||
else {
|
||||
str += ar[ix];
|
||||
}
|
||||
}
|
||||
|
||||
return (str[0] == '\n') ? str.slice(1) : str;
|
||||
}
|
||||
|
||||
vkbeautify.prototype.json = function(text,step) {
|
||||
|
||||
var step = step ? step : this.step;
|
||||
|
||||
if (typeof JSON === 'undefined' ) return text;
|
||||
|
||||
if ( typeof text === "string" ) return JSON.stringify(JSON.parse(text), null, step);
|
||||
if ( typeof text === "object" ) return JSON.stringify(text, null, step);
|
||||
|
||||
return text; // text is not string nor object
|
||||
}
|
||||
|
||||
vkbeautify.prototype.css = function(text, step) {
|
||||
|
||||
var ar = text.replace(/\s{1,}/g,' ')
|
||||
.replace(/\{/g,"{~::~")
|
||||
.replace(/\}/g,"~::~}~::~")
|
||||
.replace(/\;/g,";~::~")
|
||||
.replace(/\/\*/g,"~::~/*")
|
||||
.replace(/\*\//g,"*/~::~")
|
||||
.replace(/~::~\s{0,}~::~/g,"~::~")
|
||||
.split('~::~'),
|
||||
len = ar.length,
|
||||
deep = 0,
|
||||
str = '',
|
||||
ix = 0,
|
||||
shift = step ? createShiftArr(step) : this.shift;
|
||||
|
||||
for(ix=0;ix<len;ix++) {
|
||||
|
||||
if( /\{/.exec(ar[ix])) {
|
||||
str += shift[deep++]+ar[ix];
|
||||
} else
|
||||
if( /\}/.exec(ar[ix])) {
|
||||
str += shift[--deep]+ar[ix];
|
||||
} else
|
||||
if( /\*\\/.exec(ar[ix])) {
|
||||
str += shift[deep]+ar[ix];
|
||||
}
|
||||
else {
|
||||
str += shift[deep]+ar[ix];
|
||||
}
|
||||
}
|
||||
return str.replace(/^\n{1,}/,'');
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
|
||||
function isSubquery(str, parenthesisLevel) {
|
||||
return parenthesisLevel - (str.replace(/\(/g,'').length - str.replace(/\)/g,'').length )
|
||||
}
|
||||
|
||||
function split_sql(str, tab) {
|
||||
|
||||
return str.replace(/\s{1,}/g," ")
|
||||
|
||||
.replace(/ AND /ig,"~::~"+tab+tab+"AND ")
|
||||
.replace(/ BETWEEN /ig,"~::~"+tab+"BETWEEN ")
|
||||
.replace(/ CASE /ig,"~::~"+tab+"CASE ")
|
||||
.replace(/ ELSE /ig,"~::~"+tab+"ELSE ")
|
||||
.replace(/ END /ig,"~::~"+tab+"END ")
|
||||
.replace(/ FROM /ig,"~::~FROM ")
|
||||
.replace(/ GROUP\s{1,}BY/ig,"~::~GROUP BY ")
|
||||
.replace(/ HAVING /ig,"~::~HAVING ")
|
||||
//.replace(/ SET /ig," SET~::~")
|
||||
.replace(/ IN /ig," IN ")
|
||||
|
||||
.replace(/ JOIN /ig,"~::~JOIN ")
|
||||
.replace(/ CROSS~::~{1,}JOIN /ig,"~::~CROSS JOIN ")
|
||||
.replace(/ INNER~::~{1,}JOIN /ig,"~::~INNER JOIN ")
|
||||
.replace(/ LEFT~::~{1,}JOIN /ig,"~::~LEFT JOIN ")
|
||||
.replace(/ RIGHT~::~{1,}JOIN /ig,"~::~RIGHT JOIN ")
|
||||
|
||||
.replace(/ ON /ig,"~::~"+tab+"ON ")
|
||||
.replace(/ OR /ig,"~::~"+tab+tab+"OR ")
|
||||
.replace(/ ORDER\s{1,}BY/ig,"~::~ORDER BY ")
|
||||
.replace(/ OVER /ig,"~::~"+tab+"OVER ")
|
||||
|
||||
.replace(/\(\s{0,}SELECT /ig,"~::~(SELECT ")
|
||||
.replace(/\)\s{0,}SELECT /ig,")~::~SELECT ")
|
||||
|
||||
.replace(/ THEN /ig," THEN~::~"+tab+"")
|
||||
.replace(/ UNION /ig,"~::~UNION~::~")
|
||||
.replace(/ USING /ig,"~::~USING ")
|
||||
.replace(/ WHEN /ig,"~::~"+tab+"WHEN ")
|
||||
.replace(/ WHERE /ig,"~::~WHERE ")
|
||||
.replace(/ WITH /ig,"~::~WITH ")
|
||||
|
||||
//.replace(/\,\s{0,}\(/ig,",~::~( ")
|
||||
//.replace(/\,/ig,",~::~"+tab+tab+"")
|
||||
|
||||
.replace(/ ALL /ig," ALL ")
|
||||
.replace(/ AS /ig," AS ")
|
||||
.replace(/ ASC /ig," ASC ")
|
||||
.replace(/ DESC /ig," DESC ")
|
||||
.replace(/ DISTINCT /ig," DISTINCT ")
|
||||
.replace(/ EXISTS /ig," EXISTS ")
|
||||
.replace(/ NOT /ig," NOT ")
|
||||
.replace(/ NULL /ig," NULL ")
|
||||
.replace(/ LIKE /ig," LIKE ")
|
||||
.replace(/\s{0,}SELECT /ig,"SELECT ")
|
||||
.replace(/\s{0,}UPDATE /ig,"UPDATE ")
|
||||
.replace(/ SET /ig," SET ")
|
||||
|
||||
.replace(/~::~{1,}/g,"~::~")
|
||||
.split('~::~');
|
||||
}
|
||||
|
||||
vkbeautify.prototype.sql = function(text,step) {
|
||||
|
||||
var ar_by_quote = text.replace(/\s{1,}/g," ")
|
||||
.replace(/\'/ig,"~::~\'")
|
||||
.split('~::~'),
|
||||
len = ar_by_quote.length,
|
||||
ar = [],
|
||||
deep = 0,
|
||||
tab = this.step,//+this.step,
|
||||
inComment = true,
|
||||
inQuote = false,
|
||||
parenthesisLevel = 0,
|
||||
str = '',
|
||||
ix = 0,
|
||||
shift = step ? createShiftArr(step) : this.shift;;
|
||||
|
||||
for(ix=0;ix<len;ix++) {
|
||||
if(ix%2) {
|
||||
ar = ar.concat(ar_by_quote[ix]);
|
||||
} else {
|
||||
ar = ar.concat(split_sql(ar_by_quote[ix], tab) );
|
||||
}
|
||||
}
|
||||
|
||||
len = ar.length;
|
||||
for(ix=0;ix<len;ix++) {
|
||||
|
||||
parenthesisLevel = isSubquery(ar[ix], parenthesisLevel);
|
||||
|
||||
if( /\s{0,}\s{0,}SELECT\s{0,}/.exec(ar[ix])) {
|
||||
ar[ix] = ar[ix].replace(/\,/g,",\n"+tab+tab+"")
|
||||
}
|
||||
|
||||
if( /\s{0,}\s{0,}SET\s{0,}/.exec(ar[ix])) {
|
||||
ar[ix] = ar[ix].replace(/\,/g,",\n"+tab+tab+"")
|
||||
}
|
||||
|
||||
if( /\s{0,}\(\s{0,}SELECT\s{0,}/.exec(ar[ix])) {
|
||||
deep++;
|
||||
str += shift[deep]+ar[ix];
|
||||
} else
|
||||
if( /\'/.exec(ar[ix]) ) {
|
||||
if(parenthesisLevel<1 && deep) {
|
||||
deep--;
|
||||
}
|
||||
str += ar[ix];
|
||||
}
|
||||
else {
|
||||
str += shift[deep]+ar[ix];
|
||||
if(parenthesisLevel<1 && deep) {
|
||||
deep--;
|
||||
}
|
||||
}
|
||||
var junk = 0;
|
||||
}
|
||||
|
||||
str = str.replace(/^\n{1,}/,'').replace(/\n{1,}/g,"\n");
|
||||
return str;
|
||||
}
|
||||
|
||||
|
||||
vkbeautify.prototype.xmlmin = function(text, preserveComments) {
|
||||
|
||||
var str = preserveComments ? text
|
||||
: text.replace(/\<![ \r\n\t]*(--([^\-]|[\r\n]|-[^\-])*--[ \r\n\t]*)\>/g,"")
|
||||
.replace(/[ \r\n\t]{1,}xmlns/g, ' xmlns');
|
||||
return str.replace(/>\s{0,}</g,"><");
|
||||
}
|
||||
|
||||
vkbeautify.prototype.jsonmin = function(text) {
|
||||
|
||||
if (typeof JSON === 'undefined' ) return text;
|
||||
|
||||
return JSON.stringify(JSON.parse(text), null, 0);
|
||||
|
||||
}
|
||||
|
||||
vkbeautify.prototype.cssmin = function(text, preserveComments) {
|
||||
|
||||
var str = preserveComments ? text
|
||||
: text.replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g,"") ;
|
||||
|
||||
return str.replace(/\s{1,}/g,' ')
|
||||
.replace(/\{\s{1,}/g,"{")
|
||||
.replace(/\}\s{1,}/g,"}")
|
||||
.replace(/\;\s{1,}/g,";")
|
||||
.replace(/\/\*\s{1,}/g,"/*")
|
||||
.replace(/\*\/\s{1,}/g,"*/");
|
||||
}
|
||||
|
||||
vkbeautify.prototype.sqlmin = function(text) {
|
||||
return text.replace(/\s{1,}/g," ").replace(/\s{1,}\(/,"(").replace(/\s{1,}\)/,")");
|
||||
}
|
||||
|
||||
window.vkbeautify = new vkbeautify();
|
||||
|
||||
})();
|
||||
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2020 Dominic Szablewski - phoboslab.org
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,650 @@
|
|||
/*
|
||||
WebGLImageFilter - MIT Licensed
|
||||
|
||||
2013, Dominic Szablewski - phoboslab.org
|
||||
*/
|
||||
|
||||
(function (window) {
|
||||
var WebGLProgram = function (gl, vertexSource, fragmentSource) {
|
||||
var _collect = function (source, prefix, collection) {
|
||||
var r = new RegExp("\\b" + prefix + " \\w+ (\\w+)", "ig");
|
||||
source.replace(r, function (match, name) {
|
||||
collection[name] = 0;
|
||||
return match;
|
||||
});
|
||||
};
|
||||
|
||||
var _compile = function (gl, source, type) {
|
||||
var shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.log(gl.getShaderInfoLog(shader));
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
};
|
||||
|
||||
this.uniform = {};
|
||||
this.attribute = {};
|
||||
|
||||
var _vsh = _compile(gl, vertexSource, gl.VERTEX_SHADER);
|
||||
var _fsh = _compile(gl, fragmentSource, gl.FRAGMENT_SHADER);
|
||||
|
||||
this.id = gl.createProgram();
|
||||
gl.attachShader(this.id, _vsh);
|
||||
gl.attachShader(this.id, _fsh);
|
||||
gl.linkProgram(this.id);
|
||||
|
||||
if (!gl.getProgramParameter(this.id, gl.LINK_STATUS)) {
|
||||
console.log(gl.getProgramInfoLog(this.id));
|
||||
}
|
||||
|
||||
gl.useProgram(this.id);
|
||||
|
||||
// Collect attributes
|
||||
_collect(vertexSource, "attribute", this.attribute);
|
||||
for (var a in this.attribute) {
|
||||
this.attribute[a] = gl.getAttribLocation(this.id, a);
|
||||
}
|
||||
|
||||
// Collect uniforms
|
||||
_collect(vertexSource, "uniform", this.uniform);
|
||||
_collect(fragmentSource, "uniform", this.uniform);
|
||||
for (var u in this.uniform) {
|
||||
this.uniform[u] = gl.getUniformLocation(this.id, u);
|
||||
}
|
||||
};
|
||||
|
||||
const identityMatrix = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0];
|
||||
|
||||
const weightedAvg = (a, b, w) => a * w + b * (1 - w);
|
||||
|
||||
var WebGLImageFilter = (window.WebGLImageFilter = function (params) {
|
||||
if (!params) {
|
||||
params = {};
|
||||
}
|
||||
|
||||
var gl = null,
|
||||
_drawCount = 0,
|
||||
_sourceTexture = null,
|
||||
_lastInChain = false,
|
||||
_currentFramebufferIndex = -1,
|
||||
_tempFramebuffers = [null, null],
|
||||
_filterChain = [],
|
||||
_width = -1,
|
||||
_height = -1,
|
||||
_vertexBuffer = null,
|
||||
_currentProgram = null,
|
||||
_canvas = params.canvas || document.createElement("canvas");
|
||||
|
||||
// key is the shader program source, value is the compiled program
|
||||
var _shaderProgramCache = {};
|
||||
|
||||
var gl = _canvas.getContext("webgl") || _canvas.getContext("experimental-webgl");
|
||||
if (!gl) {
|
||||
throw "Couldn't get WebGL context";
|
||||
}
|
||||
|
||||
this.addFilter = function (name) {
|
||||
var args = Array.prototype.slice.call(arguments, 1);
|
||||
var filter = _filter[name];
|
||||
|
||||
_filterChain.push({ func: filter, args: args });
|
||||
};
|
||||
|
||||
this.reset = function () {
|
||||
_filterChain = [];
|
||||
};
|
||||
|
||||
this.apply = function (image) {
|
||||
_resize(image.width, image.height);
|
||||
_drawCount = 0;
|
||||
|
||||
// Create the texture for the input image if we haven't yet
|
||||
if (!_sourceTexture) {
|
||||
_sourceTexture = gl.createTexture();
|
||||
}
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, _sourceTexture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||
|
||||
// No filters? Just draw
|
||||
if (_filterChain.length == 0) {
|
||||
var program = _compileShader(SHADER.FRAGMENT_IDENTITY);
|
||||
_draw();
|
||||
return _canvas;
|
||||
}
|
||||
|
||||
for (var i = 0; i < _filterChain.length; i++) {
|
||||
_lastInChain = i == _filterChain.length - 1;
|
||||
var f = _filterChain[i];
|
||||
|
||||
f.func.apply(this, f.args || []);
|
||||
}
|
||||
|
||||
return _canvas;
|
||||
};
|
||||
|
||||
var _resize = function (width, height) {
|
||||
// Same width/height? Nothing to do here
|
||||
if (width == _width && height == _height) {
|
||||
return;
|
||||
}
|
||||
|
||||
_canvas.width = _width = width;
|
||||
_canvas.height = _height = height;
|
||||
|
||||
// Create the context if we don't have it yet
|
||||
if (!_vertexBuffer) {
|
||||
// Create the vertex buffer for the two triangles [x, y, u, v] * 6
|
||||
var vertices = new Float32Array([
|
||||
-1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0, -1, 1, 0, 0, 1, -1, 1, 1, 1, 1, 1, 0,
|
||||
]);
|
||||
(_vertexBuffer = gl.createBuffer()), gl.bindBuffer(gl.ARRAY_BUFFER, _vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
|
||||
|
||||
// Note sure if this is a good idea; at least it makes texture loading
|
||||
// in Ejecta instant.
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
||||
}
|
||||
|
||||
gl.viewport(0, 0, _width, _height);
|
||||
|
||||
// Delete old temp framebuffers
|
||||
_tempFramebuffers = [null, null];
|
||||
};
|
||||
|
||||
var _getTempFramebuffer = function (index) {
|
||||
_tempFramebuffers[index] =
|
||||
_tempFramebuffers[index] || _createFramebufferTexture(_width, _height);
|
||||
|
||||
return _tempFramebuffers[index];
|
||||
};
|
||||
|
||||
var _createFramebufferTexture = function (width, height) {
|
||||
var fbo = gl.createFramebuffer();
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||
|
||||
var renderbuffer = gl.createRenderbuffer();
|
||||
gl.bindRenderbuffer(gl.RENDERBUFFER, renderbuffer);
|
||||
|
||||
var texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
null
|
||||
);
|
||||
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
gl.framebufferTexture2D(
|
||||
gl.FRAMEBUFFER,
|
||||
gl.COLOR_ATTACHMENT0,
|
||||
gl.TEXTURE_2D,
|
||||
texture,
|
||||
0
|
||||
);
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
|
||||
return { fbo: fbo, texture: texture };
|
||||
};
|
||||
|
||||
var _draw = function (flags) {
|
||||
var source = null,
|
||||
target = null,
|
||||
flipY = false;
|
||||
|
||||
// Set up the source
|
||||
if (_drawCount == 0) {
|
||||
// First draw call - use the source texture
|
||||
source = _sourceTexture;
|
||||
} else {
|
||||
// All following draw calls use the temp buffer last drawn to
|
||||
source = _getTempFramebuffer(_currentFramebufferIndex).texture;
|
||||
}
|
||||
_drawCount++;
|
||||
|
||||
// Set up the target
|
||||
if (_lastInChain && !(flags & DRAW.INTERMEDIATE)) {
|
||||
// Last filter in our chain - draw directly to the WebGL Canvas. We may
|
||||
// also have to flip the image vertically now
|
||||
target = null;
|
||||
flipY = _drawCount % 2 == 0;
|
||||
} else {
|
||||
// Intermediate draw call - get a temp buffer to draw to
|
||||
_currentFramebufferIndex = (_currentFramebufferIndex + 1) % 2;
|
||||
target = _getTempFramebuffer(_currentFramebufferIndex).fbo;
|
||||
}
|
||||
|
||||
// Bind the source and target and draw the two triangles
|
||||
gl.bindTexture(gl.TEXTURE_2D, source);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, target);
|
||||
|
||||
gl.uniform1f(_currentProgram.uniform.flipY, flipY ? -1 : 1);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
};
|
||||
|
||||
var _compileShader = function (fragmentSource) {
|
||||
if (_shaderProgramCache[fragmentSource]) {
|
||||
_currentProgram = _shaderProgramCache[fragmentSource];
|
||||
gl.useProgram(_currentProgram.id);
|
||||
return _currentProgram;
|
||||
}
|
||||
|
||||
// Compile shaders
|
||||
_currentProgram = new WebGLProgram(gl, SHADER.VERTEX_IDENTITY, fragmentSource);
|
||||
|
||||
var floatSize = Float32Array.BYTES_PER_ELEMENT;
|
||||
var vertSize = 4 * floatSize;
|
||||
gl.enableVertexAttribArray(_currentProgram.attribute.pos);
|
||||
gl.vertexAttribPointer(
|
||||
_currentProgram.attribute.pos,
|
||||
2,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
vertSize,
|
||||
0 * floatSize
|
||||
);
|
||||
gl.enableVertexAttribArray(_currentProgram.attribute.uv);
|
||||
gl.vertexAttribPointer(
|
||||
_currentProgram.attribute.uv,
|
||||
2,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
vertSize,
|
||||
2 * floatSize
|
||||
);
|
||||
|
||||
_shaderProgramCache[fragmentSource] = _currentProgram;
|
||||
return _currentProgram;
|
||||
};
|
||||
|
||||
var DRAW = { INTERMEDIATE: 1 };
|
||||
|
||||
var SHADER = {};
|
||||
SHADER.VERTEX_IDENTITY = [
|
||||
"precision highp float;",
|
||||
"attribute vec2 pos;",
|
||||
"attribute vec2 uv;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform float flipY;",
|
||||
|
||||
"void main(void) {",
|
||||
"vUv = uv;",
|
||||
"gl_Position = vec4(pos.x, pos.y*flipY, 0.0, 1.);",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
SHADER.FRAGMENT_IDENTITY = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform sampler2D texture;",
|
||||
|
||||
"void main(void) {",
|
||||
"gl_FragColor = texture2D(texture, vUv);",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
var _filter = {};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Color Matrix Filter
|
||||
|
||||
_filter.colorMatrix = function (matrix, amount = 1) {
|
||||
matrix = matrix.map((coef, index) => weightedAvg(coef, identityMatrix[index], amount));
|
||||
// Create a Float32 Array and normalize the offset component to 0-1
|
||||
var m = new Float32Array(matrix);
|
||||
m[4] /= 255;
|
||||
m[9] /= 255;
|
||||
m[14] /= 255;
|
||||
m[19] /= 255;
|
||||
|
||||
// Can we ignore the alpha value? Makes things a bit faster.
|
||||
var shader =
|
||||
1 == m[18] &&
|
||||
0 == m[3] &&
|
||||
0 == m[8] &&
|
||||
0 == m[13] &&
|
||||
0 == m[15] &&
|
||||
0 == m[16] &&
|
||||
0 == m[17] &&
|
||||
0 == m[19]
|
||||
? _filter.colorMatrix.SHADER.WITHOUT_ALPHA
|
||||
: _filter.colorMatrix.SHADER.WITH_ALPHA;
|
||||
|
||||
var program = _compileShader(shader);
|
||||
gl.uniform1fv(program.uniform.m, m);
|
||||
_draw();
|
||||
};
|
||||
|
||||
_filter.colorMatrix.SHADER = {};
|
||||
_filter.colorMatrix.SHADER.WITH_ALPHA = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform sampler2D texture;",
|
||||
"uniform float m[20];",
|
||||
|
||||
"void main(void) {",
|
||||
"vec4 c = texture2D(texture, vUv);",
|
||||
"gl_FragColor.r = m[0] * c.r + m[1] * c.g + m[2] * c.b + m[3] * c.a + m[4];",
|
||||
"gl_FragColor.g = m[5] * c.r + m[6] * c.g + m[7] * c.b + m[8] * c.a + m[9];",
|
||||
"gl_FragColor.b = m[10] * c.r + m[11] * c.g + m[12] * c.b + m[13] * c.a + m[14];",
|
||||
"gl_FragColor.a = m[15] * c.r + m[16] * c.g + m[17] * c.b + m[18] * c.a + m[19];",
|
||||
"}",
|
||||
].join("\n");
|
||||
_filter.colorMatrix.SHADER.WITHOUT_ALPHA = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform sampler2D texture;",
|
||||
"uniform float m[20];",
|
||||
|
||||
"void main(void) {",
|
||||
"vec4 c = texture2D(texture, vUv);",
|
||||
"gl_FragColor.r = m[0] * c.r + m[1] * c.g + m[2] * c.b + m[4];",
|
||||
"gl_FragColor.g = m[5] * c.r + m[6] * c.g + m[7] * c.b + m[9];",
|
||||
"gl_FragColor.b = m[10] * c.r + m[11] * c.g + m[12] * c.b + m[14];",
|
||||
"gl_FragColor.a = c.a;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
_filter.brightness = function (brightness) {
|
||||
var b = (brightness || 0) + 1;
|
||||
_filter.colorMatrix([b, 0, 0, 0, 0, 0, b, 0, 0, 0, 0, 0, b, 0, 0, 0, 0, 0, 1, 0]);
|
||||
};
|
||||
|
||||
_filter.saturation = function (amount) {
|
||||
var x = ((amount || 0) * 2) / 3 + 1;
|
||||
var y = (x - 1) * -0.5;
|
||||
_filter.colorMatrix([x, y, y, 0, 0, y, x, y, 0, 0, y, y, x, 0, 0, 0, 0, 0, 1, 0]);
|
||||
};
|
||||
|
||||
_filter.desaturate = function () {
|
||||
_filter.saturation(-1);
|
||||
};
|
||||
|
||||
_filter.contrast = function (amount) {
|
||||
var v = (amount || 0) + 1;
|
||||
var o = -128 * (v - 1);
|
||||
|
||||
_filter.colorMatrix([v, 0, 0, 0, o, 0, v, 0, 0, o, 0, 0, v, 0, o, 0, 0, 0, 1, 0]);
|
||||
};
|
||||
|
||||
_filter.negative = function () {
|
||||
_filter.contrast(-2);
|
||||
};
|
||||
|
||||
_filter.hue = function (rotation) {
|
||||
rotation = ((rotation || 0) / 180) * Math.PI;
|
||||
var cos = Math.cos(rotation),
|
||||
sin = Math.sin(rotation),
|
||||
lumR = 0.213,
|
||||
lumG = 0.715,
|
||||
lumB = 0.072;
|
||||
|
||||
_filter.colorMatrix([
|
||||
lumR + cos * (1 - lumR) + sin * -lumR,
|
||||
lumG + cos * -lumG + sin * -lumG,
|
||||
lumB + cos * -lumB + sin * (1 - lumB),
|
||||
0,
|
||||
0,
|
||||
lumR + cos * -lumR + sin * 0.143,
|
||||
lumG + cos * (1 - lumG) + sin * 0.14,
|
||||
lumB + cos * -lumB + sin * -0.283,
|
||||
0,
|
||||
0,
|
||||
lumR + cos * -lumR + sin * -(1 - lumR),
|
||||
lumG + cos * -lumG + sin * lumG,
|
||||
lumB + cos * (1 - lumB) + sin * lumB,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]);
|
||||
};
|
||||
|
||||
_filter.desaturateLuminance = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
0.2764723, 0.929708, 0.0938197, 0, -37.1, 0.2764723, 0.929708, 0.0938197, 0,
|
||||
-37.1, 0.2764723, 0.929708, 0.0938197, 0, -37.1, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.sepia = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
0.393, 0.7689999, 0.18899999, 0, 0, 0.349, 0.6859999, 0.16799999, 0, 0, 0.272,
|
||||
0.5339999, 0.13099999, 0, 0, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.brownie = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
0.5997023498159715, 0.34553243048391263, -0.2708298674538042, 0,
|
||||
47.43192855600873, -0.037703249837783157, 0.8609577587992641,
|
||||
0.15059552388459913, 0, -36.96841498319127, 0.24113635128153335,
|
||||
-0.07441037908422492, 0.44972182064877153, 0, -7.562075277591283, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.vintagePinhole = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
0.6279345635605994, 0.3202183420819367, -0.03965408211312453, 0,
|
||||
9.651285835294123, 0.02578397704808868, 0.6441188644374771, 0.03259127616149294,
|
||||
0, 7.462829176470591, 0.0466055556782719, -0.0851232987247891,
|
||||
0.5241648018700465, 0, 5.159190588235296, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.kodachrome = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
1.1285582396593525, -0.3967382283601348, -0.03992559172921793, 0,
|
||||
63.72958762196502, -0.16404339962244616, 1.0835251566291304,
|
||||
-0.05498805115633132, 0, 24.732407896706203, -0.16786010706155763,
|
||||
-0.5603416277695248, 1.6014850761964943, 0, 35.62982807460946, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.technicolor = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
1.9125277891456083, -0.8545344976951645, -0.09155508482755585, 0,
|
||||
11.793603434377337, -0.3087833385928097, 1.7658908555458428,
|
||||
-0.10601743074722245, 0, -70.35205161461398, -0.231103377548616,
|
||||
-0.7501899197440212, 1.847597816108189, 0, 30.950940869491138, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.polaroid = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
1.438, -0.062, -0.062, 0, 0, -0.122, 1.378, -0.122, 0, 0, -0.016, -0.016, 1.483,
|
||||
0, 0, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.shiftToBGR = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Convolution Filter
|
||||
|
||||
_filter.convolution = function (matrix) {
|
||||
var m = new Float32Array(matrix);
|
||||
var pixelSizeX = 1 / _width;
|
||||
var pixelSizeY = 1 / _height;
|
||||
|
||||
var program = _compileShader(_filter.convolution.SHADER);
|
||||
gl.uniform1fv(program.uniform.m, m);
|
||||
gl.uniform2f(program.uniform.px, pixelSizeX, pixelSizeY);
|
||||
_draw();
|
||||
};
|
||||
|
||||
_filter.convolution.SHADER = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform sampler2D texture;",
|
||||
"uniform vec2 px;",
|
||||
"uniform float m[9];",
|
||||
|
||||
"void main(void) {",
|
||||
"vec4 c11 = texture2D(texture, vUv - px);", // top left
|
||||
"vec4 c12 = texture2D(texture, vec2(vUv.x, vUv.y - px.y));", // top center
|
||||
"vec4 c13 = texture2D(texture, vec2(vUv.x + px.x, vUv.y - px.y));", // top right
|
||||
|
||||
"vec4 c21 = texture2D(texture, vec2(vUv.x - px.x, vUv.y) );", // mid left
|
||||
"vec4 c22 = texture2D(texture, vUv);", // mid center
|
||||
"vec4 c23 = texture2D(texture, vec2(vUv.x + px.x, vUv.y) );", // mid right
|
||||
|
||||
"vec4 c31 = texture2D(texture, vec2(vUv.x - px.x, vUv.y + px.y) );", // bottom left
|
||||
"vec4 c32 = texture2D(texture, vec2(vUv.x, vUv.y + px.y) );", // bottom center
|
||||
"vec4 c33 = texture2D(texture, vUv + px );", // bottom right
|
||||
|
||||
"gl_FragColor = ",
|
||||
"c11 * m[0] + c12 * m[1] + c22 * m[2] +",
|
||||
"c21 * m[3] + c22 * m[4] + c23 * m[5] +",
|
||||
"c31 * m[6] + c32 * m[7] + c33 * m[8];",
|
||||
"gl_FragColor.a = c22.a;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
_filter.detectEdges = function () {
|
||||
_filter.convolution.call(this, [0, 1, 0, 1, -4, 1, 0, 1, 0]);
|
||||
};
|
||||
|
||||
_filter.sobelX = function () {
|
||||
_filter.convolution.call(this, [-1, 0, 1, -2, 0, 2, -1, 0, 1]);
|
||||
};
|
||||
|
||||
_filter.sobelY = function () {
|
||||
_filter.convolution.call(this, [-1, -2, -1, 0, 0, 0, 1, 2, 1]);
|
||||
};
|
||||
|
||||
_filter.sharpen = function (amount) {
|
||||
var a = amount || 1;
|
||||
_filter.convolution.call(this, [0, -1 * a, 0, -1 * a, 1 + 4 * a, -1 * a, 0, -1 * a, 0]);
|
||||
};
|
||||
|
||||
_filter.emboss = function (size) {
|
||||
var s = size || 1;
|
||||
_filter.convolution.call(this, [-2 * s, -1 * s, 0, -1 * s, 1, 1 * s, 0, 1 * s, 2 * s]);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Blur Filter
|
||||
|
||||
_filter.blur = function (size) {
|
||||
var blurSizeX = size / 7 / _width;
|
||||
var blurSizeY = size / 7 / _height;
|
||||
|
||||
var program = _compileShader(_filter.blur.SHADER);
|
||||
|
||||
// Vertical
|
||||
gl.uniform2f(program.uniform.px, 0, blurSizeY);
|
||||
_draw(DRAW.INTERMEDIATE);
|
||||
|
||||
// Horizontal
|
||||
gl.uniform2f(program.uniform.px, blurSizeX, 0);
|
||||
_draw();
|
||||
};
|
||||
|
||||
_filter.blur.SHADER = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform sampler2D texture;",
|
||||
"uniform vec2 px;",
|
||||
|
||||
"void main(void) {",
|
||||
"gl_FragColor = vec4(0.0);",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-7.0*px.x, -7.0*px.y))*0.0044299121055113265;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-6.0*px.x, -6.0*px.y))*0.00895781211794;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-5.0*px.x, -5.0*px.y))*0.0215963866053;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-4.0*px.x, -4.0*px.y))*0.0443683338718;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-3.0*px.x, -3.0*px.y))*0.0776744219933;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-2.0*px.x, -2.0*px.y))*0.115876621105;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-1.0*px.x, -1.0*px.y))*0.147308056121;",
|
||||
"gl_FragColor += texture2D(texture, vUv )*0.159576912161;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 1.0*px.x, 1.0*px.y))*0.147308056121;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 2.0*px.x, 2.0*px.y))*0.115876621105;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 3.0*px.x, 3.0*px.y))*0.0776744219933;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 4.0*px.x, 4.0*px.y))*0.0443683338718;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 5.0*px.x, 5.0*px.y))*0.0215963866053;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 6.0*px.x, 6.0*px.y))*0.00895781211794;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 7.0*px.x, 7.0*px.y))*0.0044299121055113265;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pixelate Filter
|
||||
|
||||
_filter.pixelate = function (size) {
|
||||
var blurSizeX = size / _width;
|
||||
var blurSizeY = size / _height;
|
||||
|
||||
var program = _compileShader(_filter.pixelate.SHADER);
|
||||
|
||||
// Horizontal
|
||||
gl.uniform2f(program.uniform.size, blurSizeX, blurSizeY);
|
||||
_draw();
|
||||
};
|
||||
|
||||
_filter.pixelate.SHADER = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform vec2 size;",
|
||||
"uniform sampler2D texture;",
|
||||
|
||||
"vec2 pixelate(vec2 coord, vec2 size) {",
|
||||
"return floor( coord / size ) * size;",
|
||||
"}",
|
||||
|
||||
"void main(void) {",
|
||||
"gl_FragColor = vec4(0.0);",
|
||||
"vec2 coord = pixelate(vUv, size);",
|
||||
"gl_FragColor += texture2D(texture, coord);",
|
||||
"}",
|
||||
].join("\n");
|
||||
});
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { DynamicPlaceholderPlugin } from "@html_editor/others/dynamic_placeholder_plugin";
|
||||
import { QWebPlugin } from "@html_editor/others/qweb_plugin";
|
||||
|
||||
export const DYNAMIC_PLACEHOLDER_PLUGINS = [DynamicPlaceholderPlugin, QWebPlugin];
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
.html-history-dialog .history-container {
|
||||
--border-color: #3C3E4B;
|
||||
}
|
||||
|
||||
.html-history-dialog {
|
||||
.history-view-top-bar {
|
||||
background-color: rgba(27, 161, 228, 0.1);
|
||||
border-bottom: 1px solid #385f7f;
|
||||
.text-info {
|
||||
--color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
.history-view-inner {
|
||||
background-color: rgb(27, 29, 38);
|
||||
border-color: rgba(27, 161, 228, 0.2);
|
||||
}
|
||||
.history-container {
|
||||
--border-color: #ddd;
|
||||
margin-left: 240px;
|
||||
.o_notebook_content {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: 0;
|
||||
}
|
||||
.nav {
|
||||
padding-left: 24px;
|
||||
}
|
||||
removed {
|
||||
background-color: #8d1d1d;
|
||||
opacity: 1;
|
||||
color: #d58f8f;
|
||||
}
|
||||
added {
|
||||
background-color: #1e4506;
|
||||
color: #bbd9bb;
|
||||
}
|
||||
}
|
||||
.revision-list {
|
||||
.btn {
|
||||
color: #999;
|
||||
&:hover {
|
||||
background-color: rgba($primary, .40);
|
||||
}
|
||||
&.targeted {
|
||||
color: #c3c3c3;
|
||||
}
|
||||
&.selected {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { Notebook } from "@web/core/notebook/notebook";
|
||||
import { formatDateTime } from "@web/core/l10n/dates";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { memoize } from "@web/core/utils/functions";
|
||||
import { Component, onMounted, useState, markup, onWillStart, onWillDestroy } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { user } from "@web/core/user";
|
||||
import { HtmlViewer } from "@html_editor/components/html_viewer/html_viewer";
|
||||
import { READONLY_MAIN_EMBEDDINGS } from "@html_editor/others/embedded_components/embedding_sets";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { cookie } from "@web/core/browser/cookie";
|
||||
import { loadBundle } from "@web/core/assets";
|
||||
import { htmlReplaceAll } from "@web/core/utils/html";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class HistoryDialog extends Component {
|
||||
static template = "html_editor.HistoryDialog";
|
||||
static components = { Dialog, HtmlViewer, Notebook };
|
||||
static props = {
|
||||
recordId: Number,
|
||||
recordModel: String,
|
||||
close: Function,
|
||||
restoreRequested: Function,
|
||||
historyMetadata: Array,
|
||||
versionedFieldName: String,
|
||||
title: { String, optional: true },
|
||||
noContentHelper: { String, optional: true }, //Markup
|
||||
embeddedComponents: { Array, optional: true },
|
||||
};
|
||||
|
||||
DEFAULT_AVATAR = "/mail/static/src/img/smiley/avatar.jpg";
|
||||
|
||||
static defaultProps = {
|
||||
title: _t("History"),
|
||||
noContentHelper: markup(""),
|
||||
embeddedComponents: [...READONLY_MAIN_EMBEDDINGS],
|
||||
};
|
||||
|
||||
state = useState({
|
||||
revisionsData: [],
|
||||
currentView: "content", // "content" or "comparison"
|
||||
isComparisonSplit: false, // true for side-by-side, false for unified diff
|
||||
revisionContent: null,
|
||||
revisionComparison: null,
|
||||
revisionId: null,
|
||||
revisionLoading: false,
|
||||
cssMaxHeight: 400,
|
||||
});
|
||||
|
||||
setup() {
|
||||
this.size = "fullscreen";
|
||||
this.title = this.props.title;
|
||||
this.orm = useService("orm");
|
||||
this.resizeObserver = null;
|
||||
|
||||
onWillStart(async () => {
|
||||
// We include the current document version as the first revision,
|
||||
// and we shift the rest of the metadata to be more logical for the user.
|
||||
let revisionId = -1;
|
||||
const revisionData = [];
|
||||
for (const metadata of this.props.historyMetadata) {
|
||||
revisionData.push({ ...metadata, revision_id: revisionId });
|
||||
revisionId = metadata["revision_id"];
|
||||
}
|
||||
// add the initial revision data based on the record creation date and user
|
||||
const record = await this.orm.read(
|
||||
this.props.recordModel,
|
||||
[this.props.recordId],
|
||||
["create_date", "create_uid"]
|
||||
);
|
||||
revisionData.push({
|
||||
revision_id: revisionId,
|
||||
create_date: DateTime.fromFormat(
|
||||
record[0]["create_date"],
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
).toISO(),
|
||||
create_uid: record[0]["create_uid"][0],
|
||||
create_user_name: record[0]["create_uid"][1],
|
||||
});
|
||||
|
||||
this.state.revisionsData = revisionData;
|
||||
this.resizeObserver = new ResizeObserver(this.resize.bind(this));
|
||||
this.resizeObserver.observe(document.body);
|
||||
});
|
||||
onMounted(() => this.init());
|
||||
onWillDestroy(() => {
|
||||
this.resizeObserver?.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
resize() {
|
||||
const dialogContainer = document.querySelector(".html-history-dialog-container");
|
||||
const computedStyle = getComputedStyle(dialogContainer);
|
||||
this.state.cssMaxHeight = parseInt(computedStyle.height.replace("px", "")) - 160;
|
||||
}
|
||||
|
||||
getConfig(value) {
|
||||
return {
|
||||
value: this.state[value],
|
||||
embeddedComponents: this.props.embeddedComponents,
|
||||
};
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Load diff2html only in debug mode, as the side-by-side comparison is only available in debug mode.
|
||||
if (this.env.debug) {
|
||||
await loadBundle("html_editor.assets_history_diff");
|
||||
}
|
||||
await this.updateCurrentRevision(this.state.revisionsData[0]["revision_id"]);
|
||||
this.resize();
|
||||
}
|
||||
|
||||
async updateCurrentRevision(revisionId) {
|
||||
if (this.state.revisionId === revisionId) {
|
||||
return;
|
||||
}
|
||||
this.state.revisionLoading = true;
|
||||
this.state.revisionId = revisionId;
|
||||
this.state.revisionContent = await this.getRevisionContent(revisionId);
|
||||
this.state.revisionComparison = await this.getRevisionComparison(revisionId);
|
||||
this.state.revisionComparisonSplit = await this.getRevisionComparisonSplit(revisionId);
|
||||
this.state.revisionLoading = false;
|
||||
}
|
||||
|
||||
getRevisionComparison = memoize(
|
||||
async function getRevisionComparison(revisionId) {
|
||||
if (revisionId === -1) {
|
||||
return "";
|
||||
}
|
||||
const comparison = await this.orm.call(
|
||||
this.props.recordModel,
|
||||
"html_field_history_get_comparison_at_revision",
|
||||
[this.props.recordId, this.props.versionedFieldName, revisionId]
|
||||
);
|
||||
return this._removeExternalBlockHtml(markup(comparison));
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
getRevisionComparisonSplit = memoize(
|
||||
async function getRevisionComparisonSplit(revisionId) {
|
||||
if (!this.env.debug || revisionId === -1) {
|
||||
return "";
|
||||
}
|
||||
let unifiedDiffString = await this.orm.call(
|
||||
this.props.recordModel,
|
||||
"html_field_history_get_unified_diff_at_revision",
|
||||
[this.props.recordId, this.props.versionedFieldName, revisionId]
|
||||
);
|
||||
// Remove unnecessary linebreaks
|
||||
unifiedDiffString = unifiedDiffString.replace(/^\s*[\r\n]/gm, "");
|
||||
const colorScheme = cookie.get("color_scheme") === "dark" ? "dark" : "light";
|
||||
// eslint-disable-next-line no-undef
|
||||
const diffHtml = Diff2Html.html(unifiedDiffString, {
|
||||
drawFileList: false,
|
||||
matching: "lines",
|
||||
outputFormat: "side-by-side",
|
||||
colorScheme: colorScheme,
|
||||
});
|
||||
return markup(diffHtml);
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
getRevisionContent = memoize(
|
||||
async function getRevisionContent(revisionId) {
|
||||
if (revisionId === -1) {
|
||||
const curentContent = await this.orm.read(
|
||||
this.props.recordModel,
|
||||
[this.props.recordId],
|
||||
[this.props.versionedFieldName]
|
||||
);
|
||||
if (!curentContent || !curentContent.length) {
|
||||
return this.props.noContentHelper;
|
||||
}
|
||||
return this._removeExternalBlockHtml(
|
||||
markup(curentContent[0][this.props.versionedFieldName])
|
||||
);
|
||||
}
|
||||
const content = await this.orm.call(
|
||||
this.props.recordModel,
|
||||
"html_field_history_get_content_at_revision",
|
||||
[this.props.recordId, this.props.versionedFieldName, revisionId]
|
||||
);
|
||||
return this._removeExternalBlockHtml(markup(content));
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
async _onRestoreRevisionClick() {
|
||||
this.env.services.ui.block();
|
||||
const restoredContent = await this.getRevisionContent(this.state.revisionId);
|
||||
this.props.restoreRequested(restoredContent, this.props.close);
|
||||
this.env.services.ui.unblock();
|
||||
}
|
||||
|
||||
_removeExternalBlockHtml(baseHtml) {
|
||||
const filteringRegex = /<[a-z ]+data-embedded="(?:(?!<).)+<\/[a-z]+>/gim;
|
||||
const placeholderHtml = markup`<div class="embedded-history-dialog-placeholder">${_t(
|
||||
"Dynamic element"
|
||||
)}</div>`;
|
||||
return htmlReplaceAll(baseHtml, filteringRegex, () => placeholderHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Getters
|
||||
**/
|
||||
getRevisionDate(revision) {
|
||||
if (!revision || !revision["create_date"]) {
|
||||
return "--";
|
||||
}
|
||||
return formatDateTime(
|
||||
DateTime.fromISO(revision["create_date"], { zone: "utc" }).setZone(user.tz),
|
||||
{ showSeconds: false }
|
||||
);
|
||||
}
|
||||
getRevisionClasses(revision) {
|
||||
let classesStr = "btn";
|
||||
|
||||
if (
|
||||
this.state.revisionId !== -1 &&
|
||||
(this.state.revisionId < revision.revision_id || revision.revision_id === -1)
|
||||
) {
|
||||
classesStr += " targeted";
|
||||
} else if (this.state.revisionId === revision.revision_id) {
|
||||
classesStr += " selected";
|
||||
}
|
||||
|
||||
return classesStr;
|
||||
}
|
||||
getRevisionAuthorAvatar(revision) {
|
||||
if (!revision || !revision["create_uid"]) {
|
||||
return this.DEFAULT_AVATAR;
|
||||
}
|
||||
return `${browser.location.origin}/web/image?model=res.users&field=avatar_128&id=${revision["create_uid"]}`;
|
||||
}
|
||||
|
||||
get currentRevision() {
|
||||
const id = this.state?.revisionId || this.state.revisionsData[0]["revision_id"];
|
||||
return this.state.revisionsData.find((revision) => revision["revision_id"] === id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
.html-history-dialog-container {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
.html-history-dialog {
|
||||
position: relative;
|
||||
.history-view-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #ddd;
|
||||
|
||||
>div {
|
||||
flex-grow: 6;
|
||||
&.toggle-view-btns {
|
||||
flex-grow: 1;
|
||||
padding-right: 10px;
|
||||
width: 220px;
|
||||
}
|
||||
&:last-child {
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
width: 180px;
|
||||
.fa {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.history-view-inner {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-top: 0;
|
||||
overflow: auto;
|
||||
.embedded-history-dialog-placeholder {
|
||||
color: #444;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
border : 1px solid #999;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
background-image: linear-gradient(45deg, #d1d1d1 25%, #999 25%, #999 50%, #d1d1d1 50%, #d1d1d1 75%, #999 75%, #999);
|
||||
background-size: 50px 50px;
|
||||
text-shadow:
|
||||
-2px -2px 0 #d1d1d1,
|
||||
2px -2px 0 #d1d1d1,
|
||||
-2px 2px 0 #d1d1d1,
|
||||
2px 2px 0 #d1d1d1,
|
||||
-3px 0px 0 #d1d1d1,
|
||||
3px 0px 0 #d1d1d1,
|
||||
0px -3px 0 #d1d1d1,
|
||||
0px 3px 0 #d1d1d1,
|
||||
}
|
||||
.history-comparison-split {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
.history-container {
|
||||
--border-color: #ddd;
|
||||
margin-left: 240px;
|
||||
overflow: hidden;
|
||||
.o_notebook_content {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: 0;
|
||||
}
|
||||
.nav {
|
||||
padding-left: 24px;
|
||||
}
|
||||
removed {
|
||||
display: inline;
|
||||
background-color: #f1afaf;
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
added {
|
||||
display: inline;
|
||||
background-color: #c8f1af;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
}
|
||||
.revision-list {
|
||||
overflow: auto;
|
||||
width: 230px;
|
||||
position: absolute;
|
||||
top:0;
|
||||
left: 0;
|
||||
.btn {
|
||||
--Avatar-size: 24px;
|
||||
display: block;
|
||||
text-align: left;
|
||||
width: 200px;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
margin-left: 12px;
|
||||
color: #555;
|
||||
border-radius: 6px;
|
||||
.o_avatar {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 3px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba($primary, .20);
|
||||
}
|
||||
&:after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
left : -1px;
|
||||
top: -16px;
|
||||
border-left: 2px solid;
|
||||
border-color: $secondary;
|
||||
height: 24px;
|
||||
}
|
||||
&:before {
|
||||
font-family: 'FontAwesome';
|
||||
content: '\f068';
|
||||
position: absolute;
|
||||
left : -12px;
|
||||
top: 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
background-color: $secondary;
|
||||
z-index: 10;
|
||||
}
|
||||
&.targeted {
|
||||
color: lighten($primary, 20%);
|
||||
&:before {
|
||||
color: white;
|
||||
content: '\f00c';
|
||||
background-color: lighten($primary, 20%);
|
||||
}
|
||||
&:after {
|
||||
border-color: lighten($primary, 20%);
|
||||
}
|
||||
}
|
||||
&.selected {
|
||||
color: $primary;
|
||||
&:before {
|
||||
color: white;
|
||||
content: '\f0da';
|
||||
background-color: $primary;
|
||||
}
|
||||
&:after {
|
||||
border-color: $primary;
|
||||
}
|
||||
.o_avatar {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:first-child:after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.HistoryDialog">
|
||||
<Dialog size="size" title="title" contentClass="'h-100 html-history-dialog-container'"
|
||||
t-on-close="props.close" t-on-cancel="props.close" t-on-confirm="_onRestoreRevisionClick"
|
||||
t-on-after-render="_onAfterRender">
|
||||
<div t-attf-class="dialog-container html-history-dialog #{state.revisionLoading ? 'html-history-loading' : 'html-history-loaded'}">
|
||||
<div class="revision-list d-flex flex-column align-content-stretch" t-attf-style="max-height: {{state.cssMaxHeight}}px;">
|
||||
<t t-if="!state.revisionsData.length">
|
||||
<div class="text-center w-100 pb-2 pt-0 px-0 fw-bolder">No history</div>
|
||||
</t>
|
||||
|
||||
<t t-foreach="state.revisionsData" t-as="rev"
|
||||
t-key="rev.revision_id">
|
||||
<a type="object" href="#" role="button"
|
||||
t-attf-title="Show the document submited by #{rev.create_user_name}, on #{this.getRevisionDate(rev)}"
|
||||
t-att-class="this.getRevisionClasses(rev)"
|
||||
t-on-click="() => this.updateCurrentRevision(rev.revision_id )">
|
||||
<small><t t-esc="this.getRevisionDate(rev)" /></small>
|
||||
<div class="o_avatar">
|
||||
<img class="rounded" t-att-src="this.getRevisionAuthorAvatar(rev)"
|
||||
t-att-alt="rev.create_user_name" t-att-title="rev.create_user_name"/>
|
||||
</div>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
<div class="history-container" t-attf-style="max-height: {{state.cssMaxHeight}}px;">
|
||||
<div t-attf-class="history-content-view #{state.currentView === 'content' ? '' : 'd-none'}">
|
||||
<div class="history-view-top-bar">
|
||||
<div>
|
||||
<div class="text-info smaller">
|
||||
<i class="fa fa-info-circle me-1" title="Version date"/>
|
||||
Showing the document as it was on <t t-esc="this.getRevisionDate(this.currentRevision)" />, submited by <t t-esc="this.currentRevision.create_user_name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a type="object" href="#" role="button" class="btn btn-secondary" t-on-click="() => state.currentView = 'comparison'">
|
||||
<i class="fa fa-exchange" title="View comparison" />
|
||||
View comparison
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-view-inner" t-attf-style="max-height: {{state.cssMaxHeight-20}}px;">
|
||||
<t t-if="state.revisionLoading">
|
||||
<div class="d-flex flex-column justify-content-center align-items-center">
|
||||
<img src="/web/static/img/spin.svg" alt="Loading..." class="me-2"
|
||||
style="filter: invert(1); opacity: 0.5; width: 30px; height: 30px;"/>
|
||||
<p class="m-0 text-muted loading-text">
|
||||
<em>Loading...</em>
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.revisionContent?.length">
|
||||
<div class="pe-none">
|
||||
<HtmlViewer config="getConfig('revisionContent')"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="" t-out="props.noContentHelper" />
|
||||
</div>
|
||||
</div>
|
||||
<div t-attf-class="history-comparison-view #{state.currentView === 'comparison' ? '' : 'd-none'}">
|
||||
<div class="history-view-top-bar">
|
||||
<t t-if="env.debug">
|
||||
<div class="toggle-view-btns">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="vbtn-radio" id="unified-view-btn"
|
||||
t-on-click="() => state.isComparisonSplit = false"
|
||||
t-att-checked="state.isComparisonSplit ? '' : 'checked'" />
|
||||
<label class="btn btn-secondary" for="unified-view-btn">Unified view</label>
|
||||
<input type="radio" class="btn-check" name="vbtn-radio" id="split-view-btn"
|
||||
t-on-click="() => state.isComparisonSplit = true"
|
||||
t-att-checked="state.isComparisonSplit ? 'checked' : ''" />
|
||||
<label class="btn btn-secondary" for="split-view-btn">Split view</label>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<div>
|
||||
<div class="text-info smaller">
|
||||
<i class="fa fa-info-circle me-1" title="Version date"/>
|
||||
Showing all differences between the current version and the selected one updated by <t t-esc="this.currentRevision.create_user_name" /> on <t t-esc="this.getRevisionDate(this.currentRevision)" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a type="object" href="#" role="button" class="btn btn-secondary" t-on-click="() => state.currentView = 'content'">
|
||||
<i class="fa fa-eye" title="View Content"/>
|
||||
View content
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-view-inner" t-attf-style="max-height: {{state.cssMaxHeight-60}}px;">
|
||||
<t t-if="state.revisionLoading">
|
||||
<div class="d-flex flex-column justify-content-center align-items-center">
|
||||
<img src="/web/static/img/spin.svg" alt="Loading..." class="me-2"
|
||||
style="filter: invert(1); opacity: 0.5; width: 30px; height: 30px;"/>
|
||||
<p class="m-0 text-muted loading-text">
|
||||
<em>Loading...</em>
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.revisionComparison?.length">
|
||||
<div t-attf-class="history-comparison-split #{state.isComparisonSplit ? '' : 'd-none'}">
|
||||
<HtmlViewer config="getConfig('revisionComparisonSplit')"/>
|
||||
</div>
|
||||
<div t-attf-class="pe-none history-comparison-unified #{state.isComparisonSplit ? 'd-none' : ''}">
|
||||
<HtmlViewer config="getConfig('revisionComparison')"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted fst-italic">This is the current version, nothing to compare.</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary" t-on-click="_onRestoreRevisionClick" t-att-disabled="state.revisionLoading || state.revisionId === -1">Restore history</button>
|
||||
<button class="btn btn-secondary" t-on-click="props.close">Discard</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
import {
|
||||
Component,
|
||||
markup,
|
||||
onMounted,
|
||||
onWillStart,
|
||||
onWillUnmount,
|
||||
onWillUpdateProps,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "@odoo/owl";
|
||||
import { getBundle } from "@web/core/assets";
|
||||
import { memoize } from "@web/core/utils/functions";
|
||||
import { fixInvalidHTML, instanceofMarkup } from "@html_editor/utils/sanitize";
|
||||
import { HtmlUpgradeManager } from "@html_editor/html_migrations/html_upgrade_manager";
|
||||
import { TableOfContentManager } from "@html_editor/others/embedded_components/core/table_of_content/table_of_content_manager";
|
||||
|
||||
export class HtmlViewer extends Component {
|
||||
static template = "html_editor.HtmlViewer";
|
||||
static props = {
|
||||
config: { type: Object },
|
||||
migrateHTML: { type: Boolean, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
migrateHTML: true,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.htmlUpgradeManager = new HtmlUpgradeManager();
|
||||
this.iframeRef = useRef("iframe");
|
||||
|
||||
this.state = useState({
|
||||
iframeVisible: false,
|
||||
value: this.formatValue(this.props.config.value),
|
||||
});
|
||||
this.components = new Set();
|
||||
|
||||
onWillUpdateProps((newProps) => {
|
||||
const newValue = this.formatValue(newProps.config.value);
|
||||
if (newValue.toString() !== this.state.value.toString()) {
|
||||
this.state.value = this.formatValue(newProps.config.value);
|
||||
if (this.props.config.embeddedComponents) {
|
||||
this.destroyComponents();
|
||||
}
|
||||
if (this.showIframe) {
|
||||
this.updateIframeContent(this.state.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
this.destroyComponents();
|
||||
});
|
||||
|
||||
if (this.showIframe) {
|
||||
onMounted(() => {
|
||||
const onLoadIframe = () => this.onLoadIframe(this.state.value);
|
||||
this.iframeRef.el.addEventListener("load", onLoadIframe, { once: true });
|
||||
// Force the iframe to call the `load` event. Without this line, the
|
||||
// event 'load' might never trigger.
|
||||
this.iframeRef.el.after(this.iframeRef.el);
|
||||
});
|
||||
} else {
|
||||
this.readonlyElementRef = useRef("readonlyContent");
|
||||
useEffect(
|
||||
() => {
|
||||
this.processReadonlyContent(this.readonlyElementRef.el);
|
||||
},
|
||||
() => [this.props.config.value.toString(), this.readonlyElementRef?.el]
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.config.cssAssetId) {
|
||||
onWillStart(async () => {
|
||||
this.cssAsset = await getBundle(this.props.config.cssAssetId);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.config.embeddedComponents) {
|
||||
// TODO @phoenix: should readonly iframe with embedded components be supported?
|
||||
this.embeddedComponents = memoize((embeddedComponents = []) => {
|
||||
const result = {};
|
||||
for (const embedding of embeddedComponents) {
|
||||
result[embedding.name] = embedding;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.readonlyElementRef?.el) {
|
||||
this.mountComponents();
|
||||
}
|
||||
},
|
||||
() => [this.props.config.value.toString(), this.readonlyElementRef?.el]
|
||||
);
|
||||
this.tocManager = new TableOfContentManager(this.readonlyElementRef);
|
||||
}
|
||||
}
|
||||
|
||||
get showIframe() {
|
||||
return this.props.config.hasFullHtml || this.props.config.cssAssetId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows overrides to process the value used in the Html Viewer.
|
||||
* Typically, if the value comes from the html_field, it is already fixed
|
||||
* (invalid and obsolete elements were replaced). If used as a standalone,
|
||||
* the HtmlViewer has to handle invalid nodes and html upgrades.
|
||||
*
|
||||
* @param { string | Markup } value
|
||||
* @returns { string | Markup }
|
||||
*/
|
||||
formatValue(value) {
|
||||
let newVal = fixInvalidHTML(value);
|
||||
if (this.props.migrateHTML) {
|
||||
newVal = this.htmlUpgradeManager.processForUpgrade(newVal, {
|
||||
containsComplexHTML: this.props.config.hasFullHtml,
|
||||
env: this.env,
|
||||
});
|
||||
}
|
||||
if (instanceofMarkup(value)) {
|
||||
return markup(newVal);
|
||||
}
|
||||
return newVal;
|
||||
}
|
||||
|
||||
processReadonlyContent(container) {
|
||||
this.retargetLinks(container);
|
||||
this.applyAccessibilityAttributes(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that elements with accessibility editor attributes correctly get
|
||||
* the standard accessibility attribute (aria-label, role).
|
||||
*/
|
||||
applyAccessibilityAttributes(container) {
|
||||
for (const el of container.querySelectorAll("[data-oe-role]")) {
|
||||
el.setAttribute("role", el.dataset.oeRole);
|
||||
}
|
||||
for (const el of container.querySelectorAll("[data-oe-aria-label]")) {
|
||||
el.setAttribute("aria-label", el.dataset.oeAriaLabel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all links are opened in a new tab.
|
||||
*/
|
||||
retargetLinks(container) {
|
||||
for (const link of container.querySelectorAll("a")) {
|
||||
this.retargetLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
retargetLink(link) {
|
||||
link.setAttribute("target", "_blank");
|
||||
link.setAttribute("rel", "noreferrer");
|
||||
}
|
||||
|
||||
updateIframeContent(content) {
|
||||
const contentWindow = this.iframeRef.el.contentWindow;
|
||||
const iframeTarget = this.props.config.hasFullHtml
|
||||
? contentWindow.document.documentElement
|
||||
: contentWindow.document.querySelector("#iframe_target");
|
||||
iframeTarget.innerHTML = content;
|
||||
this.processReadonlyContent(iframeTarget);
|
||||
}
|
||||
|
||||
onLoadIframe(value) {
|
||||
const contentWindow = this.iframeRef.el.contentWindow;
|
||||
if (!this.props.config.hasFullHtml) {
|
||||
contentWindow.document.open("text/html", "replace").write(
|
||||
`<!DOCTYPE html><html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
|
||||
</head>
|
||||
<body class="o_in_iframe o_readonly" style="overflow: hidden;">
|
||||
<div id="iframe_target"></div>
|
||||
</body>
|
||||
</html>`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.cssAsset) {
|
||||
for (const cssLib of this.cssAsset.cssLibs) {
|
||||
const link = contentWindow.document.createElement("link");
|
||||
link.setAttribute("type", "text/css");
|
||||
link.setAttribute("rel", "stylesheet");
|
||||
link.setAttribute("href", cssLib);
|
||||
contentWindow.document.head.append(link);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateIframeContent(this.state.value);
|
||||
this.state.iframeVisible = true;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Embedded Components
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
destroyComponent({ root, host }) {
|
||||
const { getEditableDescendants } = this.getEmbedding(host);
|
||||
const editableDescendants = getEditableDescendants?.(host) || {};
|
||||
root.destroy();
|
||||
this.components.delete(arguments[0]);
|
||||
host.append(...Object.values(editableDescendants));
|
||||
}
|
||||
|
||||
destroyComponents() {
|
||||
for (const info of [...this.components]) {
|
||||
this.destroyComponent(info);
|
||||
}
|
||||
}
|
||||
|
||||
forEachEmbeddedComponentHost(elem, callback) {
|
||||
const selector = `[data-embedded]`;
|
||||
const targets = [...elem.querySelectorAll(selector)];
|
||||
if (elem.matches(selector)) {
|
||||
targets.unshift(elem);
|
||||
}
|
||||
for (const host of targets) {
|
||||
const embedding = this.getEmbedding(host);
|
||||
if (!embedding) {
|
||||
continue;
|
||||
}
|
||||
callback(host, embedding);
|
||||
}
|
||||
}
|
||||
|
||||
getEmbedding(host) {
|
||||
return this.embeddedComponents(this.props.config.embeddedComponents)[host.dataset.embedded];
|
||||
}
|
||||
|
||||
setupNewComponent({ name, env, props }) {
|
||||
if (name === "tableOfContent") {
|
||||
Object.assign(props, {
|
||||
manager: this.tocManager,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mountComponent(host, { Component, getEditableDescendants, getProps, name }) {
|
||||
const props = getProps?.(host) || {};
|
||||
// TODO ABD TODO @phoenix: check if there is too much info in the htmlViewer env.
|
||||
// i.e.: env has X because of parent component,
|
||||
// embedded component descendant sometimes uses X from env which is set conditionally:
|
||||
// -> it will override the one one from the parent => OK.
|
||||
// -> it will not => the embedded component still has X in env because of its ancestors => Issue.
|
||||
const env = Object.create(this.env);
|
||||
if (getEditableDescendants) {
|
||||
env.getEditableDescendants = getEditableDescendants;
|
||||
}
|
||||
this.setupNewComponent({
|
||||
name,
|
||||
env,
|
||||
props,
|
||||
});
|
||||
const root = this.__owl__.app.createRoot(Component, {
|
||||
props,
|
||||
env,
|
||||
});
|
||||
const promise = root.mount(host);
|
||||
// Don't show mounting errors as they will happen often when the host
|
||||
// is disconnected from the DOM because of a patch
|
||||
promise.catch();
|
||||
// Patch mount fiber to hook into the exact call stack where root is
|
||||
// mounted (but before). This will remove host children synchronously
|
||||
// just before adding the root rendered html.
|
||||
const fiber = root.node.fiber;
|
||||
const fiberComplete = fiber.complete;
|
||||
fiber.complete = function () {
|
||||
host.replaceChildren();
|
||||
fiberComplete.call(this);
|
||||
};
|
||||
const info = {
|
||||
root,
|
||||
host,
|
||||
};
|
||||
this.components.add(info);
|
||||
}
|
||||
|
||||
mountComponents() {
|
||||
this.forEachEmbeddedComponentHost(this.readonlyElementRef.el, (host, embedding) => {
|
||||
this.mountComponent(host, embedding);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.HtmlViewer">
|
||||
<t t-if="this.showIframe">
|
||||
<iframe t-ref="iframe"
|
||||
t-att-class="{'d-none': !this.state.iframeVisible, 'o_readonly': true}"
|
||||
t-att-sandbox="props.config.hasFullHtml ? 'allow-same-origin allow-popups allow-popups-to-escape-sandbox' : false"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div t-ref="readonlyContent" class="o_readonly" t-out="state.value" />
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
const NO_OP = () => {};
|
||||
|
||||
export class Switch extends Component {
|
||||
static props = {
|
||||
value: { type: Boolean, optional: true },
|
||||
extraClasses: String,
|
||||
disabled: { type: Boolean, optional: true },
|
||||
label: { type: String, optional: true },
|
||||
description: { type: String, optional: true },
|
||||
onChange: { Function, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
onChange: NO_OP,
|
||||
};
|
||||
static template = xml`
|
||||
<label t-att-class="'o_switch' + extraClasses">
|
||||
<input type="checkbox"
|
||||
name="switch"
|
||||
class="visually-hidden"
|
||||
t-att-checked="props.value"
|
||||
t-att-disabled="props.disabled"
|
||||
t-on-change="(ev) => props.onChange(ev.target.checked)"
|
||||
t-on-keyup="onKeyup"/>
|
||||
<span/>
|
||||
<span t-if="props.label" t-esc="props.label" class="ms-2"/>
|
||||
<span t-if="props.description" class="text-muted ms-2" t-esc="props.description"/>
|
||||
</label>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
this.extraClasses = this.props.extraClasses ? ` ${this.props.extraClasses}` : "";
|
||||
}
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onKeyup(ev) {
|
||||
// "Enter" is not a default on checkboxes, but as the switch doesn't
|
||||
// look like a checkbox anymore, we support it.
|
||||
if (ev.key === "Enter") {
|
||||
ev.currentTarget.checked = !ev.currentTarget.checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
$o-we-switch-size: 1.2em !default;
|
||||
$o-we-switch-inactive-color: rgba($text-muted, 0.4) !default;
|
||||
.o_switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
|
||||
&.o_switch_disabled {
|
||||
opacity: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> input {
|
||||
&:focus + span {
|
||||
box-shadow: 0 0 0 3px lighten($o-brand-primary, 30%);
|
||||
}
|
||||
|
||||
+ span {
|
||||
border-radius: $o-we-switch-size;
|
||||
width: $o-we-switch-size * 1.7;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
background-color: $o-we-switch-inactive-color;
|
||||
font-size: $o-we-switch-size * 1.09;
|
||||
line-height: $o-we-switch-size;
|
||||
color: $o-we-switch-inactive-color;
|
||||
transition: all 0.2s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
|
||||
&:after {
|
||||
content: "\f057"; // fa-times-circle
|
||||
font-family: 'FontAwesome';
|
||||
color: white;
|
||||
transition: all 0.2s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&:checked + span {
|
||||
background: $o-brand-primary;
|
||||
|
||||
&:after {
|
||||
content: "\f058"; // fa-check-circle
|
||||
margin-left: ($o-we-switch-size * 1.7) - $o-we-switch-size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.o_switch_danger_success {
|
||||
> input {
|
||||
&:not(:checked) + span {
|
||||
background: $o-we-color-danger;
|
||||
}
|
||||
&:checked + span {
|
||||
background: $o-we-color-success;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import {
|
||||
containsAnyNonPhrasingContent,
|
||||
getDeepestPosition,
|
||||
isContentEditable,
|
||||
isElement,
|
||||
isEmpty,
|
||||
isMediaElement,
|
||||
isProtected,
|
||||
isProtecting,
|
||||
} from "@html_editor/utils/dom_info";
|
||||
import { Plugin } from "../plugin";
|
||||
import { fillEmpty } from "@html_editor/utils/dom";
|
||||
import {
|
||||
BASE_CONTAINER_CLASS,
|
||||
SUPPORTED_BASE_CONTAINER_NAMES,
|
||||
baseContainerGlobalSelector,
|
||||
createBaseContainer,
|
||||
} from "../utils/base_container";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
import { selectElements } from "@html_editor/utils/dom_traversal";
|
||||
import { childNodeIndex } from "@html_editor/utils/position";
|
||||
|
||||
/**
|
||||
* @typedef { Object } BaseContainerShared
|
||||
* @property { BaseContainerPlugin['createBaseContainer'] } createBaseContainer
|
||||
* @property { BaseContainerPlugin['getDefaultNodeName'] } getDefaultNodeName
|
||||
* @property { BaseContainerPlugin['isCandidateForBaseContainer'] } isCandidateForBaseContainer
|
||||
*/
|
||||
|
||||
export class BaseContainerPlugin extends Plugin {
|
||||
static id = "baseContainer";
|
||||
static shared = ["createBaseContainer", "getDefaultNodeName", "isCandidateForBaseContainer"];
|
||||
static defaultConfig = {
|
||||
baseContainers: ["P", "DIV"],
|
||||
};
|
||||
static dependencies = ["selection"];
|
||||
/**
|
||||
* Register one of the predicates for `invalid_for_base_container_predicates`
|
||||
* as a property for optimization, see variants of `isCandidateForBaseContainer`.
|
||||
*/
|
||||
hasNonPhrasingContentPredicate = (element) =>
|
||||
element?.nodeType === Node.ELEMENT_NODE && containsAnyNonPhrasingContent(element);
|
||||
/**
|
||||
* The `unsplittable` predicate for `invalid_for_base_container_predicates`
|
||||
* is defined in this file and not in split_plugin because it has to be removed
|
||||
* in a specific case: see `isCandidateForBaseContainerAllowUnsplittable`.
|
||||
*/
|
||||
isUnsplittablePredicate = (element) =>
|
||||
this.getResource("unsplittable_node_predicates").some((fn) => fn(element));
|
||||
resources = {
|
||||
clean_for_save_handlers: this.cleanForSave.bind(this),
|
||||
// `baseContainer` normalization should occur after every other normalization
|
||||
// because a `div` may only have the baseContainer identity if it does not
|
||||
// already have another incompatible identity given by another plugin.
|
||||
normalize_handlers: withSequence(Infinity, this.normalizeDivBaseContainers.bind(this)),
|
||||
delete_handlers: () => {
|
||||
if (this.config.cleanEmptyStructuralContainers === false) {
|
||||
return;
|
||||
}
|
||||
this.cleanEmptyStructuralContainers();
|
||||
},
|
||||
unsplittable_node_predicates: (node) => {
|
||||
if (node.nodeName !== "DIV") {
|
||||
return false;
|
||||
}
|
||||
return !this.isCandidateForBaseContainerAllowUnsplittable(node);
|
||||
},
|
||||
invalid_for_base_container_predicates: [
|
||||
(node) =>
|
||||
!node ||
|
||||
node.nodeType !== Node.ELEMENT_NODE ||
|
||||
!SUPPORTED_BASE_CONTAINER_NAMES.includes(node.tagName) ||
|
||||
isProtected(node) ||
|
||||
isProtecting(node) ||
|
||||
isMediaElement(node),
|
||||
this.isUnsplittablePredicate,
|
||||
this.hasNonPhrasingContentPredicate,
|
||||
],
|
||||
system_classes: [BASE_CONTAINER_CLASS],
|
||||
};
|
||||
|
||||
createBaseContainer(nodeName = this.getDefaultNodeName()) {
|
||||
return createBaseContainer(nodeName, this.document);
|
||||
}
|
||||
|
||||
getDefaultNodeName() {
|
||||
return this.config.baseContainers[0];
|
||||
}
|
||||
|
||||
cleanEmptyStructuralContainers() {
|
||||
const node = this.document.getSelection().anchorNode;
|
||||
|
||||
if (!isElement(node) || !isEmpty(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closestEditable = (n) =>
|
||||
isContentEditable(n.parentElement) ? closestEditable(n.parentElement) : n;
|
||||
|
||||
const isUnsplittable = this.isUnsplittablePredicate(node);
|
||||
const isCandidateForBase = this.isCandidateForBaseContainerAllowUnsplittable(node);
|
||||
|
||||
if (isUnsplittable || !isCandidateForBase) {
|
||||
return;
|
||||
}
|
||||
|
||||
let anchorNode = node.parentElement;
|
||||
if (
|
||||
anchorNode === closestEditable(node) ||
|
||||
!SUPPORTED_BASE_CONTAINER_NAMES.includes(anchorNode.nodeName) ||
|
||||
this.getResource("unremovable_node_predicates").some((p) => p(anchorNode))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(anchorNode)) {
|
||||
fillEmpty(anchorNode);
|
||||
}
|
||||
|
||||
let anchorOffset = childNodeIndex(node);
|
||||
node.remove();
|
||||
|
||||
[anchorNode, anchorOffset] = getDeepestPosition(anchorNode, anchorOffset);
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode,
|
||||
anchorOffset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if an element is eligible to become a baseContainer (i.e. an
|
||||
* unmarked div which could receive baseContainer attributes to inherit
|
||||
* paragraph-like features).
|
||||
*
|
||||
* This function considers unsplittable and childNodes.
|
||||
*/
|
||||
isCandidateForBaseContainer(element) {
|
||||
return !this.getResource("invalid_for_base_container_predicates").some((fn) => fn(element));
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if an element would be eligible to become a baseContainer
|
||||
* without considering unsplittable.
|
||||
*
|
||||
* This function is only meant to be used during `unsplittable_node_predicates` to
|
||||
* avoid an infinite loop:
|
||||
* Considering a `DIV`,
|
||||
* - During `unsplittable_node_predicates`, one predicate should return true
|
||||
* if the `DIV` is NOT a baseContainer candidate (Odoo specification),
|
||||
* therefore `invalid_for_base_container_predicates` should be evaluated.
|
||||
* - During `invalid_for_base_container_predicates`, one predicate should
|
||||
* return true if the `DIV` is unsplittable, because a node has to be
|
||||
* splittable to use the featureSet associated with paragraphs.
|
||||
* Each resource has to call the other. To avoid the issue, during
|
||||
* `unsplittable_node_predicates`, the baseContainer predicate will execute
|
||||
* all predicates for `invalid_for_base_container_predicates` except
|
||||
* the one using `unsplittable_node_predicates`, since it is already being
|
||||
* evaluated.
|
||||
*
|
||||
* In simpler terms:
|
||||
* A `DIV` is unsplittable by default;
|
||||
* UNLESS it is eligible to be a baseContainer => it becomes one;
|
||||
* UNLESS it has to be unsplittable for an explicit reason (i.e. has class
|
||||
* oe_unbreakable) => it stays unsplittable.
|
||||
*/
|
||||
isCandidateForBaseContainerAllowUnsplittable(element) {
|
||||
const predicates = new Set(this.getResource("invalid_for_base_container_predicates"));
|
||||
predicates.delete(this.isUnsplittablePredicate);
|
||||
for (const predicate of predicates) {
|
||||
if (predicate(element)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if an element would be eligible to become a baseContainer
|
||||
* without considering its childNodes.
|
||||
*
|
||||
* This function is only meant to be used internally, to avoid having to
|
||||
* compute childNodes multiple times in more complex operations.
|
||||
*/
|
||||
shallowIsCandidateForBaseContainer(element) {
|
||||
const predicates = new Set(this.getResource("invalid_for_base_container_predicates"));
|
||||
predicates.delete(this.hasNonPhrasingContentPredicate);
|
||||
for (const predicate of predicates) {
|
||||
if (predicate(element)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
cleanForSave({ root }) {
|
||||
for (const baseContainer of selectElements(root, `.${BASE_CONTAINER_CLASS}`)) {
|
||||
baseContainer.classList.remove(BASE_CONTAINER_CLASS);
|
||||
if (baseContainer.classList.length === 0) {
|
||||
baseContainer.removeAttribute("class");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDivBaseContainers(element = this.editable) {
|
||||
const newBaseContainers = [];
|
||||
const divSelector = `div:not(.${BASE_CONTAINER_CLASS})`;
|
||||
const targets = [...element.querySelectorAll(divSelector)];
|
||||
if (element.matches(divSelector)) {
|
||||
targets.unshift(element);
|
||||
}
|
||||
for (const div of targets) {
|
||||
if (
|
||||
// Ensure that newly created `div` baseContainers are never themselves
|
||||
// children of a baseContainer. BaseContainers should always only
|
||||
// contain phrasing content (even `div`), because they could be
|
||||
// converted to an element which can actually only contain phrasing
|
||||
// content. In practice a div should never be a child of a
|
||||
// baseContainer, since a baseContainer should only contain
|
||||
// phrasingContent.
|
||||
!div.parentElement?.matches(baseContainerGlobalSelector) &&
|
||||
this.shallowIsCandidateForBaseContainer(div) &&
|
||||
!containsAnyNonPhrasingContent(div)
|
||||
) {
|
||||
div.classList.add(BASE_CONTAINER_CLASS);
|
||||
newBaseContainers.push(div);
|
||||
fillEmpty(div);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,720 @@
|
|||
import { isTextNode, isParagraphRelatedElement, isEmptyBlock } from "../utils/dom_info";
|
||||
import { Plugin } from "../plugin";
|
||||
import { closestBlock } from "../utils/blocks";
|
||||
import { unwrapContents, wrapInlinesInBlocks, splitTextNode, fillEmpty } from "../utils/dom";
|
||||
import { childNodes, closestElement } from "../utils/dom_traversal";
|
||||
import { parseHTML } from "../utils/html";
|
||||
import {
|
||||
baseContainerGlobalSelector,
|
||||
getBaseContainerSelector,
|
||||
} from "@html_editor/utils/base_container";
|
||||
import { DIRECTIONS } from "../utils/position";
|
||||
import { isHtmlContentSupported } from "./selection_plugin";
|
||||
|
||||
/**
|
||||
* @typedef { import("./selection_plugin").EditorSelection } EditorSelection
|
||||
*/
|
||||
|
||||
const CLIPBOARD_BLACKLISTS = {
|
||||
unwrap: [
|
||||
// These elements' children will be unwrapped.
|
||||
".Apple-interchange-newline",
|
||||
"DIV", // DIV is unwrapped unless eligible to be a baseContainer, see cleanForPaste
|
||||
],
|
||||
remove: ["META", "STYLE", "SCRIPT"], // These elements will be removed along with their children.
|
||||
};
|
||||
export const CLIPBOARD_WHITELISTS = {
|
||||
nodes: [
|
||||
// Style
|
||||
"P",
|
||||
"H1",
|
||||
"H2",
|
||||
"H3",
|
||||
"H4",
|
||||
"H5",
|
||||
"H6",
|
||||
"BLOCKQUOTE",
|
||||
"PRE",
|
||||
// List
|
||||
"UL",
|
||||
"OL",
|
||||
"LI",
|
||||
// Inline style
|
||||
"I",
|
||||
"B",
|
||||
"U",
|
||||
"S",
|
||||
"EM",
|
||||
"FONT",
|
||||
"STRONG",
|
||||
// Table
|
||||
"TABLE",
|
||||
"THEAD",
|
||||
"TH",
|
||||
"TBODY",
|
||||
"TR",
|
||||
"TD",
|
||||
// Miscellaneous
|
||||
"IMG",
|
||||
"BR",
|
||||
"A",
|
||||
".fa",
|
||||
],
|
||||
classes: [
|
||||
// Media
|
||||
/^float-/,
|
||||
"d-block",
|
||||
"mx-auto",
|
||||
"img-fluid",
|
||||
"img-thumbnail",
|
||||
"rounded",
|
||||
"rounded-circle",
|
||||
"table",
|
||||
"table-bordered",
|
||||
/^padding-/,
|
||||
/^shadow/,
|
||||
// Odoo colors
|
||||
/^text-o-/,
|
||||
/^bg-o-/,
|
||||
// Odoo lists
|
||||
"o_checked",
|
||||
"o_checklist",
|
||||
"oe-nested",
|
||||
// Miscellaneous
|
||||
/^btn/,
|
||||
/^fa/,
|
||||
],
|
||||
attributes: ["class", "href", "src", "target"],
|
||||
styledTags: ["SPAN", "B", "STRONG", "I", "S", "U", "FONT", "TD"],
|
||||
};
|
||||
|
||||
const ONLY_LINK_REGEX = /^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/[\w-./?%&=]*)?$/i;
|
||||
|
||||
/**
|
||||
* @typedef {Object} ClipboardShared
|
||||
* @property {ClipboardPlugin['pasteText']} pasteText
|
||||
*/
|
||||
|
||||
export class ClipboardPlugin extends Plugin {
|
||||
static id = "clipboard";
|
||||
static dependencies = [
|
||||
"baseContainer",
|
||||
"dom",
|
||||
"selection",
|
||||
"sanitize",
|
||||
"history",
|
||||
"split",
|
||||
"delete",
|
||||
"lineBreak",
|
||||
];
|
||||
static shared = ["pasteText"];
|
||||
|
||||
setup() {
|
||||
this.addDomListener(this.editable, "copy", this.onCopy);
|
||||
this.addDomListener(this.editable, "cut", this.onCut);
|
||||
this.addDomListener(this.editable, "paste", this.onPaste);
|
||||
this.addDomListener(this.editable, "dragstart", this.onDragStart);
|
||||
this.addDomListener(this.editable, "drop", this.onDrop);
|
||||
}
|
||||
|
||||
onCut(ev) {
|
||||
this.onCopy(ev);
|
||||
this.dependencies.history.stageSelection();
|
||||
this.dependencies.delete.deleteSelection();
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ClipboardEvent} ev
|
||||
*/
|
||||
onCopy(ev) {
|
||||
ev.preventDefault();
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
let clonedContents = selection.cloneContents();
|
||||
if (!clonedContents.hasChildNodes()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare text content for clipboard.
|
||||
let textContent = selection.textContent();
|
||||
for (const processor of this.getResource("clipboard_text_processors")) {
|
||||
textContent = processor(textContent);
|
||||
}
|
||||
ev.clipboardData.setData("text/plain", textContent);
|
||||
|
||||
// Prepare html content for clipboard.
|
||||
for (const processor of this.getResource("clipboard_content_processors")) {
|
||||
clonedContents = processor(clonedContents, selection) || clonedContents;
|
||||
}
|
||||
this.dependencies.dom.removeSystemProperties(clonedContents);
|
||||
const dataHtmlElement = this.document.createElement("data");
|
||||
dataHtmlElement.append(clonedContents);
|
||||
prependOriginToImages(dataHtmlElement, window.location.origin);
|
||||
const htmlContent = dataHtmlElement.innerHTML;
|
||||
ev.clipboardData.setData("text/html", htmlContent);
|
||||
ev.clipboardData.setData("application/vnd.odoo.odoo-editor", htmlContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle safe pasting of html or plain text into the editor.
|
||||
*/
|
||||
onPaste(ev) {
|
||||
let selection = this.dependencies.selection.getEditableSelection();
|
||||
if (
|
||||
!selection.anchorNode.isConnected ||
|
||||
!closestElement(selection.anchorNode).isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
|
||||
this.dependencies.history.stageSelection();
|
||||
|
||||
this.dispatchTo("before_paste_handlers", selection, ev);
|
||||
// refresh selection after potential changes from `before_paste` handlers
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
|
||||
this.handlePasteUnsupportedHtml(selection, ev.clipboardData) ||
|
||||
this.handlePasteOdooEditorHtml(ev.clipboardData) ||
|
||||
this.handlePasteHtml(selection, ev.clipboardData) ||
|
||||
this.handlePasteText(selection, ev.clipboardData);
|
||||
|
||||
this.dispatchTo("after_paste_handlers", selection);
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
/**
|
||||
* @param {EditorSelection} selection
|
||||
* @param {DataTransfer} clipboardData
|
||||
*/
|
||||
handlePasteUnsupportedHtml(selection, clipboardData) {
|
||||
if (!isHtmlContentSupported(selection)) {
|
||||
const text = clipboardData.getData("text/plain");
|
||||
this.dependencies.dom.insert(text);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {DataTransfer} clipboardData
|
||||
*/
|
||||
handlePasteOdooEditorHtml(clipboardData) {
|
||||
const odooEditorHtml = clipboardData.getData("application/vnd.odoo.odoo-editor");
|
||||
const textContent = clipboardData.getData("text/plain");
|
||||
if (ONLY_LINK_REGEX.test(textContent)) {
|
||||
return false;
|
||||
}
|
||||
if (odooEditorHtml) {
|
||||
const fragment = parseHTML(this.document, odooEditorHtml);
|
||||
this.dependencies.sanitize.sanitize(fragment);
|
||||
if (fragment.hasChildNodes()) {
|
||||
this.dependencies.dom.insert(fragment);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {EditorSelection} selection
|
||||
* @param {DataTransfer} clipboardData
|
||||
*/
|
||||
handlePasteHtml(selection, clipboardData) {
|
||||
const files = this.delegateTo("bypass_paste_image_files")
|
||||
? []
|
||||
: getImageFiles(clipboardData);
|
||||
const clipboardHtml = clipboardData.getData("text/html");
|
||||
const textContent = clipboardData.getData("text/plain");
|
||||
if (ONLY_LINK_REGEX.test(textContent)) {
|
||||
return false;
|
||||
}
|
||||
if (files.length || clipboardHtml) {
|
||||
const clipboardElem = this.prepareClipboardData(clipboardHtml);
|
||||
// @phoenix @todo: should it be handled in table plugin?
|
||||
// When copy pasting a table from the outside, a picture of the
|
||||
// table can be included in the clipboard as an image file. In that
|
||||
// particular case the html table is given a higher priority than
|
||||
// the clipboard picture.
|
||||
if (files.length && !clipboardElem.querySelector("table")) {
|
||||
// @phoenix @todo: should it be handled in image plugin?
|
||||
return this.addImagesFiles(files).then((html) => {
|
||||
this.dependencies.dom.insert(html);
|
||||
this.dependencies.history.addStep();
|
||||
});
|
||||
} else if (clipboardElem.hasChildNodes()) {
|
||||
if (closestElement(selection.anchorNode, "a")) {
|
||||
this.dependencies.dom.insert(clipboardElem.textContent);
|
||||
} else {
|
||||
this.dependencies.dom.insert(clipboardElem);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {EditorSelection} selection
|
||||
* @param {DataTransfer} clipboardData
|
||||
*/
|
||||
handlePasteText(selection, clipboardData) {
|
||||
const text = clipboardData.getData("text/plain");
|
||||
if (this.delegateTo("paste_text_overrides", selection, text)) {
|
||||
return;
|
||||
} else {
|
||||
this.pasteText(text);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
pasteText(text) {
|
||||
const textFragments = text.split(/\r?\n/);
|
||||
let selection = this.dependencies.selection.getEditableSelection();
|
||||
const preEl = closestElement(selection.anchorNode, "PRE");
|
||||
let textIndex = 1;
|
||||
for (const textFragment of textFragments) {
|
||||
let modifiedTextFragment = textFragment;
|
||||
|
||||
// <pre> preserves whitespace by default, so no need for  .
|
||||
if (!preEl) {
|
||||
// Replace consecutive spaces by alternating nbsp.
|
||||
modifiedTextFragment = textFragment.replace(/( {2,})/g, (match) => {
|
||||
let alternateValue = false;
|
||||
return match.replace(/ /g, () => {
|
||||
alternateValue = !alternateValue;
|
||||
const replaceContent = alternateValue ? "\u00A0" : " ";
|
||||
return replaceContent;
|
||||
});
|
||||
});
|
||||
}
|
||||
this.dependencies.dom.insert(modifiedTextFragment);
|
||||
if (textIndex < textFragments.length) {
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
// Break line by inserting new paragraph and
|
||||
// remove current paragraph's bottom margin.
|
||||
const block = closestBlock(selection.anchorNode);
|
||||
if (
|
||||
this.dependencies.split.isUnsplittable(block) ||
|
||||
closestElement(selection.anchorNode).tagName === "PRE"
|
||||
) {
|
||||
this.dependencies.lineBreak.insertLineBreak();
|
||||
} else {
|
||||
const [blockBefore] = this.dependencies.split.splitBlock();
|
||||
if (
|
||||
block &&
|
||||
block.matches(baseContainerGlobalSelector) &&
|
||||
blockBefore &&
|
||||
!blockBefore.matches(getBaseContainerSelector("DIV"))
|
||||
) {
|
||||
// Do something only if blockBefore is not a DIV (which is the no-margin option)
|
||||
// replace blockBefore by a DIV.
|
||||
const div = this.dependencies.baseContainer.createBaseContainer("DIV");
|
||||
const cursors = this.dependencies.selection.preserveSelection();
|
||||
blockBefore.before(div);
|
||||
div.replaceChildren(...childNodes(blockBefore));
|
||||
blockBefore.remove();
|
||||
cursors.remapNode(blockBefore, div).restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
textIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare clipboard data (text/html) for safe pasting into the editor.
|
||||
*
|
||||
* @private
|
||||
* @param {string} clipboardData
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
prepareClipboardData(clipboardData) {
|
||||
const fragment = parseHTML(this.document, clipboardData);
|
||||
this.dependencies.sanitize.sanitize(fragment);
|
||||
const container = this.document.createElement("fake-container");
|
||||
container.append(fragment);
|
||||
|
||||
for (const tableElement of container.querySelectorAll("table")) {
|
||||
tableElement.classList.add("table", "table-bordered", "o_table");
|
||||
}
|
||||
if (this.delegateTo("bypass_paste_image_files")) {
|
||||
for (const imgElement of container.querySelectorAll("img")) {
|
||||
imgElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// todo: should it be in its own plugin ?
|
||||
const progId = container.querySelector('meta[name="ProgId"]');
|
||||
if (progId && progId.content === "Excel.Sheet") {
|
||||
// Microsoft Excel keeps table style in a <style> tag with custom
|
||||
// classes. The following lines parse that style and apply it to the
|
||||
// style attribute of <td> tags with matching classes.
|
||||
const xlStylesheet = container.querySelector("style");
|
||||
const xlNodes = container.querySelectorAll("[class*=xl],[class*=font]");
|
||||
for (const xlNode of xlNodes) {
|
||||
for (const xlClass of xlNode.classList) {
|
||||
// Regex captures a CSS rule definition for that xlClass.
|
||||
const xlStyle = xlStylesheet.textContent
|
||||
.match(`.${xlClass}[^{]*{(?<xlStyle>[^}]*)}`)
|
||||
.groups.xlStyle.replace("background:", "background-color:");
|
||||
xlNode.setAttribute("style", xlNode.style.cssText + ";" + xlStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
const childContent = childNodes(container);
|
||||
for (const child of childContent) {
|
||||
this.cleanForPaste(child);
|
||||
}
|
||||
// Identify the closest baseContainer from the selection. This will
|
||||
// determine which baseContainer will be used by default for the
|
||||
// clipboard content if it has to be modified.
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
const closestBaseContainer =
|
||||
selection.anchorNode &&
|
||||
closestElement(selection.anchorNode, baseContainerGlobalSelector);
|
||||
// Force inline nodes at the root of the container into separate `baseContainers`
|
||||
// elements. This is a tradeoff to ensure some features that rely on
|
||||
// nodes having a parent (e.g. convert to list, title, etc.) can work
|
||||
// properly on such nodes without having to actually handle that
|
||||
// particular case in all of those functions. In fact, this case cannot
|
||||
// happen on a new document created using this editor, but will happen
|
||||
// instantly when editing a document that was created from Etherpad.
|
||||
wrapInlinesInBlocks(container, {
|
||||
baseContainerNodeName:
|
||||
closestBaseContainer?.nodeName ||
|
||||
this.dependencies.baseContainer.getDefaultNodeName(),
|
||||
});
|
||||
const result = this.document.createDocumentFragment();
|
||||
result.replaceChildren(...childNodes(container));
|
||||
|
||||
// Split elements containing <br> into separate elements for each line.
|
||||
const brs = result.querySelectorAll("br");
|
||||
for (const br of brs) {
|
||||
const block = closestBlock(br);
|
||||
if (
|
||||
(isParagraphRelatedElement(block) ||
|
||||
this.dependencies.baseContainer.isCandidateForBaseContainer(block)) &&
|
||||
block.nodeName !== "PRE"
|
||||
) {
|
||||
// A linebreak at the beginning of a block is an empty line.
|
||||
const isEmptyLine = block.firstChild.nodeName === "BR";
|
||||
// Split blocks around it until only the BR remains in the
|
||||
// block.
|
||||
const remainingBrContainer = this.dependencies.split.splitAroundUntil(br, block);
|
||||
// Remove the container unless it represented an empty line.
|
||||
if (!isEmptyLine) {
|
||||
remainingBrContainer.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Clean a node for safely pasting. Cleaning an element involves unwrapping
|
||||
* its contents if it's an illegal (blacklisted or not whitelisted) element,
|
||||
* or removing its illegal attributes and classes.
|
||||
*
|
||||
* @param {Node} node
|
||||
*/
|
||||
cleanForPaste(node) {
|
||||
if (
|
||||
!this.isWhitelisted(node) ||
|
||||
this.isBlacklisted(node) ||
|
||||
// Google Docs have their html inside a B tag with custom id.
|
||||
(node.id && node.id.startsWith("docs-internal-guid"))
|
||||
) {
|
||||
if (!node.matches || node.matches(CLIPBOARD_BLACKLISTS.remove.join(","))) {
|
||||
node.remove();
|
||||
} else {
|
||||
let childrenNodes;
|
||||
if (node.nodeName === "DIV") {
|
||||
if (!node.hasChildNodes()) {
|
||||
node.remove();
|
||||
return;
|
||||
} else if (this.dependencies.baseContainer.isCandidateForBaseContainer(node)) {
|
||||
const whiteSpace = node.style?.whiteSpace;
|
||||
if (whiteSpace && !["normal", "nowrap"].includes(whiteSpace)) {
|
||||
node.innerHTML = node.innerHTML.replace(/\n/g, "<br>");
|
||||
}
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
const dir = node.getAttribute("dir");
|
||||
if (dir) {
|
||||
baseContainer.setAttribute("dir", dir);
|
||||
}
|
||||
baseContainer.append(...node.childNodes);
|
||||
|
||||
node.replaceWith(baseContainer);
|
||||
childrenNodes = childNodes(baseContainer);
|
||||
} else {
|
||||
childrenNodes = unwrapContents(node);
|
||||
}
|
||||
} else {
|
||||
// Unwrap the illegal node's contents.
|
||||
childrenNodes = unwrapContents(node);
|
||||
}
|
||||
for (const child of childrenNodes) {
|
||||
this.cleanForPaste(child);
|
||||
}
|
||||
}
|
||||
} else if (node.nodeType !== Node.TEXT_NODE) {
|
||||
if (node.nodeName === "THEAD") {
|
||||
const tbody = node.nextElementSibling;
|
||||
if (tbody) {
|
||||
// If a <tbody> already exists, move all rows from
|
||||
// <thead> into the start of <tbody>.
|
||||
tbody.prepend(...node.children);
|
||||
node.remove();
|
||||
node = tbody;
|
||||
} else {
|
||||
// Otherwise, replace the <thead> with <tbody>
|
||||
node = this.dependencies.dom.setTagName(node, "TBODY");
|
||||
}
|
||||
} else if (["TD", "TH"].includes(node.nodeName)) {
|
||||
// Insert base container into empty TD.
|
||||
if (isEmptyBlock(node)) {
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
fillEmpty(baseContainer);
|
||||
node.replaceChildren(baseContainer);
|
||||
}
|
||||
|
||||
if (node.hasAttribute("bgcolor") && !node.style["background-color"]) {
|
||||
node.style["background-color"] = node.getAttribute("bgcolor");
|
||||
}
|
||||
} else if (node.nodeName === "FONT") {
|
||||
// FONT tags have some style information in custom attributes,
|
||||
// this maps them to the style attribute.
|
||||
if (node.hasAttribute("color") && !node.style["color"]) {
|
||||
node.style["color"] = node.getAttribute("color");
|
||||
}
|
||||
if (node.hasAttribute("size") && !node.style["font-size"]) {
|
||||
// FONT size uses non-standard numeric values.
|
||||
node.style["font-size"] = +node.getAttribute("size") + 10 + "pt";
|
||||
}
|
||||
} else if (
|
||||
["S", "U"].includes(node.nodeName) &&
|
||||
childNodes(node).length === 1 &&
|
||||
node.firstChild.nodeName === "FONT"
|
||||
) {
|
||||
// S and U tags sometimes contain FONT tags. We prefer the
|
||||
// strike to adopt the style of the text, so we invert them.
|
||||
const fontNode = node.firstChild;
|
||||
node.before(fontNode);
|
||||
node.replaceChildren(...childNodes(fontNode));
|
||||
fontNode.appendChild(node);
|
||||
} else if (
|
||||
node.nodeName === "IMG" &&
|
||||
node.getAttribute("aria-roledescription") === "checkbox"
|
||||
) {
|
||||
const checklist = node.closest("ul");
|
||||
const closestLi = node.closest("li");
|
||||
if (checklist) {
|
||||
checklist.classList.add("o_checklist");
|
||||
if (node.getAttribute("alt") === "checked") {
|
||||
closestLi.classList.add("o_checked");
|
||||
}
|
||||
node.remove();
|
||||
node = checklist;
|
||||
}
|
||||
}
|
||||
// Remove all illegal attributes and classes from the node, then
|
||||
// clean its children.
|
||||
for (const attribute of [...node.attributes]) {
|
||||
// Keep allowed styles on nodes with allowed tags.
|
||||
// todo: should the whitelist be a resource?
|
||||
if (
|
||||
CLIPBOARD_WHITELISTS.styledTags.includes(node.nodeName) &&
|
||||
attribute.name === "style"
|
||||
) {
|
||||
node.removeAttribute(attribute.name);
|
||||
if (["SPAN", "FONT"].includes(node.tagName)) {
|
||||
for (const unwrappedNode of unwrapContents(node)) {
|
||||
this.cleanForPaste(unwrappedNode);
|
||||
}
|
||||
}
|
||||
} else if (!this.isWhitelisted(attribute)) {
|
||||
node.removeAttribute(attribute.name);
|
||||
}
|
||||
}
|
||||
for (const klass of [...node.classList]) {
|
||||
if (!this.isWhitelisted(klass)) {
|
||||
node.classList.remove(klass);
|
||||
}
|
||||
}
|
||||
for (const child of childNodes(node)) {
|
||||
this.cleanForPaste(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Return true if the given attribute, class or node is whitelisted for
|
||||
* pasting, false otherwise.
|
||||
*
|
||||
* @private
|
||||
* @param {Attr | string | Node} item
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isWhitelisted(item) {
|
||||
if (item.nodeType === Node.ATTRIBUTE_NODE) {
|
||||
return CLIPBOARD_WHITELISTS.attributes.includes(item.name);
|
||||
} else if (typeof item === "string") {
|
||||
return CLIPBOARD_WHITELISTS.classes.some((okClass) =>
|
||||
okClass instanceof RegExp ? okClass.test(item) : okClass === item
|
||||
);
|
||||
} else {
|
||||
return isTextNode(item) || item.matches?.(CLIPBOARD_WHITELISTS.nodes.join(","));
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Return true if the given node is blacklisted for pasting, false
|
||||
* otherwise.
|
||||
*
|
||||
* @private
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isBlacklisted(node) {
|
||||
return (
|
||||
!isTextNode(node) &&
|
||||
node.matches([].concat(...Object.values(CLIPBOARD_BLACKLISTS)).join(","))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
onDragStart(ev) {
|
||||
if (ev.target.nodeName === "IMG") {
|
||||
this.dragImage = ev.target instanceof HTMLElement && ev.target;
|
||||
ev.dataTransfer.setData(
|
||||
"application/vnd.odoo.odoo-editor-node",
|
||||
this.dragImage.outerHTML
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Handle safe dropping of html into the editor.
|
||||
*
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
async onDrop(ev) {
|
||||
ev.preventDefault();
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
if (!isHtmlContentSupported(selection)) {
|
||||
return;
|
||||
}
|
||||
const nodeToSplit =
|
||||
selection.direction === DIRECTIONS.RIGHT ? selection.focusNode : selection.anchorNode;
|
||||
const offsetToSplit =
|
||||
selection.direction === DIRECTIONS.RIGHT
|
||||
? selection.focusOffset
|
||||
: selection.anchorOffset;
|
||||
if (nodeToSplit.nodeType === Node.TEXT_NODE && !selection.isCollapsed) {
|
||||
const selectionToRestore = this.dependencies.selection.preserveSelection();
|
||||
// Split the text node beforehand to ensure the insertion offset
|
||||
// remains correct after deleting the selection.
|
||||
splitTextNode(nodeToSplit, offsetToSplit, DIRECTIONS.LEFT);
|
||||
selectionToRestore.restore();
|
||||
}
|
||||
|
||||
const dataTransfer = (ev.originalEvent || ev).dataTransfer;
|
||||
const imageNodeHTML = ev.dataTransfer.getData("application/vnd.odoo.odoo-editor-node");
|
||||
const image =
|
||||
imageNodeHTML &&
|
||||
this.dragImage &&
|
||||
imageNodeHTML === this.dragImage.outerHTML &&
|
||||
this.dragImage;
|
||||
|
||||
const fileTransferItems = getImageFiles(dataTransfer);
|
||||
const htmlTransferItem = [...dataTransfer.items].find((item) => item.type === "text/html");
|
||||
if (image || fileTransferItems.length || htmlTransferItem) {
|
||||
if (this.document.caretPositionFromPoint) {
|
||||
const range = this.document.caretPositionFromPoint(ev.clientX, ev.clientY);
|
||||
this.dependencies.delete.deleteSelection();
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode: range.offsetNode,
|
||||
anchorOffset: range.offset,
|
||||
});
|
||||
} else if (this.document.caretRangeFromPoint) {
|
||||
const range = this.document.caretRangeFromPoint(ev.clientX, ev.clientY);
|
||||
this.dependencies.delete.deleteSelection();
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode: range.startContainer,
|
||||
anchorOffset: range.startOffset,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (image) {
|
||||
const fragment = this.document.createDocumentFragment();
|
||||
fragment.append(image);
|
||||
this.dependencies.dom.insert(fragment);
|
||||
this.dependencies.history.addStep();
|
||||
} else if (fileTransferItems.length) {
|
||||
const html = await this.addImagesFiles(fileTransferItems);
|
||||
this.dependencies.dom.insert(html);
|
||||
this.dependencies.history.addStep();
|
||||
} else if (htmlTransferItem) {
|
||||
htmlTransferItem.getAsString((pastedText) => {
|
||||
this.dependencies.dom.insert(this.prepareClipboardData(pastedText));
|
||||
this.dependencies.history.addStep();
|
||||
});
|
||||
}
|
||||
}
|
||||
// @phoenix @todo: move to image or image paste plugin?
|
||||
/**
|
||||
* Add images inside the editable at the current selection.
|
||||
*
|
||||
* @param {File[]} imageFiles
|
||||
*/
|
||||
async addImagesFiles(imageFiles) {
|
||||
const promises = [];
|
||||
for (const imageFile of imageFiles) {
|
||||
const imageNode = this.document.createElement("img");
|
||||
imageNode.classList.add("img-fluid");
|
||||
this.dispatchTo("added_image_handlers", imageNode);
|
||||
imageNode.dataset.fileName = imageFile.name;
|
||||
promises.push(
|
||||
getImageUrl(imageFile).then((url) => {
|
||||
imageNode.src = url;
|
||||
return imageNode;
|
||||
})
|
||||
);
|
||||
}
|
||||
const nodes = await Promise.all(promises);
|
||||
const fragment = this.document.createDocumentFragment();
|
||||
fragment.append(...nodes);
|
||||
return fragment;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DataTransfer} dataTransfer
|
||||
*/
|
||||
function getImageFiles(dataTransfer) {
|
||||
return [...dataTransfer.items]
|
||||
.filter((item) => item.kind === "file" && item.type.includes("image/"))
|
||||
.map((item) => item.getAsFile());
|
||||
}
|
||||
/**
|
||||
* @param {File} file
|
||||
*/
|
||||
function getImageUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
reader.onloadend = (e) => {
|
||||
if (reader.error) {
|
||||
return reject(reader.error);
|
||||
}
|
||||
resolve(e.target.result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add origin to relative img src.
|
||||
* @param {string} origin
|
||||
*/
|
||||
function prependOriginToImages(doc, origin) {
|
||||
doc.querySelectorAll("img").forEach((img) => {
|
||||
const src = img.getAttribute("src");
|
||||
if (src && !/^(http|\/\/|data:)/.test(src)) {
|
||||
img.src = origin + (src.startsWith("/") ? src : "/" + src);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { isProtected } from "@html_editor/utils/dom_info";
|
||||
import { Plugin } from "../plugin";
|
||||
import { descendants } from "../utils/dom_traversal";
|
||||
|
||||
export class CommentPlugin extends Plugin {
|
||||
static id = "comment";
|
||||
resources = {
|
||||
normalize_handlers: this.removeComment.bind(this),
|
||||
};
|
||||
|
||||
removeComment(node) {
|
||||
for (const el of [node, ...descendants(node)]) {
|
||||
if (el.nodeType === Node.COMMENT_NODE && !isProtected(el)) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { isArtificialVoidElement } from "@html_editor/core/selection_plugin";
|
||||
import { Plugin } from "@html_editor/plugin";
|
||||
import { selectElements } from "@html_editor/utils/dom_traversal";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
|
||||
/**
|
||||
* This plugin is responsible for setting the contenteditable attribute on some
|
||||
* elements.
|
||||
*
|
||||
* The force_editable_selector and force_not_editable_selector resources allow
|
||||
* other plugins to easily add editable or non editable elements.
|
||||
*/
|
||||
|
||||
export class ContentEditablePlugin extends Plugin {
|
||||
static id = "contentEditablePlugin";
|
||||
resources = {
|
||||
normalize_handlers: withSequence(5, this.normalize.bind(this)),
|
||||
clean_for_save_handlers: withSequence(Infinity, this.cleanForSave.bind(this)),
|
||||
};
|
||||
|
||||
normalize(root) {
|
||||
const toDisableSelector = this.getResource("force_not_editable_selector").join(",");
|
||||
const toDisableEls = toDisableSelector ? [...selectElements(root, toDisableSelector)] : [];
|
||||
for (const toDisable of toDisableEls) {
|
||||
toDisable.setAttribute("contenteditable", "false");
|
||||
}
|
||||
const toEnableSelector = this.getResource("force_editable_selector").join(",");
|
||||
let filteredContentEditableEls = toEnableSelector
|
||||
? [...selectElements(root, toEnableSelector)]
|
||||
: [];
|
||||
for (const fn of this.getResource("filter_contenteditable_handlers")) {
|
||||
filteredContentEditableEls = [...fn(filteredContentEditableEls)];
|
||||
}
|
||||
const extraContentEditableEls = [];
|
||||
for (const fn of this.getResource("extra_contenteditable_handlers")) {
|
||||
extraContentEditableEls.push(...fn(filteredContentEditableEls));
|
||||
}
|
||||
for (const contentEditableEl of [
|
||||
...filteredContentEditableEls,
|
||||
...extraContentEditableEls,
|
||||
]) {
|
||||
if (!contentEditableEl.isContentEditable) {
|
||||
if (
|
||||
isArtificialVoidElement(contentEditableEl) ||
|
||||
contentEditableEl.nodeName === "IMG"
|
||||
) {
|
||||
contentEditableEl.classList.add("o_editable_media");
|
||||
continue;
|
||||
}
|
||||
if (!contentEditableEl.matches(toDisableSelector)) {
|
||||
contentEditableEl.setAttribute("contenteditable", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanForSave({ root }) {
|
||||
const toRemoveSelector = this.getResource("contenteditable_to_remove_selector").join(",");
|
||||
const contenteditableEls = toRemoveSelector
|
||||
? [...selectElements(root, toRemoveSelector)]
|
||||
: [];
|
||||
for (const contenteditableEl of contenteditableEls) {
|
||||
contenteditableEl.removeAttribute("contenteditable");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,35 @@
|
|||
import { Plugin } from "../plugin";
|
||||
|
||||
/**
|
||||
* @typedef {typeof import("@odoo/owl").Component} Component
|
||||
* @typedef {import("@web/core/dialog/dialog_service").DialogServiceInterfaceAddOptions} DialogServiceInterfaceAddOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DialogShared
|
||||
* @property {DialogPlugin['addDialog']} addDialog
|
||||
*/
|
||||
|
||||
export class DialogPlugin extends Plugin {
|
||||
static id = "dialog";
|
||||
static dependencies = ["selection"];
|
||||
static shared = ["addDialog"];
|
||||
|
||||
/**
|
||||
* @param {Component} DialogClass
|
||||
* @param {Object} props
|
||||
* @param {DialogServiceInterfaceAddOptions} options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
addDialog(DialogClass, props, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.services.dialog.add(DialogClass, props, {
|
||||
onClose: () => {
|
||||
this.dependencies.selection.focusEditable();
|
||||
resolve();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,654 @@
|
|||
import { Plugin } from "../plugin";
|
||||
import { closestBlock, isBlock } from "../utils/blocks";
|
||||
import {
|
||||
cleanTrailingBR,
|
||||
fillEmpty,
|
||||
fillShrunkPhrasingParent,
|
||||
makeContentsInline,
|
||||
removeClass,
|
||||
removeStyle,
|
||||
splitTextNode,
|
||||
unwrapContents,
|
||||
wrapInlinesInBlocks,
|
||||
} from "../utils/dom";
|
||||
import {
|
||||
allowsParagraphRelatedElements,
|
||||
getDeepestPosition,
|
||||
isContentEditable,
|
||||
isContentEditableAncestor,
|
||||
isEmptyBlock,
|
||||
isListElement,
|
||||
isListItemElement,
|
||||
isParagraphRelatedElement,
|
||||
isProtecting,
|
||||
isProtected,
|
||||
isSelfClosingElement,
|
||||
isShrunkBlock,
|
||||
isTangible,
|
||||
isUnprotecting,
|
||||
listElementSelector,
|
||||
isEditorTab,
|
||||
} from "../utils/dom_info";
|
||||
import {
|
||||
childNodes,
|
||||
children,
|
||||
closestElement,
|
||||
descendants,
|
||||
firstLeaf,
|
||||
lastLeaf,
|
||||
} from "../utils/dom_traversal";
|
||||
import { FONT_SIZE_CLASSES, TEXT_STYLE_CLASSES } from "../utils/formatting";
|
||||
import { DIRECTIONS, childNodeIndex, nodeSize, rightPos } from "../utils/position";
|
||||
import { normalizeCursorPosition } from "@html_editor/utils/selection";
|
||||
import { baseContainerGlobalSelector } from "@html_editor/utils/base_container";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
|
||||
/**
|
||||
* Get distinct connected parents of nodes
|
||||
*
|
||||
* @param {Iterable} nodes
|
||||
* @returns {Set}
|
||||
*/
|
||||
function getConnectedParents(nodes) {
|
||||
const parents = new Set();
|
||||
for (const node of nodes) {
|
||||
if (node.isConnected && node.parentElement) {
|
||||
parents.add(node.parentElement);
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} DomShared
|
||||
* @property { DomPlugin['insert'] } insert
|
||||
* @property { DomPlugin['copyAttributes'] } copyAttributes
|
||||
* @property { DomPlugin['canSetBlock'] } canSetBlock
|
||||
* @property { DomPlugin['setBlock'] } setBlock
|
||||
* @property { DomPlugin['setTagName'] } setTagName
|
||||
* @property { DomPlugin['removeSystemProperties'] } removeSystemProperties
|
||||
*/
|
||||
|
||||
export class DomPlugin extends Plugin {
|
||||
static id = "dom";
|
||||
static dependencies = ["baseContainer", "selection", "history", "split", "delete", "lineBreak"];
|
||||
static shared = [
|
||||
"insert",
|
||||
"copyAttributes",
|
||||
"canSetBlock",
|
||||
"setBlock",
|
||||
"setTagName",
|
||||
"removeSystemProperties",
|
||||
];
|
||||
resources = {
|
||||
user_commands: [
|
||||
{
|
||||
id: "insertFontAwesome",
|
||||
run: this.insertFontAwesome.bind(this),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "setTag",
|
||||
run: this.setBlock.bind(this),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
],
|
||||
/** Handlers */
|
||||
clean_for_save_handlers: ({ root }) => {
|
||||
this.removeEmptyClassAndStyleAttributes(root);
|
||||
},
|
||||
clipboard_content_processors: this.removeEmptyClassAndStyleAttributes.bind(this),
|
||||
functional_empty_node_predicates: [isSelfClosingElement, isEditorTab],
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.systemClasses = this.getResource("system_classes");
|
||||
this.systemAttributes = this.getResource("system_attributes");
|
||||
this.systemStyleProperties = this.getResource("system_style_properties");
|
||||
this.systemPropertiesSelector = [
|
||||
...this.systemClasses.map((className) => `.${className}`),
|
||||
...this.systemAttributes.map((attr) => `[${attr}]`),
|
||||
...this.systemStyleProperties.map((prop) => `[style*="${prop}"]`),
|
||||
].join(",");
|
||||
}
|
||||
|
||||
// Shared
|
||||
|
||||
/**
|
||||
* @param {string | DocumentFragment | Element | null} content
|
||||
*/
|
||||
insert(content) {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
let selection = this.dependencies.selection.getEditableSelection();
|
||||
if (!selection.isCollapsed) {
|
||||
this.dependencies.delete.deleteSelection();
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
}
|
||||
|
||||
let container = this.document.createElement("fake-element");
|
||||
const containerFirstChild = this.document.createElement("fake-element-fc");
|
||||
const containerLastChild = this.document.createElement("fake-element-lc");
|
||||
if (typeof content === "string") {
|
||||
container.textContent = content;
|
||||
} else {
|
||||
if (content.nodeType === Node.ELEMENT_NODE) {
|
||||
this.dispatchTo("normalize_handlers", content);
|
||||
} else {
|
||||
for (const child of children(content)) {
|
||||
this.dispatchTo("normalize_handlers", child);
|
||||
}
|
||||
}
|
||||
container.replaceChildren(content);
|
||||
}
|
||||
|
||||
const block = closestBlock(selection.anchorNode);
|
||||
for (const cb of this.getResource("before_insert_processors")) {
|
||||
container = cb(container, block);
|
||||
}
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
|
||||
let startNode;
|
||||
let insertBefore = false;
|
||||
if (selection.startContainer.nodeType === Node.TEXT_NODE) {
|
||||
insertBefore = !selection.startOffset;
|
||||
splitTextNode(selection.startContainer, selection.startOffset, DIRECTIONS.LEFT);
|
||||
startNode = selection.startContainer;
|
||||
}
|
||||
|
||||
const allInsertedNodes = [];
|
||||
// In case the html inserted starts with a list and will be inserted within
|
||||
// a list, unwrap the list elements from the list.
|
||||
const hasSingleChild = nodeSize(container) === 1;
|
||||
if (
|
||||
closestElement(selection.anchorNode, listElementSelector) &&
|
||||
isListElement(container.firstChild)
|
||||
) {
|
||||
unwrapContents(container.firstChild);
|
||||
}
|
||||
// Similarly if the html inserted ends with a list.
|
||||
if (
|
||||
closestElement(selection.focusNode, listElementSelector) &&
|
||||
isListElement(container.lastChild) &&
|
||||
!hasSingleChild
|
||||
) {
|
||||
unwrapContents(container.lastChild);
|
||||
}
|
||||
|
||||
startNode = startNode || this.dependencies.selection.getEditableSelection().anchorNode;
|
||||
|
||||
const shouldUnwrap = (node) =>
|
||||
(isParagraphRelatedElement(node) || isListItemElement(node)) &&
|
||||
!isEmptyBlock(block) &&
|
||||
!isEmptyBlock(node) &&
|
||||
(isContentEditable(node) ||
|
||||
(!node.isConnected && !closestElement(node, "[contenteditable]"))) &&
|
||||
!this.dependencies.split.isUnsplittable(node) &&
|
||||
(node.nodeName === block.nodeName ||
|
||||
(this.dependencies.baseContainer.isCandidateForBaseContainer(node) &&
|
||||
this.dependencies.baseContainer.isCandidateForBaseContainer(block)) ||
|
||||
block.nodeName === "PRE" ||
|
||||
(block.nodeName === "DIV" && this.dependencies.split.isUnsplittable(block))) &&
|
||||
// If the selection anchorNode is the editable itself, the content
|
||||
// should not be unwrapped.
|
||||
!this.isEditionBoundary(selection.anchorNode);
|
||||
|
||||
// Empty block must contain a br element to allow cursor placement.
|
||||
if (
|
||||
container.lastElementChild &&
|
||||
isBlock(container.lastElementChild) &&
|
||||
!container.lastElementChild.hasChildNodes()
|
||||
) {
|
||||
fillEmpty(container.lastElementChild);
|
||||
}
|
||||
|
||||
// In case the html inserted is all contained in a single root <p> or <li>
|
||||
// tag, we take the all content of the <p> or <li> and avoid inserting the
|
||||
// <p> or <li>.
|
||||
if (
|
||||
container.childElementCount === 1 &&
|
||||
(this.dependencies.baseContainer.isCandidateForBaseContainer(container.firstChild) ||
|
||||
shouldUnwrap(container.firstChild))
|
||||
) {
|
||||
const nodeToUnwrap = container.firstElementChild;
|
||||
container.replaceChildren(...childNodes(nodeToUnwrap));
|
||||
} else if (container.childElementCount > 1) {
|
||||
const isSelectionAtStart =
|
||||
firstLeaf(block) === selection.anchorNode && selection.anchorOffset === 0;
|
||||
const isSelectionAtEnd =
|
||||
lastLeaf(block) === selection.focusNode &&
|
||||
selection.focusOffset === nodeSize(selection.focusNode);
|
||||
// Grab the content of the first child block and isolate it.
|
||||
if (shouldUnwrap(container.firstChild) && !isSelectionAtStart) {
|
||||
// Unwrap the deepest nested first <li> element in the
|
||||
// container to extract and paste the text content of the list.
|
||||
if (isListItemElement(container.firstChild)) {
|
||||
const deepestBlock = closestBlock(firstLeaf(container.firstChild));
|
||||
this.dependencies.split.splitAroundUntil(deepestBlock, container.firstChild);
|
||||
container.firstElementChild.replaceChildren(...childNodes(deepestBlock));
|
||||
}
|
||||
containerFirstChild.replaceChildren(...childNodes(container.firstElementChild));
|
||||
container.firstElementChild.remove();
|
||||
}
|
||||
// Grab the content of the last child block and isolate it.
|
||||
if (shouldUnwrap(container.lastChild) && !isSelectionAtEnd) {
|
||||
// Unwrap the deepest nested last <li> element in the container
|
||||
// to extract and paste the text content of the list.
|
||||
if (isListItemElement(container.lastChild)) {
|
||||
const deepestBlock = closestBlock(lastLeaf(container.lastChild));
|
||||
this.dependencies.split.splitAroundUntil(deepestBlock, container.lastChild);
|
||||
container.lastElementChild.replaceChildren(...childNodes(deepestBlock));
|
||||
}
|
||||
containerLastChild.replaceChildren(...childNodes(container.lastElementChild));
|
||||
container.lastElementChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
if (startNode.nodeType === Node.ELEMENT_NODE) {
|
||||
if (selection.anchorOffset === 0) {
|
||||
const textNode = this.document.createTextNode("");
|
||||
if (isSelfClosingElement(startNode)) {
|
||||
startNode.parentNode.insertBefore(textNode, startNode);
|
||||
} else {
|
||||
startNode.prepend(textNode);
|
||||
}
|
||||
startNode = textNode;
|
||||
allInsertedNodes.push(textNode);
|
||||
} else {
|
||||
startNode = childNodes(startNode).at(selection.anchorOffset - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have isolated block content, first we split the current focus
|
||||
// element if it's a block then we insert the content in the right places.
|
||||
let currentNode = startNode;
|
||||
const _insertAt = (reference, nodes, insertBefore) => {
|
||||
for (const child of insertBefore ? nodes.reverse() : nodes) {
|
||||
reference[insertBefore ? "before" : "after"](child);
|
||||
reference = child;
|
||||
}
|
||||
};
|
||||
const lastInsertedNodes = childNodes(containerLastChild);
|
||||
if (containerLastChild.hasChildNodes()) {
|
||||
const toInsert = childNodes(containerLastChild); // Prevent mutation
|
||||
_insertAt(currentNode, [...toInsert], insertBefore);
|
||||
currentNode = insertBefore ? toInsert[0] : currentNode;
|
||||
toInsert[toInsert.length - 1];
|
||||
}
|
||||
const firstInsertedNodes = childNodes(containerFirstChild);
|
||||
if (containerFirstChild.hasChildNodes()) {
|
||||
const toInsert = childNodes(containerFirstChild); // Prevent mutation
|
||||
_insertAt(currentNode, [...toInsert], insertBefore);
|
||||
currentNode = toInsert[toInsert.length - 1];
|
||||
insertBefore = false;
|
||||
}
|
||||
allInsertedNodes.push(...firstInsertedNodes);
|
||||
|
||||
// If all the Html have been isolated, We force a split of the parent element
|
||||
// to have the need new line in the final result
|
||||
if (!container.hasChildNodes()) {
|
||||
if (this.dependencies.split.isUnsplittable(closestBlock(currentNode.nextSibling))) {
|
||||
this.dependencies.lineBreak.insertLineBreakNode({
|
||||
targetNode: currentNode.nextSibling,
|
||||
targetOffset: 0,
|
||||
});
|
||||
} else {
|
||||
// If we arrive here, the o_enter index should always be 0.
|
||||
const parent = currentNode.nextSibling.parentElement;
|
||||
const index = childNodes(parent).indexOf(currentNode.nextSibling);
|
||||
this.dependencies.split.splitBlockNode({
|
||||
targetNode: parent,
|
||||
targetOffset: index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let nodeToInsert;
|
||||
let doesCurrentNodeAllowsP = allowsParagraphRelatedElements(currentNode);
|
||||
const candidatesForRemoval = [];
|
||||
const insertedNodes = childNodes(container);
|
||||
while ((nodeToInsert = container.firstChild)) {
|
||||
if (isBlock(nodeToInsert) && !doesCurrentNodeAllowsP) {
|
||||
// Split blocks at the edges if inserting new blocks (preventing
|
||||
// <p><p>text</p></p> or <li><li>text</li></li> scenarios).
|
||||
while (
|
||||
!this.isEditionBoundary(currentNode.parentElement) &&
|
||||
(!allowsParagraphRelatedElements(currentNode.parentElement) ||
|
||||
(isListItemElement(currentNode.parentElement) &&
|
||||
!this.dependencies.split.isUnsplittable(nodeToInsert)))
|
||||
) {
|
||||
if (this.dependencies.split.isUnsplittable(currentNode.parentElement)) {
|
||||
// If we have to insert an unsplittable element, we cannot afford to
|
||||
// unwrap it we need to search for a more suitable spot to put it
|
||||
if (this.dependencies.split.isUnsplittable(nodeToInsert)) {
|
||||
currentNode = currentNode.parentElement;
|
||||
doesCurrentNodeAllowsP = allowsParagraphRelatedElements(currentNode);
|
||||
continue;
|
||||
} else {
|
||||
makeContentsInline(container);
|
||||
nodeToInsert = container.firstChild;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let offset = childNodeIndex(currentNode);
|
||||
if (!insertBefore) {
|
||||
offset += 1;
|
||||
}
|
||||
if (offset) {
|
||||
const [left, right] = this.dependencies.split.splitElement(
|
||||
currentNode.parentElement,
|
||||
offset
|
||||
);
|
||||
currentNode = insertBefore ? right : left;
|
||||
const otherNode = insertBefore ? left : right;
|
||||
if (isBlock(otherNode)) {
|
||||
fillShrunkPhrasingParent(otherNode);
|
||||
}
|
||||
// After the content insertion, the right-part of a
|
||||
// split is evaluated for removal, if it is unnecessary
|
||||
// (to guarantee a paragraph-related element
|
||||
// after the last unsplittable inserted element).
|
||||
candidatesForRemoval.push(right);
|
||||
} else {
|
||||
if (isBlock(currentNode)) {
|
||||
fillShrunkPhrasingParent(currentNode);
|
||||
}
|
||||
currentNode = currentNode.parentElement;
|
||||
}
|
||||
doesCurrentNodeAllowsP = allowsParagraphRelatedElements(currentNode);
|
||||
}
|
||||
if (
|
||||
isListItemElement(currentNode.parentElement) &&
|
||||
isBlock(nodeToInsert) &&
|
||||
this.dependencies.split.isUnsplittable(nodeToInsert)
|
||||
) {
|
||||
const br = document.createElement("br");
|
||||
currentNode[
|
||||
isEmptyBlock(currentNode) || !isTangible(currentNode) ? "before" : "after"
|
||||
](br);
|
||||
}
|
||||
}
|
||||
// Ensure that all adjacent paragraph elements are converted to
|
||||
// <li> when inserting in a list.
|
||||
const container = closestBlock(currentNode);
|
||||
for (const processor of this.getResource("node_to_insert_processors")) {
|
||||
nodeToInsert = processor({ nodeToInsert, container });
|
||||
}
|
||||
if (insertBefore) {
|
||||
currentNode.before(nodeToInsert);
|
||||
insertBefore = false;
|
||||
} else {
|
||||
currentNode.after(nodeToInsert);
|
||||
}
|
||||
allInsertedNodes.push(nodeToInsert);
|
||||
if (currentNode.tagName !== "BR" && isShrunkBlock(currentNode)) {
|
||||
currentNode.remove();
|
||||
}
|
||||
currentNode = nodeToInsert;
|
||||
}
|
||||
allInsertedNodes.push(...lastInsertedNodes);
|
||||
this.getResource("after_insert_handlers").forEach((handler) => handler(allInsertedNodes));
|
||||
let insertedNodesParents = getConnectedParents(allInsertedNodes);
|
||||
for (const parent of insertedNodesParents) {
|
||||
if (
|
||||
!this.config.allowInlineAtRoot &&
|
||||
this.isEditionBoundary(parent) &&
|
||||
allowsParagraphRelatedElements(parent)
|
||||
) {
|
||||
// Ensure that edition boundaries do not have inline content.
|
||||
wrapInlinesInBlocks(parent, {
|
||||
baseContainerNodeName: this.dependencies.baseContainer.getDefaultNodeName(),
|
||||
});
|
||||
}
|
||||
}
|
||||
insertedNodesParents = getConnectedParents(allInsertedNodes);
|
||||
for (const parent of insertedNodesParents) {
|
||||
if (
|
||||
!isProtecting(parent) &&
|
||||
!(isProtected(parent) && !isUnprotecting(parent)) &&
|
||||
parent.isContentEditable
|
||||
) {
|
||||
cleanTrailingBR(parent, [
|
||||
(node) => {
|
||||
// Don't remove the last BR in cases where the
|
||||
// previous sibling is an unsplittable block
|
||||
// (i.e. a table, a non-editable div, ...)
|
||||
// to allow placing the cursor after that unsplittable
|
||||
// element. This can be removed when the cursor
|
||||
// is properly handled around these elements.
|
||||
const previousSibling = node.previousSibling;
|
||||
return (
|
||||
previousSibling &&
|
||||
isBlock(previousSibling) &&
|
||||
this.dependencies.split.isUnsplittable(previousSibling)
|
||||
);
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
for (const candidateForRemoval of candidatesForRemoval) {
|
||||
// Ensure that a paragraph related element is present after the last
|
||||
// unsplittable inserted element
|
||||
if (
|
||||
candidateForRemoval.isConnected &&
|
||||
(isParagraphRelatedElement(candidateForRemoval) ||
|
||||
isListItemElement(candidateForRemoval)) &&
|
||||
candidateForRemoval.parentElement.isContentEditable &&
|
||||
isEmptyBlock(candidateForRemoval) &&
|
||||
((candidateForRemoval.previousElementSibling &&
|
||||
!this.dependencies.split.isUnsplittable(
|
||||
candidateForRemoval.previousElementSibling
|
||||
)) ||
|
||||
(candidateForRemoval.nextElementSibling &&
|
||||
!this.dependencies.split.isUnsplittable(
|
||||
candidateForRemoval.nextElementSibling
|
||||
)))
|
||||
) {
|
||||
candidateForRemoval.remove();
|
||||
}
|
||||
}
|
||||
for (const insertedNode of allInsertedNodes.reverse()) {
|
||||
if (insertedNode.isConnected) {
|
||||
currentNode = insertedNode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let lastPosition =
|
||||
isParagraphRelatedElement(currentNode) ||
|
||||
isListItemElement(currentNode) ||
|
||||
isListElement(currentNode)
|
||||
? rightPos(lastLeaf(currentNode))
|
||||
: rightPos(currentNode);
|
||||
lastPosition = normalizeCursorPosition(lastPosition[0], lastPosition[1], "right");
|
||||
|
||||
if (!this.config.allowInlineAtRoot && this.isEditionBoundary(lastPosition[0])) {
|
||||
// Correct the position if it happens to be in the editable root.
|
||||
lastPosition = getDeepestPosition(...lastPosition);
|
||||
}
|
||||
this.dependencies.selection.setSelection(
|
||||
{ anchorNode: lastPosition[0], anchorOffset: lastPosition[1] },
|
||||
{ normalize: false }
|
||||
);
|
||||
return firstInsertedNodes.concat(insertedNodes).concat(lastInsertedNodes);
|
||||
}
|
||||
|
||||
isEditionBoundary(node) {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
if (node === this.editable) {
|
||||
return true;
|
||||
}
|
||||
return isContentEditableAncestor(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} source
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
copyAttributes(source, target) {
|
||||
if (source?.nodeType !== Node.ELEMENT_NODE || target?.nodeType !== Node.ELEMENT_NODE) {
|
||||
return;
|
||||
}
|
||||
const ignoredAttrs = new Set(this.getResource("system_attributes"));
|
||||
const ignoredClasses = new Set(this.getResource("system_classes"));
|
||||
for (const attr of source.attributes) {
|
||||
if (ignoredAttrs.has(attr.name)) {
|
||||
continue;
|
||||
}
|
||||
if (attr.name !== "class" || ignoredClasses.size === 0) {
|
||||
target.setAttribute(attr.name, attr.value);
|
||||
} else {
|
||||
const classes = [...source.classList];
|
||||
for (const className of classes) {
|
||||
if (!ignoredClasses.has(className)) {
|
||||
target.classList.add(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic method to change an element tagName.
|
||||
* It is a technical function which only modifies a tag and its attributes.
|
||||
* It does not modify descendants nor handle the cursor.
|
||||
* @see setBlock for the more thorough command.
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
* @param {string} newTagName
|
||||
*/
|
||||
setTagName(el, newTagName) {
|
||||
const document = el.ownerDocument;
|
||||
if (el.tagName === newTagName) {
|
||||
return el;
|
||||
}
|
||||
const newEl = document.createElement(newTagName);
|
||||
const content = childNodes(el);
|
||||
if (isListItemElement(el)) {
|
||||
el.append(newEl);
|
||||
newEl.replaceChildren(...content);
|
||||
} else {
|
||||
if (el.parentElement) {
|
||||
el.before(newEl);
|
||||
}
|
||||
this.copyAttributes(el, newEl);
|
||||
newEl.replaceChildren(...content);
|
||||
el.remove();
|
||||
}
|
||||
return newEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove system-specific classes, attributes, and style properties from a
|
||||
* fragment or an element.
|
||||
*
|
||||
* @param {DocumentFragment|HTMLElement} root
|
||||
*/
|
||||
removeSystemProperties(root) {
|
||||
const clean = (element) => {
|
||||
removeClass(element, ...this.systemClasses);
|
||||
this.systemAttributes.forEach((attr) => element.removeAttribute(attr));
|
||||
removeStyle(element, ...this.systemStyleProperties);
|
||||
};
|
||||
if (root.matches?.(this.systemPropertiesSelector)) {
|
||||
clean(root);
|
||||
}
|
||||
for (const element of root.querySelectorAll(this.systemPropertiesSelector)) {
|
||||
clean(element);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// commands
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
insertFontAwesome({ faClass = "fa fa-star" } = {}) {
|
||||
const fontAwesomeNode = document.createElement("i");
|
||||
fontAwesomeNode.className = faClass;
|
||||
this.insert(fontAwesomeNode);
|
||||
this.dependencies.history.addStep();
|
||||
const [anchorNode, anchorOffset] = rightPos(fontAwesomeNode);
|
||||
this.dependencies.selection.setSelection({ anchorNode, anchorOffset });
|
||||
}
|
||||
|
||||
getBlocksToSet() {
|
||||
const targetedBlocks = [...this.dependencies.selection.getTargetedBlocks()];
|
||||
return targetedBlocks.filter(
|
||||
(block) =>
|
||||
!descendants(block).some((descendant) => targetedBlocks.includes(descendant)) &&
|
||||
block.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
canSetBlock() {
|
||||
return this.getBlocksToSet().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {string} param0.tagName
|
||||
* @param {string} [param0.extraClass]
|
||||
*/
|
||||
setBlock({ tagName, extraClass = "" }) {
|
||||
let newCandidate = this.document.createElement(tagName.toUpperCase());
|
||||
if (extraClass) {
|
||||
newCandidate.classList.add(extraClass);
|
||||
}
|
||||
if (this.dependencies.baseContainer.isCandidateForBaseContainer(newCandidate)) {
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer(
|
||||
newCandidate.nodeName
|
||||
);
|
||||
this.copyAttributes(newCandidate, baseContainer);
|
||||
newCandidate = baseContainer;
|
||||
}
|
||||
const cursors = this.dependencies.selection.preserveSelection();
|
||||
const newEls = [];
|
||||
for (const block of this.getBlocksToSet()) {
|
||||
if (
|
||||
isParagraphRelatedElement(block) ||
|
||||
isListItemElement(block) ||
|
||||
block.nodeName === "BLOCKQUOTE"
|
||||
) {
|
||||
if (newCandidate.matches(baseContainerGlobalSelector) && isListItemElement(block)) {
|
||||
continue;
|
||||
}
|
||||
const newEl = this.setTagName(block, tagName);
|
||||
cursors.remapNode(block, newEl);
|
||||
// We want to be able to edit the case `<h2 class="h3">`
|
||||
// but in that case, we want to display "Header 2" and
|
||||
// not "Header 3" as it is more important to display
|
||||
// the semantic tag being used (especially for h1 ones).
|
||||
// This is why those are not in `TEXT_STYLE_CLASSES`.
|
||||
const headingClasses = ["h1", "h2", "h3", "h4", "h5", "h6"];
|
||||
removeClass(newEl, ...FONT_SIZE_CLASSES, ...TEXT_STYLE_CLASSES, ...headingClasses);
|
||||
delete newEl.style.fontSize;
|
||||
if (extraClass) {
|
||||
newEl.classList.add(extraClass);
|
||||
}
|
||||
newEls.push(newEl);
|
||||
} else {
|
||||
// eg do not change a <div> into a h1: insert the h1
|
||||
// into it instead.
|
||||
newCandidate.append(...childNodes(block));
|
||||
block.append(newCandidate);
|
||||
cursors.remapNode(block, newCandidate);
|
||||
}
|
||||
}
|
||||
cursors.restore();
|
||||
this.dispatchTo("set_tag_handlers", newEls);
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
|
||||
removeEmptyClassAndStyleAttributes(root) {
|
||||
for (const node of [root, ...descendants(root)]) {
|
||||
if (node.classList && !node.classList.length) {
|
||||
node.removeAttribute("class");
|
||||
}
|
||||
if (node.style && !node.style.length) {
|
||||
node.removeAttribute("style");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import {
|
||||
htmlEditorVersions,
|
||||
stripVersion,
|
||||
VERSION_SELECTOR,
|
||||
} from "@html_editor/html_migrations/html_migrations_utils";
|
||||
import { Plugin } from "@html_editor/plugin";
|
||||
|
||||
export class EditorVersionPlugin extends Plugin {
|
||||
static id = "editorVersion";
|
||||
resources = {
|
||||
clean_for_save_handlers: this.cleanForSave.bind(this),
|
||||
normalize_handlers: this.normalize.bind(this),
|
||||
};
|
||||
|
||||
normalize(element) {
|
||||
if (element.matches(VERSION_SELECTOR) && element !== this.editable) {
|
||||
delete element.dataset.oeVersion;
|
||||
}
|
||||
stripVersion(element);
|
||||
}
|
||||
|
||||
cleanForSave({ root }) {
|
||||
const VERSIONS = htmlEditorVersions();
|
||||
const firstChild = root.firstElementChild;
|
||||
const version = VERSIONS.at(-1);
|
||||
if (firstChild && version) {
|
||||
firstChild.dataset.oeVersion = version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,708 @@
|
|||
import { prepareUpdate } from "@html_editor/utils/dom_state";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
import { callbacksForCursorUpdate } from "@html_editor/utils/selection";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Plugin } from "../plugin";
|
||||
import { closestBlock, isBlock } from "../utils/blocks";
|
||||
import { cleanTextNode, fillEmpty, removeClass, splitTextNode, unwrapContents } from "../utils/dom";
|
||||
import {
|
||||
areSimilarElements,
|
||||
isContentEditable,
|
||||
isElement,
|
||||
isEmptyBlock,
|
||||
isEmptyTextNode,
|
||||
isParagraphRelatedElement,
|
||||
isSelfClosingElement,
|
||||
isTextNode,
|
||||
isVisibleTextNode,
|
||||
isZwnbsp,
|
||||
isZWS,
|
||||
previousLeaf,
|
||||
} from "../utils/dom_info";
|
||||
import { isFakeLineBreak } from "../utils/dom_state";
|
||||
import {
|
||||
childNodes,
|
||||
closestElement,
|
||||
descendants,
|
||||
findFurthest,
|
||||
selectElements,
|
||||
} from "../utils/dom_traversal";
|
||||
import { formatsSpecs, FORMATTABLE_TAGS } from "../utils/formatting";
|
||||
import { boundariesIn, boundariesOut, DIRECTIONS, leftPos, rightPos } from "../utils/position";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
|
||||
const allWhitespaceRegex = /^[\s\u200b]*$/;
|
||||
|
||||
function isFormatted(formatPlugin, format) {
|
||||
return (sel, nodes) => formatPlugin.isSelectionFormat(format, nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} FormatShared
|
||||
* @property { FormatPlugin['isSelectionFormat'] } isSelectionFormat
|
||||
* @property { FormatPlugin['insertAndSelectZws'] } insertAndSelectZws
|
||||
* @property { FormatPlugin['mergeAdjacentInlines'] } mergeAdjacentInlines
|
||||
* @property { FormatPlugin['formatSelection'] } formatSelection
|
||||
*/
|
||||
|
||||
export class FormatPlugin extends Plugin {
|
||||
static id = "format";
|
||||
static dependencies = ["selection", "history", "input", "split"];
|
||||
// TODO ABD: refactor to handle Knowledge comments inside this plugin without sharing mergeAdjacentInlines.
|
||||
static shared = [
|
||||
"isSelectionFormat",
|
||||
"insertAndSelectZws",
|
||||
"mergeAdjacentInlines",
|
||||
"formatSelection",
|
||||
];
|
||||
resources = {
|
||||
user_commands: [
|
||||
{
|
||||
id: "formatBold",
|
||||
description: _t("Toggle bold"),
|
||||
icon: "fa-bold",
|
||||
run: this.formatSelection.bind(this, "bold"),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "formatItalic",
|
||||
description: _t("Toggle italic"),
|
||||
icon: "fa-italic",
|
||||
run: this.formatSelection.bind(this, "italic"),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "formatUnderline",
|
||||
description: _t("Toggle underline"),
|
||||
icon: "fa-underline",
|
||||
run: this.formatSelection.bind(this, "underline"),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "formatStrikethrough",
|
||||
description: _t("Toggle strikethrough"),
|
||||
icon: "fa-strikethrough",
|
||||
run: this.formatSelection.bind(this, "strikeThrough"),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "formatFontSize",
|
||||
run: ({ size }) =>
|
||||
this.formatSelection("fontSize", {
|
||||
applyStyle: true,
|
||||
formatProps: { size },
|
||||
}),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "formatFontSizeClassName",
|
||||
run: ({ className }) =>
|
||||
this.formatSelection("setFontSizeClassName", {
|
||||
applyStyle: true,
|
||||
formatProps: { className },
|
||||
}),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "removeFormat",
|
||||
description: (sel, nodes) =>
|
||||
nodes && this.hasAnyFormat(nodes)
|
||||
? _t("Remove Format")
|
||||
: _t("Selection has no format"),
|
||||
icon: "fa-eraser",
|
||||
run: this.removeAllFormats.bind(this),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
],
|
||||
shortcuts: [
|
||||
{ hotkey: "control+b", commandId: "formatBold" },
|
||||
{ hotkey: "control+i", commandId: "formatItalic" },
|
||||
{ hotkey: "control+u", commandId: "formatUnderline" },
|
||||
{ hotkey: "control+5", commandId: "formatStrikethrough" },
|
||||
{ hotkey: "control+space", commandId: "removeFormat" },
|
||||
],
|
||||
toolbar_groups: withSequence(20, { id: "decoration" }),
|
||||
toolbar_items: [
|
||||
{
|
||||
id: "bold",
|
||||
groupId: "decoration",
|
||||
namespaces: ["compact", "expanded"],
|
||||
commandId: "formatBold",
|
||||
isActive: isFormatted(this, "bold"),
|
||||
},
|
||||
{
|
||||
id: "italic",
|
||||
groupId: "decoration",
|
||||
namespaces: ["compact", "expanded"],
|
||||
commandId: "formatItalic",
|
||||
isActive: isFormatted(this, "italic"),
|
||||
},
|
||||
{
|
||||
id: "underline",
|
||||
groupId: "decoration",
|
||||
namespaces: ["compact", "expanded"],
|
||||
commandId: "formatUnderline",
|
||||
isActive: isFormatted(this, "underline"),
|
||||
},
|
||||
{
|
||||
id: "strikethrough",
|
||||
groupId: "decoration",
|
||||
commandId: "formatStrikethrough",
|
||||
isActive: isFormatted(this, "strikeThrough"),
|
||||
},
|
||||
withSequence(20, {
|
||||
id: "remove_format",
|
||||
groupId: "decoration",
|
||||
commandId: "removeFormat",
|
||||
isDisabled: (sel, nodes) => !this.hasAnyFormat(nodes),
|
||||
}),
|
||||
],
|
||||
/** Handlers */
|
||||
beforeinput_handlers: withSequence(20, this.onBeforeInput.bind(this)),
|
||||
clean_for_save_handlers: this.cleanForSave.bind(this),
|
||||
normalize_handlers: this.normalize.bind(this),
|
||||
selectionchange_handlers: this.removeEmptyInlineElement.bind(this),
|
||||
set_tag_handlers: this.removeFontSizeFormat.bind(this),
|
||||
before_insert_processors: this.unwrapEmptyFormat.bind(this),
|
||||
|
||||
intangible_char_for_keyboard_navigation_predicates: (_, char) => char === "\u200b",
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string[]} formats
|
||||
* @param {Node[]} targetedNodes
|
||||
*/
|
||||
removeFormats(formats, targetedNodes) {
|
||||
for (const format of formats) {
|
||||
if (
|
||||
!formatsSpecs[format].removeStyle ||
|
||||
!this.hasSelectionFormat(format, targetedNodes)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.formatSelection(format, { applyStyle: false, removeFormat: true });
|
||||
}
|
||||
}
|
||||
|
||||
unwrapEmptyFormat(insertedNode, block) {
|
||||
const anchorNode = this.dependencies.selection.getEditableSelection().anchorNode;
|
||||
if (!block.contains(anchorNode)) {
|
||||
return insertedNode;
|
||||
}
|
||||
const emptyZWS = closestElement(anchorNode, "[data-oe-zws-empty-inline]");
|
||||
if (
|
||||
!emptyZWS ||
|
||||
!emptyZWS.parentElement.isContentEditable ||
|
||||
this.getResource("unremovable_node_predicates").some((p) => p(emptyZWS))
|
||||
) {
|
||||
return insertedNode;
|
||||
}
|
||||
const cursors = this.dependencies.selection.preserveSelection();
|
||||
cursors.update(callbacksForCursorUpdate.remove(emptyZWS));
|
||||
emptyZWS.remove();
|
||||
cursors.restore();
|
||||
return insertedNode;
|
||||
}
|
||||
|
||||
removeAllFormats() {
|
||||
const targetedNodes = this.dependencies.selection.getTargetedNodes();
|
||||
this.removeFormats(Object.keys(formatsSpecs), targetedNodes);
|
||||
this.dispatchTo("remove_all_formats_handlers");
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
|
||||
removeFontSizeFormat(els) {
|
||||
if (els.every((el) => isParagraphRelatedElement(el))) {
|
||||
const targetedNodes = this.dependencies.selection.getTargetedNodes();
|
||||
this.removeFormats(["fontSize", "setFontSizeClassName"], targetedNodes);
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the current selection on the editable contains a formated
|
||||
* node
|
||||
*
|
||||
* @param {String} format 'bold'|'italic'|'underline'|'strikeThrough'|'switchDirection'
|
||||
* @param {Node[]} [targetedNodes]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasSelectionFormat(format, targetedNodes = this.dependencies.selection.getTargetedNodes()) {
|
||||
const targetedTextNodes = targetedNodes.filter(isTextNode);
|
||||
const isFormatted = formatsSpecs[format].isFormatted;
|
||||
return targetedTextNodes.some((n) => isFormatted(n, { editable: this.editable }));
|
||||
}
|
||||
/**
|
||||
* Return true if the current selection on the editable appears as the given
|
||||
* format. The selection is considered to appear as that format if every
|
||||
* text node in it appears as that format.
|
||||
*
|
||||
* @param {String} format 'bold'|'italic'|'underline'|'strikeThrough'|'switchDirection'
|
||||
* @param {Node[]} [targetedNodes]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSelectionFormat(format, targetedNodes = this.dependencies.selection.getTargetedNodes()) {
|
||||
const targetedTextNodes = targetedNodes.filter(isTextNode);
|
||||
const isFormatted = formatsSpecs[format].isFormatted;
|
||||
return (
|
||||
targetedTextNodes.length &&
|
||||
targetedTextNodes.every(
|
||||
(node) =>
|
||||
isZwnbsp(node) ||
|
||||
isEmptyTextNode(node) ||
|
||||
isFormatted(node, { editable: this.editable })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
hasAnyFormat(targetedNodes) {
|
||||
for (const format of Object.keys(formatsSpecs)) {
|
||||
if (
|
||||
formatsSpecs[format].removeStyle &&
|
||||
this.hasSelectionFormat(format, targetedNodes)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return targetedNodes.some((node) =>
|
||||
this.getResource("has_format_predicates").some((predicate) => predicate(node))
|
||||
);
|
||||
}
|
||||
|
||||
formatSelection(formatName, options) {
|
||||
this.dispatchTo("format_selection_handlers", formatName, options);
|
||||
if (this._formatSelection(formatName, options) && !options?.removeFormat) {
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
}
|
||||
|
||||
// @todo phoenix: refactor this method.
|
||||
_formatSelection(formatName, { applyStyle, formatProps } = {}) {
|
||||
this.dependencies.selection.selectAroundNonEditable();
|
||||
// note: does it work if selection is in opposite direction?
|
||||
const selection = this.dependencies.split.splitSelection();
|
||||
if (typeof applyStyle === "undefined") {
|
||||
applyStyle = !this.isSelectionFormat(formatName);
|
||||
}
|
||||
|
||||
let zws;
|
||||
if (selection.isCollapsed) {
|
||||
if (isTextNode(selection.anchorNode) && selection.anchorNode.textContent === "\u200b") {
|
||||
zws = selection.anchorNode;
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode: zws,
|
||||
anchorOffset: 0,
|
||||
focusNode: zws,
|
||||
focusOffset: 1,
|
||||
});
|
||||
} else {
|
||||
zws = this.insertAndSelectZws();
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTextNodes = /** @type { Text[] } **/ (
|
||||
this.dependencies.selection
|
||||
.getTargetedNodes()
|
||||
.filter(
|
||||
(n) =>
|
||||
this.dependencies.selection.areNodeContentsFullySelected(n) &&
|
||||
((isTextNode(n) && (isVisibleTextNode(n) || isZWS(n))) ||
|
||||
(n.nodeName === "BR" &&
|
||||
(isFakeLineBreak(n) ||
|
||||
previousLeaf(n, closestBlock(n))?.nodeName === "BR"))) &&
|
||||
isContentEditable(n)
|
||||
)
|
||||
);
|
||||
const unformattedTextNodes = selectedTextNodes.filter((n) => {
|
||||
const listItem = closestElement(n, "li");
|
||||
if (listItem && this.dependencies.selection.areNodeContentsFullySelected(listItem)) {
|
||||
const hasFontSizeStyle =
|
||||
formatName === "setFontSizeClassName"
|
||||
? listItem.classList.contains(formatProps?.className)
|
||||
: listItem.style.fontSize;
|
||||
return !hasFontSizeStyle;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const tagetedFieldNodes = new Set(
|
||||
this.dependencies.selection
|
||||
.getTargetedNodes()
|
||||
.map((n) => closestElement(n, "*[t-field],*[t-out],*[t-esc]"))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const formatSpec = formatsSpecs[formatName];
|
||||
for (const node of unformattedTextNodes) {
|
||||
const inlineAncestors = [];
|
||||
/** @type { Node } */
|
||||
let currentNode = node;
|
||||
let parentNode = node.parentElement;
|
||||
|
||||
// Remove the format on all inline ancestors until a block or an element
|
||||
// with a class that is not indicated as splittable.
|
||||
const isClassListSplittable = (classList) =>
|
||||
[...classList].every((className) =>
|
||||
this.getResource("format_class_predicates").some((cb) => cb(className))
|
||||
);
|
||||
|
||||
while (
|
||||
parentNode &&
|
||||
!isBlock(parentNode) &&
|
||||
!this.dependencies.split.isUnsplittable(parentNode) &&
|
||||
(parentNode.classList.length === 0 || isClassListSplittable(parentNode.classList))
|
||||
) {
|
||||
const isUselessZws =
|
||||
parentNode.tagName === "SPAN" &&
|
||||
parentNode.hasAttribute("data-oe-zws-empty-inline") &&
|
||||
parentNode.getAttributeNames().length === 1;
|
||||
|
||||
if (isUselessZws) {
|
||||
unwrapContents(parentNode);
|
||||
} else {
|
||||
const newLastAncestorInlineFormat = this.dependencies.split.splitAroundUntil(
|
||||
currentNode,
|
||||
parentNode
|
||||
);
|
||||
removeFormat(newLastAncestorInlineFormat, formatSpec);
|
||||
if (["setFontSizeClassName", "fontSize"].includes(formatName) && applyStyle) {
|
||||
removeClass(newLastAncestorInlineFormat, "o_default_font_size");
|
||||
}
|
||||
if (newLastAncestorInlineFormat.isConnected) {
|
||||
inlineAncestors.push(newLastAncestorInlineFormat);
|
||||
currentNode = newLastAncestorInlineFormat;
|
||||
}
|
||||
}
|
||||
|
||||
parentNode = currentNode.parentElement;
|
||||
}
|
||||
|
||||
const firstBlockOrClassHasFormat = formatSpec.isFormatted(parentNode, formatProps);
|
||||
if (firstBlockOrClassHasFormat && !applyStyle) {
|
||||
formatSpec.addNeutralStyle &&
|
||||
formatSpec.addNeutralStyle(getOrCreateSpan(node, inlineAncestors));
|
||||
} else if (
|
||||
(!firstBlockOrClassHasFormat || parentNode.nodeName === "LI") &&
|
||||
applyStyle
|
||||
) {
|
||||
const tag = formatSpec.tagName && this.document.createElement(formatSpec.tagName);
|
||||
if (tag) {
|
||||
node.after(tag);
|
||||
tag.append(node);
|
||||
|
||||
if (!formatSpec.isFormatted(tag, formatProps)) {
|
||||
tag.after(node);
|
||||
tag.remove();
|
||||
formatSpec.addStyle(getOrCreateSpan(node, inlineAncestors), formatProps);
|
||||
}
|
||||
} else if (formatName !== "fontSize" || formatProps.size !== undefined) {
|
||||
formatSpec.addStyle(getOrCreateSpan(node, inlineAncestors), formatProps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const targetedFieldNode of tagetedFieldNodes) {
|
||||
if (applyStyle) {
|
||||
formatSpec.addStyle(targetedFieldNode, formatProps);
|
||||
} else {
|
||||
formatSpec.removeStyle(targetedFieldNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (zws) {
|
||||
const siblings = [...zws.parentElement.childNodes];
|
||||
if (
|
||||
!isBlock(zws.parentElement) &&
|
||||
unformattedTextNodes.includes(siblings[0]) &&
|
||||
unformattedTextNodes.includes(siblings[siblings.length - 1])
|
||||
) {
|
||||
zws.parentElement.setAttribute("data-oe-zws-empty-inline", "");
|
||||
} else {
|
||||
const span = this.document.createElement("span");
|
||||
span.setAttribute("data-oe-zws-empty-inline", "");
|
||||
zws.before(span);
|
||||
span.append(zws);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
unformattedTextNodes.length === 1 &&
|
||||
unformattedTextNodes[0] &&
|
||||
unformattedTextNodes[0].textContent === "\u200B"
|
||||
) {
|
||||
this.dependencies.selection.setCursorStart(unformattedTextNodes[0]);
|
||||
} else if (selectedTextNodes.length) {
|
||||
const firstNode = selectedTextNodes[0];
|
||||
const lastNode = selectedTextNodes[selectedTextNodes.length - 1];
|
||||
let newSelection;
|
||||
if (selection.direction === DIRECTIONS.RIGHT) {
|
||||
newSelection = {
|
||||
anchorNode: firstNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: lastNode,
|
||||
focusOffset: lastNode.length,
|
||||
};
|
||||
} else {
|
||||
newSelection = {
|
||||
anchorNode: lastNode,
|
||||
anchorOffset: lastNode.length,
|
||||
focusNode: firstNode,
|
||||
focusOffset: 0,
|
||||
};
|
||||
}
|
||||
this.dependencies.selection.setSelection(newSelection, { normalize: false });
|
||||
return true;
|
||||
}
|
||||
if (tagetedFieldNodes.size > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
normalize(root) {
|
||||
for (const el of selectElements(root, "[data-oe-zws-empty-inline]")) {
|
||||
if (!allWhitespaceRegex.test(el.textContent)) {
|
||||
// The element has some meaningful text. Remove the ZWS in it.
|
||||
delete el.dataset.oeZwsEmptyInline;
|
||||
this.cleanZWS(el);
|
||||
if (
|
||||
el.tagName === "SPAN" &&
|
||||
el.getAttributeNames().length === 0 &&
|
||||
el.classList.length === 0
|
||||
) {
|
||||
// Useless span, unwrap it.
|
||||
unwrapContents(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.mergeAdjacentInlines(root);
|
||||
}
|
||||
|
||||
cleanForSave({ root, preserveSelection = false } = {}) {
|
||||
for (const element of root.querySelectorAll("[data-oe-zws-empty-inline]")) {
|
||||
let currentElement = element.parentElement;
|
||||
this.cleanElement(element, { preserveSelection });
|
||||
while (
|
||||
currentElement &&
|
||||
!isBlock(currentElement) &&
|
||||
!currentElement.childNodes.length
|
||||
) {
|
||||
const parentElement = currentElement.parentElement;
|
||||
currentElement.remove();
|
||||
currentElement = parentElement;
|
||||
}
|
||||
if (currentElement && isBlock(currentElement)) {
|
||||
fillEmpty(currentElement);
|
||||
}
|
||||
}
|
||||
this.mergeAdjacentInlines(root, { preserveSelection });
|
||||
}
|
||||
|
||||
removeEmptyInlineElement(selectionData) {
|
||||
const { anchorNode } = selectionData.editableSelection;
|
||||
const blockEl = closestBlock(anchorNode);
|
||||
const inlineElement = findFurthest(
|
||||
closestElement(anchorNode),
|
||||
blockEl,
|
||||
(e) => !isBlock(e) && e.textContent === "\u200b"
|
||||
);
|
||||
if (
|
||||
this.lastEmptyInlineElement?.isConnected &&
|
||||
this.lastEmptyInlineElement !== inlineElement
|
||||
) {
|
||||
// Remove last empty inline element.
|
||||
this.cleanElement(this.lastEmptyInlineElement, { preserveSelection: true });
|
||||
}
|
||||
// Skip if current block is empty.
|
||||
if (inlineElement && !isEmptyBlock(blockEl)) {
|
||||
this.lastEmptyInlineElement = inlineElement;
|
||||
} else {
|
||||
this.lastEmptyInlineElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
cleanElement(element, { preserveSelection }) {
|
||||
delete element.dataset.oeZwsEmptyInline;
|
||||
if (!allWhitespaceRegex.test(element.textContent)) {
|
||||
// The element has some meaningful text. Remove the ZWS in it.
|
||||
this.cleanZWS(element, { preserveSelection });
|
||||
return;
|
||||
}
|
||||
if (this.getResource("unremovable_node_predicates").some((p) => p(element))) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
![...element.classList].every((c) =>
|
||||
this.getResource("format_class_predicates").some((p) => p(c))
|
||||
)
|
||||
) {
|
||||
// Original comment from web_editor:
|
||||
// We only remove the empty element if it has no class, to ensure we
|
||||
// don't break visual styles (in that case, its ZWS was kept to
|
||||
// ensure the cursor can be placed in it).
|
||||
return;
|
||||
}
|
||||
const restore = prepareUpdate(...leftPos(element), ...rightPos(element));
|
||||
element.remove();
|
||||
restore();
|
||||
}
|
||||
|
||||
cleanZWS(element, { preserveSelection = true } = {}) {
|
||||
const textNodes = descendants(element).filter(isTextNode);
|
||||
const cursors = preserveSelection ? this.dependencies.selection.preserveSelection() : null;
|
||||
for (const node of textNodes) {
|
||||
cleanTextNode(node, "\u200B", cursors);
|
||||
}
|
||||
cursors?.restore();
|
||||
}
|
||||
|
||||
insertText(selection, content) {
|
||||
if (selection.anchorNode.nodeType === Node.TEXT_NODE) {
|
||||
selection = this.dependencies.selection.setSelection(
|
||||
{
|
||||
anchorNode: selection.anchorNode.parentElement,
|
||||
anchorOffset: splitTextNode(selection.anchorNode, selection.anchorOffset),
|
||||
},
|
||||
{ normalize: false }
|
||||
);
|
||||
}
|
||||
|
||||
const txt = this.document.createTextNode(content || "#");
|
||||
const restore = prepareUpdate(selection.anchorNode, selection.anchorOffset);
|
||||
selection.anchorNode.insertBefore(
|
||||
txt,
|
||||
selection.anchorNode.childNodes[selection.anchorOffset]
|
||||
);
|
||||
restore();
|
||||
const [anchorNode, anchorOffset, focusNode, focusOffset] = boundariesOut(txt);
|
||||
this.dependencies.selection.setSelection(
|
||||
{ anchorNode, anchorOffset, focusNode, focusOffset },
|
||||
{ normalize: false }
|
||||
);
|
||||
return txt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the actual selection (assumed to be collapsed) and insert a
|
||||
* zero-width space at its anchor point. Then, select that zero-width
|
||||
* space.
|
||||
*
|
||||
* @returns {Node} the inserted zero-width space
|
||||
*/
|
||||
insertAndSelectZws() {
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
const zws = this.insertText(selection, "\u200B");
|
||||
splitTextNode(zws, selection.anchorOffset);
|
||||
return zws;
|
||||
}
|
||||
|
||||
onBeforeInput(ev) {
|
||||
if (
|
||||
ev.inputType.startsWith("format") &&
|
||||
!isHtmlContentSupported(this.dependencies.selection.getEditableSelection())
|
||||
) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
if (ev.inputType === "insertText") {
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
if (!selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
const element = closestElement(selection.anchorNode);
|
||||
if (element.hasAttribute("data-oe-zws-empty-inline")) {
|
||||
// Select its ZWS content to make sure the text will be
|
||||
// inserted inside the element, and not before (outside) it.
|
||||
// This addresses an undesired behavior of the
|
||||
// contenteditable.
|
||||
const [anchorNode, anchorOffset, focusNode, focusOffset] = boundariesIn(element);
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode,
|
||||
anchorOffset,
|
||||
focusNode,
|
||||
focusOffset,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Node} root
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.preserveSelection=true]
|
||||
*/
|
||||
mergeAdjacentInlines(root, { preserveSelection = true } = {}) {
|
||||
let selectionToRestore = null;
|
||||
for (const node of [root, ...descendants(root)].filter(isElement)) {
|
||||
if (this.shouldBeMergedWithPreviousSibling(node)) {
|
||||
if (preserveSelection) {
|
||||
selectionToRestore ??= this.dependencies.selection.preserveSelection();
|
||||
selectionToRestore.update(callbacksForCursorUpdate.merge(node));
|
||||
}
|
||||
node.previousSibling.append(...childNodes(node));
|
||||
node.remove();
|
||||
}
|
||||
}
|
||||
selectionToRestore?.restore();
|
||||
}
|
||||
|
||||
shouldBeMergedWithPreviousSibling(node) {
|
||||
const isMergeable = (node) =>
|
||||
FORMATTABLE_TAGS.includes(node.nodeName) &&
|
||||
!this.getResource("unsplittable_node_predicates").some((predicate) => predicate(node));
|
||||
return (
|
||||
!isSelfClosingElement(node) &&
|
||||
areSimilarElements(node, node.previousSibling) &&
|
||||
isMergeable(node)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateSpan(node, ancestors) {
|
||||
const document = node.ownerDocument;
|
||||
const span = ancestors.find((element) => element.tagName === "SPAN" && element.isConnected);
|
||||
const lastInlineAncestor = ancestors.findLast(
|
||||
(element) => !isBlock(element) && element.isConnected
|
||||
);
|
||||
if (span) {
|
||||
return span;
|
||||
} else {
|
||||
const span = document.createElement("span");
|
||||
// Apply font span above current inline top ancestor so that
|
||||
// the font style applies to the other style tags as well.
|
||||
if (lastInlineAncestor) {
|
||||
lastInlineAncestor.after(span);
|
||||
span.append(lastInlineAncestor);
|
||||
} else {
|
||||
node.after(span);
|
||||
span.append(node);
|
||||
}
|
||||
return span;
|
||||
}
|
||||
}
|
||||
function removeFormat(node, formatSpec) {
|
||||
const document = node.ownerDocument;
|
||||
node = closestElement(node);
|
||||
if (formatSpec.hasStyle(node)) {
|
||||
formatSpec.removeStyle(node);
|
||||
if (["SPAN", "FONT"].includes(node.tagName) && !node.getAttributeNames().length) {
|
||||
return unwrapContents(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (formatSpec.isTag && formatSpec.isTag(node)) {
|
||||
const attributesNames = node
|
||||
.getAttributeNames()
|
||||
.filter((name) => name !== "data-oe-zws-empty-inline");
|
||||
if (attributesNames.length) {
|
||||
// Change tag name
|
||||
const newNode = document.createElement("span");
|
||||
while (node.firstChild) {
|
||||
newNode.appendChild(node.firstChild);
|
||||
}
|
||||
for (let index = node.attributes.length - 1; index >= 0; --index) {
|
||||
newNode.attributes.setNamedItem(node.attributes[index].cloneNode());
|
||||
}
|
||||
node.parentNode.replaceChild(newNode, node);
|
||||
} else {
|
||||
unwrapContents(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,20 @@
|
|||
import { Plugin } from "../plugin";
|
||||
|
||||
export class InputPlugin extends Plugin {
|
||||
static id = "input";
|
||||
static dependencies = ["history"];
|
||||
setup() {
|
||||
this.addDomListener(this.editable, "beforeinput", this.onBeforeInput);
|
||||
this.addDomListener(this.editable, "input", this.onInput);
|
||||
}
|
||||
|
||||
onBeforeInput(ev) {
|
||||
this.dependencies.history.stageSelection();
|
||||
this.dispatchTo("beforeinput_handlers", ev);
|
||||
}
|
||||
|
||||
onInput(ev) {
|
||||
this.dependencies.history.addStep();
|
||||
this.dispatchTo("input_handlers", ev);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { splitTextNode } from "@html_editor/utils/dom";
|
||||
import { Plugin } from "../plugin";
|
||||
import { CTGROUPS, CTYPES } from "../utils/content_types";
|
||||
import { getState, isFakeLineBreak, prepareUpdate } from "../utils/dom_state";
|
||||
import { DIRECTIONS, leftPos, rightPos } from "../utils/position";
|
||||
import { closestElement } from "@html_editor/utils/dom_traversal";
|
||||
import { closestBlock, isBlock } from "../utils/blocks";
|
||||
import { nextLeaf } from "../utils/dom_info";
|
||||
|
||||
/**
|
||||
* @typedef { Object } LineBreakShared
|
||||
* @property { LineBreakPlugin['insertLineBreak'] } insertLineBreak
|
||||
* @property { LineBreakPlugin['insertLineBreakElement'] } insertLineBreakElement
|
||||
* @property { LineBreakPlugin['insertLineBreakNode'] } insertLineBreakNode
|
||||
*/
|
||||
|
||||
export class LineBreakPlugin extends Plugin {
|
||||
static dependencies = ["selection", "history", "input", "delete"];
|
||||
static id = "lineBreak";
|
||||
static shared = ["insertLineBreak", "insertLineBreakNode", "insertLineBreakElement"];
|
||||
resources = {
|
||||
beforeinput_handlers: this.onBeforeInput.bind(this),
|
||||
legit_feff_predicates: [
|
||||
(node) =>
|
||||
!node.nextSibling &&
|
||||
!isBlock(closestElement(node)) &&
|
||||
nextLeaf(node, closestBlock(node)),
|
||||
],
|
||||
};
|
||||
|
||||
insertLineBreak() {
|
||||
this.dispatchTo("before_line_break_handlers");
|
||||
let selection = this.dependencies.selection.getSelectionData().deepEditableSelection;
|
||||
if (!selection.isCollapsed) {
|
||||
// @todo @phoenix collapseIfZWS is not tested
|
||||
// this.shared.collapseIfZWS();
|
||||
this.dependencies.delete.deleteSelection();
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
}
|
||||
|
||||
const targetNode = selection.anchorNode;
|
||||
const targetOffset = selection.anchorOffset;
|
||||
|
||||
this.insertLineBreakNode({ targetNode, targetOffset });
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {Node} params.targetNode
|
||||
* @param {number} params.targetOffset
|
||||
*/
|
||||
insertLineBreakNode({ targetNode, targetOffset }) {
|
||||
const closestEl = closestElement(targetNode);
|
||||
if (closestEl && !closestEl.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
if (targetNode.nodeType === Node.TEXT_NODE) {
|
||||
targetOffset = splitTextNode(targetNode, targetOffset);
|
||||
targetNode = targetNode.parentElement;
|
||||
}
|
||||
|
||||
if (this.delegateTo("insert_line_break_element_overrides", { targetNode, targetOffset })) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.insertLineBreakElement({ targetNode, targetOffset });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {HTMLElement} params.targetNode
|
||||
* @param {number} params.targetOffset
|
||||
*/
|
||||
insertLineBreakElement({ targetNode, targetOffset }) {
|
||||
const closestEl = closestElement(targetNode);
|
||||
if (closestEl && !closestEl.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
const restore = prepareUpdate(targetNode, targetOffset);
|
||||
|
||||
const brEl = this.document.createElement("br");
|
||||
const brEls = [brEl];
|
||||
if (targetOffset >= targetNode.childNodes.length) {
|
||||
targetNode.appendChild(brEl);
|
||||
if (
|
||||
!isBlock(closestElement(targetNode)) &&
|
||||
nextLeaf(targetNode, closestBlock(targetNode))
|
||||
) {
|
||||
targetNode.appendChild(this.document.createTextNode("\uFEFF"));
|
||||
}
|
||||
} else {
|
||||
targetNode.insertBefore(brEl, targetNode.childNodes[targetOffset]);
|
||||
}
|
||||
if (
|
||||
isFakeLineBreak(brEl) &&
|
||||
!(getState(...leftPos(brEl), DIRECTIONS.LEFT).cType & (CTGROUPS.BLOCK | CTYPES.BR))
|
||||
) {
|
||||
const brEl2 = this.document.createElement("br");
|
||||
brEl.before(brEl2);
|
||||
brEls.unshift(brEl2);
|
||||
}
|
||||
|
||||
restore();
|
||||
|
||||
// @todo ask AGE about why this code was only needed for unbreakable.
|
||||
// See `this._applyCommand('oEnter') === UNBREAKABLE_ROLLBACK_CODE` in
|
||||
// web_editor. Because now we should have a strong handling of the link
|
||||
// selection with the link isolation, if we want to insert a BR outside,
|
||||
// we can move the cursor outside the link.
|
||||
// So if there is no reason to keep this code, we should remove it.
|
||||
//
|
||||
// const anchor = brEls[0].parentElement;
|
||||
// // @todo @phoenix should this case be handled by a LinkPlugin?
|
||||
// // @todo @phoenix Don't we want this for all spans ?
|
||||
// if (anchor.nodeName === "A" && brEls.includes(anchor.firstChild)) {
|
||||
// brEls.forEach((br) => anchor.before(br));
|
||||
// const pos = rightPos(brEls[brEls.length - 1]);
|
||||
// this.dependencies.selection.setSelection({ anchorNode: pos[0], anchorOffset: pos[1] });
|
||||
// } else if (anchor.nodeName === "A" && brEls.includes(anchor.lastChild)) {
|
||||
// brEls.forEach((br) => anchor.after(br));
|
||||
// const pos = rightPos(brEls[0]);
|
||||
// this.dependencies.selection.setSelection({ anchorNode: pos[0], anchorOffset: pos[1] });
|
||||
// }
|
||||
for (const el of brEls) {
|
||||
// @todo @phoenix we don t want to setSelection multiple times
|
||||
if (el.parentNode) {
|
||||
const pos = rightPos(el);
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode: pos[0],
|
||||
anchorOffset: pos[1],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeInput(e) {
|
||||
if (e.inputType === "insertLineBreak") {
|
||||
e.preventDefault();
|
||||
this.insertLineBreak();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { getDeepestPosition, isParagraphRelatedElement } from "@html_editor/utils/dom_info";
|
||||
import { Plugin } from "../plugin";
|
||||
import { isNotAllowedContent } from "./selection_plugin";
|
||||
import { endPos, startPos } from "@html_editor/utils/position";
|
||||
import { childNodes } from "@html_editor/utils/dom_traversal";
|
||||
|
||||
export class NoInlineRootPlugin extends Plugin {
|
||||
static id = "noInlineRoot";
|
||||
static dependencies = ["baseContainer", "selection", "history"];
|
||||
|
||||
resources = {
|
||||
fix_selection_on_editable_root_overrides: this.fixSelectionOnEditableRoot.bind(this),
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.addDomListener(this.editable, "keydown", (ev) => {
|
||||
this.currentKeyDown = ev.key;
|
||||
});
|
||||
this.addDomListener(this.editable, "pointerdown", () => {
|
||||
this.isPointerDown = true;
|
||||
});
|
||||
this.addDomListener(this.editable, "pointerup", () => {
|
||||
this.isPointerDown = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the cursor in a safe place (not the editable root).
|
||||
* Inserts an empty paragraph if selection results from mouse click and
|
||||
* there's no other way to insert text before/after a block.
|
||||
*
|
||||
* @param {import("./selection_plugin").EditorSelection} selection
|
||||
* @returns {boolean} Whether the selection was fixed
|
||||
*/
|
||||
fixSelectionOnEditableRoot(selection) {
|
||||
if (!selection.isCollapsed || selection.anchorNode !== this.editable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const children = childNodes(this.editable);
|
||||
const nodeAfterCursor = children[selection.anchorOffset];
|
||||
const nodeBeforeCursor = children[selection.anchorOffset - 1];
|
||||
const key = this.currentKeyDown;
|
||||
delete this.currentKeyDown;
|
||||
|
||||
if (key?.startsWith("Arrow")) {
|
||||
return this.fixSelectionOnEditableRootArrowKeys(nodeAfterCursor, nodeBeforeCursor, key);
|
||||
}
|
||||
return (
|
||||
this.fixSelectionOnEditableRootGeneric(nodeAfterCursor, nodeBeforeCursor) ||
|
||||
this.fixSelectionOnEditableRootCreateP(nodeAfterCursor, nodeBeforeCursor)
|
||||
);
|
||||
}
|
||||
/**
|
||||
* @param {Node} nodeAfterCursor
|
||||
* @param {Node} nodeBeforeCursor
|
||||
* @param {string} key
|
||||
* @returns {boolean} Whether the selection was fixed
|
||||
*/
|
||||
fixSelectionOnEditableRootArrowKeys(nodeAfterCursor, nodeBeforeCursor, key) {
|
||||
if (!["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].includes(key)) {
|
||||
return false;
|
||||
}
|
||||
const directionForward = ["ArrowRight", "ArrowDown"].includes(key);
|
||||
let node = directionForward ? nodeAfterCursor : nodeBeforeCursor;
|
||||
while (node && isNotAllowedContent(node)) {
|
||||
node = directionForward ? node.nextElementSibling : node.previousElementSibling;
|
||||
}
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
let [anchorNode, anchorOffset] = directionForward ? startPos(node) : endPos(node);
|
||||
[anchorNode, anchorOffset] = getDeepestPosition(anchorNode, anchorOffset);
|
||||
this.dependencies.selection.setSelection({ anchorNode, anchorOffset });
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* @param {Node} nodeAfterCursor
|
||||
* @param {Node} nodeBeforeCursor
|
||||
* @returns {boolean} Whether the selection was fixed
|
||||
*/
|
||||
fixSelectionOnEditableRootGeneric(nodeAfterCursor, nodeBeforeCursor) {
|
||||
if (isParagraphRelatedElement(nodeAfterCursor)) {
|
||||
// Cursor is right before a 'P'.
|
||||
this.dependencies.selection.setCursorStart(nodeAfterCursor);
|
||||
return true;
|
||||
}
|
||||
if (isParagraphRelatedElement(nodeBeforeCursor)) {
|
||||
// Cursor is right after a 'P'.
|
||||
this.dependencies.selection.setCursorEnd(nodeBeforeCursor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Handle cursor not next to a 'P'.
|
||||
* Insert a new 'P' if selection resulted from a mouse click.
|
||||
*
|
||||
* In some situations (notably around tables and horizontal
|
||||
* separators), the cursor could be placed having its anchorNode at
|
||||
* the editable root, allowing the user to insert inlined text at
|
||||
* it.
|
||||
*
|
||||
* @param {Node} nodeAfterCursor
|
||||
* @param {Node} nodeBeforeCursor
|
||||
* @returns {boolean} Whether the selection was fixed
|
||||
*/
|
||||
fixSelectionOnEditableRootCreateP(nodeAfterCursor, nodeBeforeCursor) {
|
||||
if (!this.isPointerDown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
baseContainer.append(this.document.createElement("br"));
|
||||
if (!nodeAfterCursor) {
|
||||
// Cursor is at the end of the editable.
|
||||
this.editable.append(baseContainer);
|
||||
} else if (!nodeBeforeCursor) {
|
||||
// Cursor is at the beginning of the editable.
|
||||
this.editable.prepend(baseContainer);
|
||||
} else {
|
||||
// Cursor is between two non-p blocks
|
||||
nodeAfterCursor.before(baseContainer);
|
||||
}
|
||||
this.dependencies.selection.setCursorStart(baseContainer);
|
||||
this.dependencies.history.addStep();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import {
|
||||
Component,
|
||||
onWillDestroy,
|
||||
useEffect,
|
||||
useExternalListener,
|
||||
useRef,
|
||||
useState,
|
||||
useSubEnv,
|
||||
xml,
|
||||
} from "@odoo/owl";
|
||||
import { OVERLAY_SYMBOL } from "@web/core/overlay/overlay_container";
|
||||
import { usePosition } from "@web/core/position/position_hook";
|
||||
import { useActiveElement } from "@web/core/ui/ui_service";
|
||||
import { closestScrollableY } from "@web/core/utils/scrolling";
|
||||
|
||||
export class EditorOverlay extends Component {
|
||||
static template = xml`
|
||||
<div t-ref="root" class="overlay" t-att-class="props.className" t-on-pointerdown.stop="() => {}">
|
||||
<t t-component="props.Component" t-props="props.props"/>
|
||||
</div>`;
|
||||
|
||||
static props = {
|
||||
target: { validate: (el) => el.nodeType === Node.ELEMENT_NODE, optional: true },
|
||||
initialSelection: { type: Object, optional: true },
|
||||
Component: Function,
|
||||
props: { type: Object, optional: true },
|
||||
editable: { validate: (el) => el.nodeType === Node.ELEMENT_NODE },
|
||||
bus: Object,
|
||||
history: Object,
|
||||
close: Function,
|
||||
isOverlayOpen: Function,
|
||||
|
||||
// Props from createOverlay
|
||||
positionOptions: { type: Object, optional: true },
|
||||
className: { type: String, optional: true },
|
||||
closeOnPointerdown: { type: Boolean, optional: true },
|
||||
hasAutofocus: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
className: "",
|
||||
closeOnPointerdown: true,
|
||||
hasAutofocus: false,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.lastSelection = this.props.initialSelection;
|
||||
/** @type {HTMLElement} */
|
||||
const editable = this.props.editable;
|
||||
let getTarget, position;
|
||||
if (this.props.target) {
|
||||
getTarget = () => this.props.target;
|
||||
} else {
|
||||
this.rangeElement = editable.ownerDocument.createElement("range-el");
|
||||
editable.after(this.rangeElement);
|
||||
onWillDestroy(() => {
|
||||
this.rangeElement.remove();
|
||||
});
|
||||
getTarget = this.getSelectionTarget.bind(this);
|
||||
}
|
||||
|
||||
useExternalListener(this.props.bus, "updatePosition", () => {
|
||||
position.unlock();
|
||||
});
|
||||
|
||||
const rootRef = useRef("root");
|
||||
|
||||
if (this.props.positionOptions?.updatePositionOnResize ?? true) {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
position.unlock();
|
||||
});
|
||||
useEffect(
|
||||
(root) => {
|
||||
resizeObserver.observe(root);
|
||||
return () => {
|
||||
resizeObserver.unobserve(root);
|
||||
};
|
||||
},
|
||||
() => [rootRef.el]
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.closeOnPointerdown) {
|
||||
const clickAway = (ev) => {
|
||||
if (!this.env[OVERLAY_SYMBOL]?.contains(ev.composedPath()[0])) {
|
||||
this.props.close();
|
||||
}
|
||||
};
|
||||
const editableDocument = this.props.editable.ownerDocument;
|
||||
useExternalListener(editableDocument, "pointerdown", clickAway);
|
||||
// Listen to pointerdown outside the iframe
|
||||
if (editableDocument !== document) {
|
||||
useExternalListener(document, "pointerdown", clickAway);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.hasAutofocus) {
|
||||
useActiveElement("root");
|
||||
}
|
||||
const topDocument = editable.ownerDocument.defaultView.top.document;
|
||||
const container = closestScrollable(editable) || topDocument.documentElement;
|
||||
const resizeObserver = new ResizeObserver(() => position.unlock());
|
||||
resizeObserver.observe(container);
|
||||
onWillDestroy(() => resizeObserver.disconnect());
|
||||
const positionOptions = {
|
||||
position: "bottom-start",
|
||||
container: container,
|
||||
...this.props.positionOptions,
|
||||
onPositioned: (el, solution) => {
|
||||
this.props.positionOptions?.onPositioned?.(el, solution);
|
||||
this.updateVisibility(el, solution, container);
|
||||
},
|
||||
};
|
||||
position = usePosition("root", getTarget, positionOptions);
|
||||
|
||||
this.overlayState = useState({ isOverlayVisible: true });
|
||||
useSubEnv({ overlayState: this.overlayState });
|
||||
}
|
||||
|
||||
getSelectionTarget() {
|
||||
const doc = this.props.editable.ownerDocument;
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || !selection.rangeCount || !this.props.isOverlayOpen()) {
|
||||
return null;
|
||||
}
|
||||
const inEditable = this.props.editable.contains(selection.anchorNode);
|
||||
let range;
|
||||
if (inEditable) {
|
||||
range = selection.getRangeAt(0);
|
||||
this.lastSelection = { range };
|
||||
} else {
|
||||
if (!this.lastSelection) {
|
||||
return null;
|
||||
}
|
||||
range = this.lastSelection.range;
|
||||
}
|
||||
let rect = range.getBoundingClientRect();
|
||||
if (rect.x === 0 && rect.width === 0 && rect.height === 0) {
|
||||
// Attention, ignoring DOM mutations is always dangerous (when we add or remove nodes)
|
||||
// because if another mutation uses the target that is not observed, that mutation can never be applied
|
||||
// again (when undo/redo and in collaboration).
|
||||
this.props.history.ignoreDOMMutations(() => {
|
||||
const clonedRange = range.cloneRange();
|
||||
const shadowCaret = doc.createTextNode("|");
|
||||
clonedRange.insertNode(shadowCaret);
|
||||
clonedRange.selectNode(shadowCaret);
|
||||
rect = clonedRange.getBoundingClientRect();
|
||||
shadowCaret.remove();
|
||||
clonedRange.detach();
|
||||
});
|
||||
}
|
||||
// Html element with a patched getBoundingClientRect method. It
|
||||
// represents the range as a (HTMLElement) target for the usePosition
|
||||
// hook.
|
||||
this.rangeElement.getBoundingClientRect = () => rect;
|
||||
return this.rangeElement;
|
||||
}
|
||||
|
||||
updateVisibility(overlayElement, solution, container) {
|
||||
// @todo: mobile tests rely on a visible (yet overflowing) toolbar
|
||||
// Remove this once the mobile toolbar is fixed?
|
||||
if (this.env.isSmall) {
|
||||
return;
|
||||
}
|
||||
const shouldBeVisible = this.shouldOverlayBeVisible(overlayElement, solution, container);
|
||||
overlayElement.style.visibility = shouldBeVisible ? "visible" : "hidden";
|
||||
this.overlayState.isOverlayVisible = shouldBeVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} overlayElement
|
||||
* @param {Object} solution
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
shouldOverlayBeVisible(overlayElement, solution, container) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const overflowsTop = solution.top < containerRect.top;
|
||||
const overflowsBottom = solution.top + overlayElement.offsetHeight > containerRect.bottom;
|
||||
const canFlip = this.props.positionOptions?.flip ?? true;
|
||||
if (overflowsTop) {
|
||||
if (overflowsBottom) {
|
||||
// Overlay is bigger than the cointainer. Hiding it would it
|
||||
// make always invisible.
|
||||
return true;
|
||||
}
|
||||
if (solution.direction === "top" && canFlip) {
|
||||
// Scrolling down will make overlay eventually flip and no longer overflow
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (overflowsBottom) {
|
||||
if (solution.direction === "bottom" && canFlip) {
|
||||
// Scrolling up will make overlay eventually flip and no longer overflow
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around closestScrollableY that keeps searching outside of iframes.
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
function closestScrollable(el) {
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
return closestScrollableY(el) || closestScrollable(el.ownerDocument.defaultView.frameElement);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { markRaw, EventBus } from "@odoo/owl";
|
||||
import { Plugin } from "../plugin";
|
||||
import { EditorOverlay } from "./overlay";
|
||||
|
||||
/**
|
||||
* @typedef { Object } OverlayShared
|
||||
* @property { OverlayPlugin['createOverlay'] } createOverlay
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provides the following feature:
|
||||
* - adding a component in overlay above the editor, with proper positioning
|
||||
*/
|
||||
export class OverlayPlugin extends Plugin {
|
||||
static id = "overlay";
|
||||
static dependencies = ["history"];
|
||||
static shared = ["createOverlay"];
|
||||
|
||||
overlays = [];
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
for (const overlay of this.overlays) {
|
||||
overlay.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an overlay component and adds it to the list of overlays.
|
||||
*
|
||||
* @param {Function} Component
|
||||
* @param {Object} [props={}]
|
||||
* @param {Object} [options]
|
||||
* @returns {Overlay}
|
||||
*/
|
||||
createOverlay(Component, props = {}, options) {
|
||||
const overlay = new Overlay(this, Component, props, options);
|
||||
this.overlays.push(overlay);
|
||||
return overlay;
|
||||
}
|
||||
}
|
||||
|
||||
export class Overlay {
|
||||
constructor(plugin, C, props, options) {
|
||||
this.plugin = plugin;
|
||||
this.C = C;
|
||||
this.editorOverlayProps = props;
|
||||
this.options = options;
|
||||
this.isOpen = false;
|
||||
this._remove = null;
|
||||
this.component = null;
|
||||
this.bus = new EventBus();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {HTMLElement | null} [options.target] for the overlay.
|
||||
* If null or undefined, the current selection will be used instead
|
||||
* @param {any} [options.props] overlay component props
|
||||
*/
|
||||
open({ target, props }) {
|
||||
if (this.isOpen) {
|
||||
this.updatePosition();
|
||||
} else {
|
||||
this.isOpen = true;
|
||||
const selection = this.plugin.editable.ownerDocument.getSelection();
|
||||
let initialSelection;
|
||||
if (selection && selection.type !== "None") {
|
||||
initialSelection = {
|
||||
range: selection.getRangeAt(0),
|
||||
};
|
||||
}
|
||||
this._remove = this.plugin.services.overlay.add(
|
||||
EditorOverlay,
|
||||
markRaw({
|
||||
...this.editorOverlayProps,
|
||||
Component: this.C,
|
||||
editable: this.plugin.editable,
|
||||
props,
|
||||
target,
|
||||
initialSelection,
|
||||
bus: this.bus,
|
||||
close: this.close.bind(this),
|
||||
isOverlayOpen: this.isOverlayOpen.bind(this),
|
||||
history: {
|
||||
ignoreDOMMutations: this.plugin.dependencies.history.ignoreDOMMutations,
|
||||
},
|
||||
}),
|
||||
{
|
||||
...this.options,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
if (this._remove) {
|
||||
this._remove();
|
||||
}
|
||||
}
|
||||
|
||||
isOverlayOpen() {
|
||||
return this.isOpen;
|
||||
}
|
||||
|
||||
updatePosition() {
|
||||
this.bus.trigger("updatePosition");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { Plugin } from "../plugin";
|
||||
import { isProtecting, isUnprotecting } from "../utils/dom_info";
|
||||
import { childNodes } from "../utils/dom_traversal";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
|
||||
const PROTECTED_SELECTOR = `[data-oe-protected="true"],[data-oe-protected=""]`;
|
||||
const UNPROTECTED_SELECTOR = `[data-oe-protected="false"]`;
|
||||
|
||||
/**
|
||||
* @typedef { Object } ProtectedNodeShared
|
||||
* @property { ProtectedNodePlugin['setProtectingNode'] } setProtectingNode
|
||||
*
|
||||
* @typedef { import("./history_plugin").HistoryMutationRecord } HistoryMutationRecord
|
||||
*/
|
||||
|
||||
export class ProtectedNodePlugin extends Plugin {
|
||||
static id = "protectedNode";
|
||||
static shared = ["setProtectingNode"];
|
||||
resources = {
|
||||
/** Handlers */
|
||||
clean_for_save_handlers: ({ root }) => this.cleanForSave(root),
|
||||
normalize_handlers: withSequence(0, this.normalize.bind(this)),
|
||||
before_filter_mutation_record_handlers: this.beforeFilteringMutationRecords.bind(this),
|
||||
|
||||
unsplittable_node_predicates: [
|
||||
isProtecting, // avoid merge
|
||||
isUnprotecting,
|
||||
],
|
||||
savable_mutation_record_predicates: this.isMutationRecordSavable.bind(this),
|
||||
removable_descendants_providers: this.filterDescendantsToRemove.bind(this),
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.protectedNodes = new WeakSet();
|
||||
}
|
||||
|
||||
filterDescendantsToRemove(elem) {
|
||||
// TODO @phoenix: history plugin can register protected nodes in its
|
||||
// id maps, should it be prevented? => if yes, take care that data-oe-protected="false"
|
||||
// elements should also be registered even though they are protected.
|
||||
if (isProtecting(elem)) {
|
||||
const descendantsToRemove = [];
|
||||
for (const candidate of elem.querySelectorAll(UNPROTECTED_SELECTOR)) {
|
||||
if (candidate.closest(PROTECTED_SELECTOR) === elem) {
|
||||
descendantsToRemove.push(...childNodes(candidate));
|
||||
}
|
||||
}
|
||||
return descendantsToRemove;
|
||||
}
|
||||
}
|
||||
|
||||
protectNode(node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.matches(UNPROTECTED_SELECTOR)) {
|
||||
this.unProtectDescendants(node);
|
||||
} else if (!this.protectedNodes.has(node)) {
|
||||
this.protectDescendants(node);
|
||||
}
|
||||
// assume that descendants are already handled if the node
|
||||
// is already protected.
|
||||
}
|
||||
this.protectedNodes.add(node);
|
||||
}
|
||||
|
||||
unProtectNode(node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.matches(PROTECTED_SELECTOR)) {
|
||||
this.protectDescendants(node);
|
||||
} else if (this.protectedNodes.has(node)) {
|
||||
this.unProtectDescendants(node);
|
||||
}
|
||||
// assume that descendants are already handled if the node
|
||||
// is already not protected.
|
||||
}
|
||||
this.protectedNodes.delete(node);
|
||||
}
|
||||
|
||||
protectDescendants(node) {
|
||||
let child = node.firstChild;
|
||||
while (child) {
|
||||
this.protectNode(child);
|
||||
child = child.nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
unProtectDescendants(node) {
|
||||
let child = node.firstChild;
|
||||
while (child) {
|
||||
this.unProtectNode(child);
|
||||
child = child.nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HistoryMutationRecord[]} records
|
||||
*/
|
||||
beforeFilteringMutationRecords(records) {
|
||||
for (const record of records) {
|
||||
if (record.type === "childList") {
|
||||
if (record.target.nodeType !== Node.ELEMENT_NODE) {
|
||||
return;
|
||||
}
|
||||
const addedNodes = record.addedTrees.map((tree) => tree.node);
|
||||
if (
|
||||
(this.protectedNodes.has(record.target) &&
|
||||
!record.target.matches(UNPROTECTED_SELECTOR)) ||
|
||||
record.target.matches(PROTECTED_SELECTOR)
|
||||
) {
|
||||
for (const addedNode of addedNodes) {
|
||||
this.protectNode(addedNode);
|
||||
}
|
||||
} else if (
|
||||
!this.protectedNodes.has(record.target) ||
|
||||
record.target.matches(UNPROTECTED_SELECTOR)
|
||||
) {
|
||||
for (const addedNode of addedNodes) {
|
||||
this.unProtectNode(addedNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HistoryMutationRecord} record
|
||||
* @return {boolean}
|
||||
*/
|
||||
isMutationRecordSavable(record) {
|
||||
if (record.type === "childList") {
|
||||
return !(
|
||||
(this.protectedNodes.has(record.target) &&
|
||||
!record.target.matches(UNPROTECTED_SELECTOR)) ||
|
||||
record.target.matches(PROTECTED_SELECTOR)
|
||||
);
|
||||
}
|
||||
return !this.protectedNodes.has(record.target);
|
||||
}
|
||||
|
||||
forEachProtectingElem(elem, callback) {
|
||||
const selector = `[data-oe-protected]`;
|
||||
const protectingNodes = [...elem.querySelectorAll(selector)].reverse();
|
||||
if (elem.matches(selector)) {
|
||||
protectingNodes.push(elem);
|
||||
}
|
||||
for (const protectingNode of protectingNodes) {
|
||||
if (protectingNode.dataset.oeProtected === "false") {
|
||||
callback(protectingNode, false);
|
||||
} else {
|
||||
callback(protectingNode, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalize(elem) {
|
||||
this.forEachProtectingElem(elem, this.setProtectingNode.bind(this));
|
||||
}
|
||||
|
||||
setProtectingNode(elem, protecting) {
|
||||
elem.dataset.oeProtected = protecting;
|
||||
// contenteditable attribute is set on (un)protecting nodes for
|
||||
// implementation convenience. This could be removed but the editor
|
||||
// should be adapted to handle some use cases that are handled for
|
||||
// contenteditable elements. Currently unsupported configurations:
|
||||
// 1) unprotected non-editable content: would typically be added/removed
|
||||
// programmatically and shared in collaboration => some logic should
|
||||
// be added to handle undo/redo properly for consistency.
|
||||
// -> A adds content, A replaces his content with a new one, B replaces
|
||||
// content of A with his own, A undo => there is now the content of B
|
||||
// and the old content of A in the node, is it still coherent?
|
||||
// 2) protected editable content: need a specification of which
|
||||
// functions of the editor are allowed to work (and how) in that
|
||||
// editable part (none?) => should be enforced.
|
||||
if (protecting) {
|
||||
elem.setAttribute("contenteditable", "false");
|
||||
this.protectDescendants(elem);
|
||||
} else {
|
||||
elem.setAttribute("contenteditable", "true");
|
||||
this.unProtectDescendants(elem);
|
||||
}
|
||||
}
|
||||
|
||||
cleanForSave(clone) {
|
||||
this.forEachProtectingElem(clone, (protectingNode) => {
|
||||
protectingNode.removeAttribute("contenteditable");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { selectElements } from "@html_editor/utils/dom_traversal";
|
||||
import { Plugin } from "../plugin";
|
||||
|
||||
/**
|
||||
* @typedef { Object } SanitizeShared
|
||||
* @property { SanitizePlugin['sanitize'] } sanitize
|
||||
*/
|
||||
|
||||
export class SanitizePlugin extends Plugin {
|
||||
static id = "sanitize";
|
||||
static shared = ["sanitize"];
|
||||
resources = {
|
||||
clean_for_save_handlers: this.cleanForSave.bind(this),
|
||||
normalize_handlers: this.normalize.bind(this),
|
||||
};
|
||||
|
||||
setup() {
|
||||
if (!window.DOMPurify) {
|
||||
throw new Error("DOMPurify is not available");
|
||||
}
|
||||
this.DOMPurify = DOMPurify(this.window);
|
||||
}
|
||||
/**
|
||||
* Sanitizes in place an html element. Current implementation uses the
|
||||
* DOMPurify library.
|
||||
*
|
||||
* @param {HTMLElement} elem
|
||||
* @returns {HTMLElement} the element itself
|
||||
*/
|
||||
sanitize(elem) {
|
||||
return this.DOMPurify.sanitize(elem, {
|
||||
IN_PLACE: true,
|
||||
ADD_TAGS: ["#document-fragment", "fake-el"],
|
||||
ADD_ATTR: ["contenteditable", "t-field", "t-out", "t-esc"],
|
||||
});
|
||||
}
|
||||
|
||||
normalize(element) {
|
||||
for (const el of selectElements(
|
||||
element,
|
||||
".o-contenteditable-false, .o-contenteditable-true"
|
||||
)) {
|
||||
el.contentEditable = el.matches(".o-contenteditable-true");
|
||||
}
|
||||
for (const el of selectElements(element, "[data-oe-role]")) {
|
||||
el.setAttribute("role", el.dataset.oeRole);
|
||||
}
|
||||
for (const el of selectElements(element, "[data-oe-aria-label]")) {
|
||||
el.setAttribute("aria-label", el.dataset.oeAriaLabel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that attributes sanitized by the server are properly removed before
|
||||
* the save, to avoid mismatches and a reset of the editable content.
|
||||
* Only attributes under the responsibility (associated with an editor
|
||||
* attribute or class) of the sanitize plugin are removed.
|
||||
*
|
||||
* /!\ CAUTION: using server-sanitized attributes without editor-specific
|
||||
* classes/attributes in a custom plugin should be managed by that same
|
||||
* custom plugin.
|
||||
*/
|
||||
cleanForSave({ root }) {
|
||||
for (const el of selectElements(
|
||||
root,
|
||||
".o-contenteditable-false, .o-contenteditable-true"
|
||||
)) {
|
||||
el.removeAttribute("contenteditable");
|
||||
}
|
||||
for (const el of selectElements(root, "[data-oe-role]")) {
|
||||
el.removeAttribute("role");
|
||||
}
|
||||
for (const el of selectElements(root, "[data-oe-aria-label]")) {
|
||||
el.removeAttribute("aria-label");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,61 @@
|
|||
import { Plugin, isValidTargetForDomListener } from "../plugin";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Shortcut
|
||||
* @property {string} hotkey
|
||||
* @property {string} commandId
|
||||
* @property {Object} [commandParams]
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* resources = {
|
||||
* user_commands: [
|
||||
* { id: "myCommands", run: myCommandFunction },
|
||||
* ],
|
||||
* shortcuts: [
|
||||
* { hotkey: "control+shift+q", commandId: "myCommands" },
|
||||
* ],
|
||||
* }
|
||||
*/
|
||||
|
||||
export class ShortCutPlugin extends Plugin {
|
||||
static id = "shortcut";
|
||||
static dependencies = ["userCommand", "selection"];
|
||||
|
||||
setup() {
|
||||
const hotkeyService = this.services.hotkey;
|
||||
if (!hotkeyService) {
|
||||
throw new Error("ShorcutPlugin needs hotkey service to properly work");
|
||||
}
|
||||
if (document !== this.document) {
|
||||
hotkeyService.registerIframe({ contentWindow: this.window });
|
||||
}
|
||||
for (const shortcut of this.getResource("shortcuts")) {
|
||||
const command = this.dependencies.userCommand.getCommand(shortcut.commandId);
|
||||
this.addShortcut(
|
||||
shortcut.hotkey,
|
||||
() => {
|
||||
command.run(shortcut.commandParams);
|
||||
},
|
||||
{
|
||||
isAvailable: command.isAvailable,
|
||||
global: !!shortcut.global,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
addShortcut(hotkey, action, { isAvailable, global }) {
|
||||
this._cleanups.push(
|
||||
this.services.hotkey.add(hotkey, action, {
|
||||
area: () => this.editable,
|
||||
bypassEditableProtection: true,
|
||||
allowRepeat: true,
|
||||
isAvailable: (target) =>
|
||||
(!isAvailable ||
|
||||
isAvailable(this.dependencies.selection.getEditableSelection())) &&
|
||||
(global || isValidTargetForDomListener(target)),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
import { Plugin } from "../plugin";
|
||||
import { isBlock } from "../utils/blocks";
|
||||
import { fillEmpty, splitTextNode } from "../utils/dom";
|
||||
import {
|
||||
isContentEditable,
|
||||
isContentEditableAncestor,
|
||||
isTextNode,
|
||||
isVisible,
|
||||
} from "../utils/dom_info";
|
||||
import { prepareUpdate } from "../utils/dom_state";
|
||||
import { childNodes, closestElement, firstLeaf, lastLeaf } from "../utils/dom_traversal";
|
||||
import { DIRECTIONS, childNodeIndex, nodeSize } from "../utils/position";
|
||||
import { isProtected, isProtecting } from "@html_editor/utils/dom_info";
|
||||
|
||||
/**
|
||||
* @typedef { Object } SplitShared
|
||||
* @property { SplitPlugin['isUnsplittable'] } isUnsplittable
|
||||
* @property { SplitPlugin['splitAroundUntil'] } splitAroundUntil
|
||||
* @property { SplitPlugin['splitBlock'] } splitBlock
|
||||
* @property { SplitPlugin['splitBlockNode'] } splitBlockNode
|
||||
* @property { SplitPlugin['splitElement'] } splitElement
|
||||
* @property { SplitPlugin['splitElementBlock'] } splitElementBlock
|
||||
* @property { SplitPlugin['splitSelection'] } splitSelection
|
||||
*/
|
||||
|
||||
export class SplitPlugin extends Plugin {
|
||||
static dependencies = ["baseContainer", "selection", "history", "input", "delete", "lineBreak"];
|
||||
static id = "split";
|
||||
static shared = [
|
||||
"splitBlock",
|
||||
"splitBlockNode",
|
||||
"splitElementBlock",
|
||||
"splitElement",
|
||||
"splitAroundUntil",
|
||||
"splitSelection",
|
||||
"isUnsplittable",
|
||||
];
|
||||
resources = {
|
||||
beforeinput_handlers: this.onBeforeInput.bind(this),
|
||||
|
||||
unsplittable_node_predicates: [
|
||||
// An unremovable element is also unmergeable (as merging two
|
||||
// elements results in removing one of them).
|
||||
// An unmergeable element is unsplittable and vice-versa (as
|
||||
// split and merge are reverse operations from one another).
|
||||
// Therefore, unremovable nodes are also unsplittable.
|
||||
(node) =>
|
||||
this.getResource("unremovable_node_predicates").some((predicate) =>
|
||||
predicate(node)
|
||||
),
|
||||
// "Unbreakable" is a legacy term that means unsplittable and
|
||||
// unmergeable.
|
||||
(node) => node.classList?.contains("oe_unbreakable"),
|
||||
(node) => {
|
||||
const isExplicitlyNotContentEditable = (node) =>
|
||||
// In the `contenteditable` attribute consideration,
|
||||
// disconnected nodes can be unsplittable only if they are
|
||||
// explicitly set under a contenteditable="false" element.
|
||||
!isContentEditable(node) &&
|
||||
(node.isConnected || closestElement(node, "[contenteditable]"));
|
||||
return (
|
||||
isExplicitlyNotContentEditable(node) ||
|
||||
// If node sets contenteditable='true' and is inside a non-editable
|
||||
// context, it has to be unsplittable since splitting it would modify
|
||||
// the non-editable parent content.
|
||||
(node.parentElement &&
|
||||
isContentEditableAncestor(node) &&
|
||||
isExplicitlyNotContentEditable(node.parentElement))
|
||||
);
|
||||
},
|
||||
(node) => node.nodeName === "SECTION",
|
||||
],
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// commands
|
||||
// --------------------------------------------------------------------------
|
||||
splitBlock() {
|
||||
this.dispatchTo("before_split_block_handlers");
|
||||
let selection = this.dependencies.selection.getSelectionData().deepEditableSelection;
|
||||
if (!selection.isCollapsed) {
|
||||
// @todo @phoenix collapseIfZWS is not tested
|
||||
// this.shared.collapseIfZWS();
|
||||
this.dependencies.delete.deleteSelection();
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
}
|
||||
|
||||
return this.splitBlockNode({
|
||||
targetNode: selection.anchorNode,
|
||||
targetOffset: selection.anchorOffset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {Node} param0.targetNode
|
||||
* @param {number} param0.targetOffset
|
||||
* @returns {[HTMLElement|undefined, HTMLElement|undefined]}
|
||||
*/
|
||||
splitBlockNode({ targetNode, targetOffset }) {
|
||||
if (targetNode.nodeType === Node.TEXT_NODE) {
|
||||
targetOffset = splitTextNode(targetNode, targetOffset);
|
||||
targetNode = targetNode.parentElement;
|
||||
}
|
||||
const blockToSplit = closestElement(targetNode, isBlock);
|
||||
const params = { targetNode, targetOffset, blockToSplit };
|
||||
|
||||
if (this.delegateTo("split_element_block_overrides", params)) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
return this.splitElementBlock(params);
|
||||
}
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {HTMLElement} param0.targetNode
|
||||
* @param {number} param0.targetOffset
|
||||
* @param {HTMLElement} param0.blockToSplit
|
||||
* @returns {[HTMLElement|undefined, HTMLElement|undefined]}
|
||||
*/
|
||||
splitElementBlock({ targetNode, targetOffset, blockToSplit }) {
|
||||
// If the block is unsplittable, insert a line break instead.
|
||||
if (this.isUnsplittable(blockToSplit)) {
|
||||
// @todo: t-if, t-else etc are not blocks, but they are
|
||||
// unsplittable. The check must be done from the targetNode up to
|
||||
// the block for unsplittables. There are apparently no tests for
|
||||
// this.
|
||||
this.dependencies.lineBreak.insertLineBreakElement({ targetNode, targetOffset });
|
||||
return [undefined, undefined];
|
||||
}
|
||||
const restore = prepareUpdate(targetNode, targetOffset);
|
||||
|
||||
const [beforeElement, afterElement] = this.splitElementUntil(
|
||||
targetNode,
|
||||
targetOffset,
|
||||
blockToSplit.parentElement
|
||||
);
|
||||
restore();
|
||||
const fillEmptyElement = (node) => {
|
||||
if (isProtecting(node) || isProtected(node)) {
|
||||
// TODO ABD: add test
|
||||
return;
|
||||
} else if (node.nodeType === Node.TEXT_NODE && !isVisible(node)) {
|
||||
const parent = node.parentElement;
|
||||
node.remove();
|
||||
fillEmptyElement(parent);
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.hasAttribute("data-oe-zws-empty-inline")) {
|
||||
delete node.dataset.oeZwsEmptyInline;
|
||||
}
|
||||
fillEmpty(node);
|
||||
}
|
||||
};
|
||||
fillEmptyElement(lastLeaf(beforeElement));
|
||||
fillEmptyElement(firstLeaf(afterElement));
|
||||
|
||||
this.dependencies.selection.setCursorStart(afterElement);
|
||||
|
||||
return [beforeElement, afterElement];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isUnsplittable(node) {
|
||||
return this.getResource("unsplittable_node_predicates").some((p) => p(node));
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the given element at the given offset. The element will be removed in
|
||||
* the process so caution is advised in dealing with its reference. Returns a
|
||||
* tuple containing the new elements on both sides of the split.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {number} offset
|
||||
* @returns {[HTMLElement, HTMLElement]}
|
||||
*/
|
||||
splitElement(element, offset) {
|
||||
/** @type {HTMLElement} **/
|
||||
const firstPart = element.cloneNode();
|
||||
/** @type {HTMLElement} **/
|
||||
const secondPart = element.cloneNode();
|
||||
element.before(firstPart);
|
||||
element.after(secondPart);
|
||||
const children = childNodes(element);
|
||||
firstPart.append(...children.slice(0, offset));
|
||||
secondPart.append(...children.slice(offset));
|
||||
element.remove();
|
||||
this.dispatchTo("after_split_element_handlers", { firstPart, secondPart });
|
||||
return [firstPart, secondPart];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the given element at the given offset, until the given limit ancestor.
|
||||
* The element will be removed in the process so caution is advised in dealing
|
||||
* with its reference. Returns a tuple containing the new elements on both sides
|
||||
* of the split.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {number} offset
|
||||
* @param {HTMLElement} limitAncestor
|
||||
* @returns {[HTMLElement, HTMLElement]}
|
||||
*/
|
||||
splitElementUntil(element, offset, limitAncestor) {
|
||||
if (element === limitAncestor) {
|
||||
return [element, element];
|
||||
}
|
||||
let [before, after] = this.splitElement(element, offset);
|
||||
if (after.parentElement !== limitAncestor) {
|
||||
const afterIndex = childNodeIndex(after);
|
||||
[before, after] = this.splitElementUntil(
|
||||
after.parentElement,
|
||||
afterIndex,
|
||||
limitAncestor
|
||||
);
|
||||
}
|
||||
return [before, after];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split around the given elements, until a given ancestor (included). Elements
|
||||
* will be removed in the process so caution is advised in dealing with their
|
||||
* references. Returns the new split root element that is a clone of
|
||||
* limitAncestor or the original limitAncestor if no split occured.
|
||||
*
|
||||
* @param {Node[] | Node} elements
|
||||
* @param {HTMLElement} limitAncestor
|
||||
* @returns { Node }
|
||||
*/
|
||||
splitAroundUntil(elements, limitAncestor) {
|
||||
elements = Array.isArray(elements) ? elements : [elements];
|
||||
const firstNode = elements[0];
|
||||
const lastNode = elements[elements.length - 1];
|
||||
if ([firstNode, lastNode].includes(limitAncestor)) {
|
||||
return limitAncestor;
|
||||
}
|
||||
let before = firstNode.previousSibling;
|
||||
let after = lastNode.nextSibling;
|
||||
let beforeSplit, afterSplit;
|
||||
if (
|
||||
!before &&
|
||||
!after &&
|
||||
firstNode.parentElement !== limitAncestor &&
|
||||
lastNode.parentElement !== limitAncestor
|
||||
) {
|
||||
return this.splitAroundUntil(
|
||||
[firstNode.parentElement, lastNode.parentElement],
|
||||
limitAncestor
|
||||
);
|
||||
} else if (!after && lastNode.parentElement !== limitAncestor) {
|
||||
return this.splitAroundUntil([firstNode, lastNode.parentElement], limitAncestor);
|
||||
} else if (!before && firstNode.parentElement !== limitAncestor) {
|
||||
return this.splitAroundUntil([firstNode.parentElement, lastNode], limitAncestor);
|
||||
}
|
||||
// Split up ancestors up to font
|
||||
while (after && after.parentElement !== limitAncestor) {
|
||||
afterSplit = this.splitElement(after.parentElement, childNodeIndex(after))[0];
|
||||
after = afterSplit.nextSibling;
|
||||
}
|
||||
if (after) {
|
||||
afterSplit = this.splitElement(limitAncestor, childNodeIndex(after))[0];
|
||||
limitAncestor = afterSplit;
|
||||
}
|
||||
while (before && before.parentElement !== limitAncestor) {
|
||||
beforeSplit = this.splitElement(before.parentElement, childNodeIndex(before) + 1)[1];
|
||||
before = beforeSplit.previousSibling;
|
||||
}
|
||||
if (before) {
|
||||
beforeSplit = this.splitElement(limitAncestor, childNodeIndex(before) + 1)[1];
|
||||
}
|
||||
return beforeSplit || afterSplit || limitAncestor;
|
||||
}
|
||||
|
||||
splitSelection() {
|
||||
let { startContainer, startOffset, endContainer, endOffset, direction } =
|
||||
this.dependencies.selection.getEditableSelection();
|
||||
const isInSingleContainer = startContainer === endContainer;
|
||||
if (isTextNode(endContainer) && endOffset > 0 && endOffset < nodeSize(endContainer)) {
|
||||
const endParent = endContainer.parentNode;
|
||||
const splitOffset = splitTextNode(endContainer, endOffset);
|
||||
endContainer = endParent.childNodes[splitOffset - 1] || endParent.firstChild;
|
||||
if (isInSingleContainer) {
|
||||
startContainer = endContainer;
|
||||
}
|
||||
endOffset = endContainer.textContent.length;
|
||||
}
|
||||
if (
|
||||
isTextNode(startContainer) &&
|
||||
startOffset > 0 &&
|
||||
startOffset < nodeSize(startContainer)
|
||||
) {
|
||||
splitTextNode(startContainer, startOffset);
|
||||
startOffset = 0;
|
||||
if (isInSingleContainer) {
|
||||
endOffset = startContainer.textContent.length;
|
||||
}
|
||||
}
|
||||
|
||||
const selection =
|
||||
direction === DIRECTIONS.RIGHT
|
||||
? {
|
||||
anchorNode: startContainer,
|
||||
anchorOffset: startOffset,
|
||||
focusNode: endContainer,
|
||||
focusOffset: endOffset,
|
||||
}
|
||||
: {
|
||||
anchorNode: endContainer,
|
||||
anchorOffset: endOffset,
|
||||
focusNode: startContainer,
|
||||
focusOffset: startOffset,
|
||||
};
|
||||
return this.dependencies.selection.setSelection(selection, { normalize: false });
|
||||
}
|
||||
|
||||
onBeforeInput(e) {
|
||||
if (e.inputType === "insertParagraph") {
|
||||
e.preventDefault();
|
||||
this.splitBlock();
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Plugin } from "@html_editor/plugin";
|
||||
import { backgroundImageCssToParts, backgroundImagePartsToCss } from "@html_editor/utils/image";
|
||||
|
||||
/**
|
||||
* @typedef { Object } StyleShared
|
||||
* @property { StylePlugin['setBackgroundImageUrl'] } setBackgroundImageUrl
|
||||
*/
|
||||
|
||||
export class StylePlugin extends Plugin {
|
||||
static id = "style";
|
||||
static shared = ["setBackgroundImageUrl"];
|
||||
|
||||
setBackgroundImageUrl(el, value) {
|
||||
const parts = backgroundImageCssToParts(el.style["background-image"]);
|
||||
if (value) {
|
||||
parts.url = `url('${value}')`;
|
||||
} else {
|
||||
delete parts.url;
|
||||
}
|
||||
el.style["background-image"] = backgroundImagePartsToCss(parts);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Plugin } from "../plugin";
|
||||
|
||||
/**
|
||||
* @typedef { import("./selection_plugin").EditorSelection } EditorSelection
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef { Object } UserCommand
|
||||
* @property { string } id
|
||||
* @property { Function } run
|
||||
* @property { String } [title]
|
||||
* @property { String } [description]
|
||||
* @property { string } [icon]
|
||||
* @property { (selection: EditorSelection) => boolean } [isAvailable]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef { Object } UserCommandShared
|
||||
* @property { UserCommandPlugin['getCommand'] } getCommand
|
||||
*/
|
||||
|
||||
export class UserCommandPlugin extends Plugin {
|
||||
static id = "userCommand";
|
||||
static shared = ["getCommand"];
|
||||
|
||||
setup() {
|
||||
this.commands = {};
|
||||
for (const command of this.getResource("user_commands")) {
|
||||
if (command.id in this.commands) {
|
||||
throw new Error(`Duplicate user command id: ${command.id}`);
|
||||
}
|
||||
this.commands[command.id] = command;
|
||||
}
|
||||
Object.freeze(this.commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} commandId
|
||||
* @returns {UserCommand}
|
||||
* @throws {Error} if the command ID is unknown.
|
||||
*/
|
||||
getCommand(commandId) {
|
||||
const command = this.commands[commandId];
|
||||
if (!command) {
|
||||
throw new Error(`Unknown user command id: ${commandId}`);
|
||||
}
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { useEffect, useState } from "@odoo/owl";
|
||||
|
||||
export function useDropdownAutoVisibility(overlayState, popoverRef) {
|
||||
if (!overlayState) {
|
||||
return;
|
||||
}
|
||||
const state = useState(overlayState);
|
||||
useEffect(
|
||||
() => {
|
||||
if (popoverRef.el) {
|
||||
if (!state.isOverlayVisible) {
|
||||
popoverRef.el.style.visibility = "hidden";
|
||||
} else {
|
||||
popoverRef.el.style.visibility = "visible";
|
||||
}
|
||||
}
|
||||
},
|
||||
() => [state.isOverlayVisible]
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
import { MAIN_PLUGINS } from "./plugin_sets";
|
||||
import { createBaseContainer, SUPPORTED_BASE_CONTAINER_NAMES } from "./utils/base_container";
|
||||
import { fillShrunkPhrasingParent, removeClass } from "./utils/dom";
|
||||
import { isEmpty } from "./utils/dom_info";
|
||||
import { resourceSequenceSymbol, withSequence } from "./utils/resource";
|
||||
import { fixInvalidHTML, initElementForEdition } from "./utils/sanitize";
|
||||
import { setElementContent } from "@web/core/utils/html";
|
||||
|
||||
/**
|
||||
* @typedef { import("./plugin_sets").SharedMethods } SharedMethods
|
||||
* @typedef {typeof import("./plugin").Plugin} PluginConstructor
|
||||
**/
|
||||
|
||||
/**
|
||||
* @typedef { Object } CollaborationConfig
|
||||
* @property { string } collaboration.peerId
|
||||
* @property { Object } collaboration.busService
|
||||
* @property { Object } collaboration.collaborationChannel
|
||||
* @property { String } collaboration.collaborationChannel.collaborationModelName
|
||||
* @property { String } collaboration.collaborationChannel.collaborationFieldName
|
||||
* @property { Number } collaboration.collaborationChannel.collaborationResId
|
||||
* @property { 'start' | 'focus' } [collaboration.collaborativeTrigger]
|
||||
|
||||
* @typedef { Object } EditorConfig
|
||||
* @property { string } [content]
|
||||
* @property { boolean } [allowInlineAtRoot]
|
||||
* @property { string[] } [baseContainers]
|
||||
* @property { PluginConstructor[] } [Plugins]
|
||||
* @property { string[] } [classList]
|
||||
* @property { Object } [localOverlayContainers]
|
||||
* @property { Object } [embeddedComponentInfo]
|
||||
* @property { Object } [resources]
|
||||
* @property { string } [direction="ltr"]
|
||||
* @property { Function } [onChange]
|
||||
* @property { Function } [onEditorReady]
|
||||
* @property { boolean } [dropImageAsAttachment]
|
||||
* @property { CollaborationConfig } [collaboration]
|
||||
* @property { Function } getRecordInfo
|
||||
*/
|
||||
|
||||
function sortPlugins(plugins) {
|
||||
const initialPlugins = new Set(plugins);
|
||||
const inResult = new Set();
|
||||
// need to sort them
|
||||
const result = [];
|
||||
let P;
|
||||
|
||||
function findPlugin() {
|
||||
for (const P of initialPlugins) {
|
||||
if (P.dependencies.every((dep) => inResult.has(dep))) {
|
||||
initialPlugins.delete(P);
|
||||
return P;
|
||||
}
|
||||
}
|
||||
}
|
||||
while ((P = findPlugin())) {
|
||||
inResult.add(P.id);
|
||||
result.push(P);
|
||||
}
|
||||
if (initialPlugins.size) {
|
||||
const messages = [];
|
||||
for (const P of initialPlugins) {
|
||||
messages.push(
|
||||
`"${P.id}" is missing (${P.dependencies
|
||||
.filter((d) => !inResult.has(d))
|
||||
.join(", ")})`
|
||||
);
|
||||
}
|
||||
throw new Error(`Missing dependencies: ${messages.join(", ")}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class Editor {
|
||||
/**
|
||||
* @param { EditorConfig } config
|
||||
*/
|
||||
constructor(config, services) {
|
||||
this.isReady = false;
|
||||
this.isDestroyed = false;
|
||||
this.config = config;
|
||||
this.services = services;
|
||||
this.resources = null;
|
||||
this.plugins = [];
|
||||
/** @type { HTMLElement } **/
|
||||
this.editable = null;
|
||||
/** @type { Document } **/
|
||||
this.document = null;
|
||||
/** @ts-ignore @type { SharedMethods } **/
|
||||
this.shared = {};
|
||||
}
|
||||
|
||||
attachTo(editable) {
|
||||
if (this.isDestroyed || this.editable) {
|
||||
throw new Error("Cannot re-attach an editor");
|
||||
}
|
||||
this.editable = editable;
|
||||
this.document = editable.ownerDocument;
|
||||
this.preparePlugins();
|
||||
if ("content" in this.config) {
|
||||
setElementContent(editable, fixInvalidHTML(this.config.content));
|
||||
if (isEmpty(editable)) {
|
||||
const baseContainer = createBaseContainer(
|
||||
this.config.baseContainers[0],
|
||||
this.document
|
||||
);
|
||||
fillShrunkPhrasingParent(baseContainer);
|
||||
editable.replaceChildren(baseContainer);
|
||||
}
|
||||
}
|
||||
editable.setAttribute("contenteditable", true);
|
||||
initElementForEdition(editable, { allowInlineAtRoot: !!this.config.allowInlineAtRoot });
|
||||
editable.classList.add("odoo-editor-editable");
|
||||
if (this.config.classList) {
|
||||
editable.classList.add(...this.config.classList);
|
||||
}
|
||||
if (this.config.height) {
|
||||
editable.style.height = this.config.height;
|
||||
}
|
||||
if (
|
||||
!this.config.baseContainers.every((name) =>
|
||||
SUPPORTED_BASE_CONTAINER_NAMES.includes(name)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid baseContainers: ${this.config.baseContainers.join(
|
||||
", "
|
||||
)}. Supported: ${SUPPORTED_BASE_CONTAINER_NAMES.join(", ")}`
|
||||
);
|
||||
}
|
||||
this.startPlugins();
|
||||
this.isReady = true;
|
||||
this.config.onEditorReady?.();
|
||||
}
|
||||
|
||||
preparePlugins() {
|
||||
const Plugins = sortPlugins(this.config.Plugins || MAIN_PLUGINS);
|
||||
this.config = Object.assign({}, ...Plugins.map((P) => P.defaultConfig), this.config);
|
||||
const plugins = new Map();
|
||||
for (const P of Plugins) {
|
||||
if (P.id === "") {
|
||||
throw new Error(`Missing plugin id (class ${P.name})`);
|
||||
}
|
||||
if (plugins.has(P.id)) {
|
||||
throw new Error(`Duplicate plugin id: ${P.id}`);
|
||||
}
|
||||
const imports = {};
|
||||
for (const dep of P.dependencies) {
|
||||
if (plugins.has(dep)) {
|
||||
imports[dep] = {};
|
||||
for (const h of plugins.get(dep).shared) {
|
||||
imports[dep][h] = this.shared[dep][h];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Missing dependency for plugin ${P.id}: ${dep}`);
|
||||
}
|
||||
}
|
||||
plugins.set(P.id, P);
|
||||
const plugin = new P(this.document, this.editable, imports, this.config, this.services);
|
||||
this.plugins.push(plugin);
|
||||
const exports = {};
|
||||
for (const h of P.shared) {
|
||||
if (!(h in plugin)) {
|
||||
throw new Error(`Missing helper implementation: ${h} in plugin ${P.id}`);
|
||||
}
|
||||
exports[h] = plugin[h].bind(plugin);
|
||||
}
|
||||
this.shared[P.id] = exports;
|
||||
}
|
||||
const resources = this.createResources();
|
||||
for (const plugin of this.plugins) {
|
||||
plugin._resources = resources;
|
||||
}
|
||||
this.resources = resources;
|
||||
}
|
||||
|
||||
startPlugins() {
|
||||
for (const plugin of this.plugins) {
|
||||
plugin.setup();
|
||||
}
|
||||
this.resources["normalize_handlers"].forEach((cb) => cb(this.editable));
|
||||
this.resources["start_edition_handlers"].forEach((cb) => cb());
|
||||
}
|
||||
|
||||
createResources() {
|
||||
const resources = {};
|
||||
|
||||
function registerResources(obj) {
|
||||
for (const key in obj) {
|
||||
if (!(key in resources)) {
|
||||
resources[key] = [];
|
||||
}
|
||||
resources[key].push(obj[key]);
|
||||
}
|
||||
}
|
||||
if (this.config.resources) {
|
||||
registerResources(this.config.resources);
|
||||
}
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.resources) {
|
||||
registerResources(plugin.resources);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in resources) {
|
||||
const resource = resources[key]
|
||||
.flat()
|
||||
.map((r) => {
|
||||
const isObjectWithSequence =
|
||||
typeof r === "object" && r !== null && resourceSequenceSymbol in r;
|
||||
return isObjectWithSequence ? r : withSequence(10, r);
|
||||
})
|
||||
.sort((a, b) => a[resourceSequenceSymbol] - b[resourceSequenceSymbol])
|
||||
.map((r) => r.object);
|
||||
|
||||
resources[key] = resource;
|
||||
Object.freeze(resources[key]);
|
||||
}
|
||||
|
||||
return Object.freeze(resources);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} resourceId
|
||||
* @returns {Array}
|
||||
*/
|
||||
getResource(resourceId) {
|
||||
return this.resources[resourceId] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the functions registered under resourceId with the given
|
||||
* arguments.
|
||||
*
|
||||
* @param {string} resourceId
|
||||
* @param {...any} args The arguments to pass to the handlers
|
||||
*/
|
||||
dispatchTo(resourceId, ...args) {
|
||||
this.getResource(resourceId).forEach((handler) => handler(...args));
|
||||
}
|
||||
|
||||
getContent() {
|
||||
return this.getElContent().innerHTML;
|
||||
}
|
||||
|
||||
getElContent() {
|
||||
const el = this.editable.cloneNode(true);
|
||||
this.resources["clean_for_save_handlers"].forEach((cb) => cb({ root: el }));
|
||||
return el;
|
||||
}
|
||||
|
||||
destroy(willBeRemoved) {
|
||||
if (this.editable) {
|
||||
let plugin;
|
||||
while ((plugin = this.plugins.pop())) {
|
||||
plugin.destroy();
|
||||
}
|
||||
this.shared = {};
|
||||
if (!willBeRemoved) {
|
||||
// we only remove class/attributes when necessary. If we know that the editable
|
||||
// element will be removed, no need to make changes that may require the browser
|
||||
// to recompute the layout
|
||||
this.editable.removeAttribute("contenteditable");
|
||||
removeClass(this.editable, "odoo-editor-editable");
|
||||
}
|
||||
this.editable = null;
|
||||
}
|
||||
this.isDestroyed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,402 @@
|
|||
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,
|
||||
EMBEDDED_COMPONENT_PLUGINS,
|
||||
MAIN_PLUGINS,
|
||||
NO_EMBEDDED_COMPONENTS_FALLBACK_PLUGINS,
|
||||
} from "@html_editor/plugin_sets";
|
||||
import { DYNAMIC_PLACEHOLDER_PLUGINS } from "@html_editor/backend/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_editor/components/html_viewer/html_viewer";
|
||||
import { EditorVersionPlugin } from "@html_editor/core/editor_version_plugin";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
import { fixInvalidHTML, instanceofMarkup } from "@html_editor/utils/sanitize";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
|
||||
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 },
|
||||
migrateHTML: { type: Boolean, 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] || "";
|
||||
let newVal = fixInvalidHTML(value);
|
||||
if (this.props.migrateHTML) {
|
||||
newVal = this.htmlUpgradeManager.processForUpgrade(newVal, {
|
||||
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() {
|
||||
await this.editor.shared.imageSave?.savePendingImages();
|
||||
return this.editor.getElContent();
|
||||
}
|
||||
|
||||
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) {
|
||||
return 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: [
|
||||
...(this.props.migrateHTML ? [EditorVersionPlugin] : []),
|
||||
...MAIN_PLUGINS,
|
||||
...(this.props.isCollaborative ? COLLABORATION_PLUGINS : []),
|
||||
...(this.props.dynamicPlaceholder ? DYNAMIC_PLACEHOLDER_PLUGINS : []),
|
||||
...(this.props.embeddedComponents
|
||||
? EMBEDDED_COMPONENT_PLUGINS
|
||||
: NO_EMBEDDED_COMPONENTS_FALLBACK_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.props.dynamicPlaceholder,
|
||||
dynamicPlaceholderResModel:
|
||||
this.props.record.data[this.props.dynamicPlaceholderModelReferenceField || "model"],
|
||||
direction: localization.direction || "ltr",
|
||||
getRecordInfo: () => {
|
||||
const { resModel, resId, data, fields, id } = this.props.record;
|
||||
return { resModel, resId, data, fields, id };
|
||||
},
|
||||
resources: {},
|
||||
...this.props.editorConfig,
|
||||
};
|
||||
|
||||
if (!("baseContainers" in config)) {
|
||||
config.baseContainers = ["DIV", "P"];
|
||||
}
|
||||
|
||||
if (this.props.embeddedComponents) {
|
||||
config.resources.embedded_components = [...MAIN_EMBEDDINGS];
|
||||
config.embeddedComponentInfo = { app: this.__owl__.app, env: this.env };
|
||||
}
|
||||
|
||||
const { sanitize_tags, sanitize } = this.props.record.fields[this.props.name];
|
||||
if (
|
||||
!("allowVideo" in config) &&
|
||||
!this.props.embeddedComponents &&
|
||||
(sanitize_tags || (sanitize_tags === undefined && sanitize))
|
||||
) {
|
||||
config.allowVideo = false; // Tag-sanitized fields remove videos.
|
||||
}
|
||||
if (this.props.codeview) {
|
||||
config.resources = {
|
||||
...config.resources,
|
||||
user_commands: [
|
||||
{
|
||||
id: "codeview",
|
||||
description: _t("Code view"),
|
||||
icon: "fa-code",
|
||||
run: this.toggleCodeView.bind(this),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
],
|
||||
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,
|
||||
};
|
||||
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 ("allowImage" in options) {
|
||||
editorConfig.allowImage = Boolean(options.allowImage);
|
||||
}
|
||||
if ("allowMediaDocuments" in options) {
|
||||
editorConfig.allowMediaDocuments = Boolean(options.allowMediaDocuments);
|
||||
}
|
||||
if ("allowVideo" in options) {
|
||||
editorConfig.allowVideo = Boolean(options.allowVideo);
|
||||
}
|
||||
if ("allowFile" in options) {
|
||||
editorConfig.allowFile = Boolean(options.allowFile);
|
||||
}
|
||||
if ("allowChecklist" in options) {
|
||||
editorConfig.allowChecklist = Boolean(options.allowChecklist);
|
||||
}
|
||||
if ("allowAttachmentCreation" in options) {
|
||||
editorConfig.allowImage = Boolean(options.allowAttachmentCreation);
|
||||
editorConfig.allowFile = Boolean(options.allowAttachmentCreation);
|
||||
}
|
||||
if ("baseContainers" in options) {
|
||||
editorConfig.baseContainers = options.baseContainers;
|
||||
}
|
||||
if ("cleanEmptyStructuralContainers" in options) {
|
||||
editorConfig.cleanEmptyStructuralContainers = Boolean(
|
||||
options.cleanEmptyStructuralContainers
|
||||
);
|
||||
}
|
||||
return {
|
||||
editorConfig,
|
||||
isCollaborative: options.collaborative,
|
||||
collaborativeTrigger: options.collaborative_trigger,
|
||||
migrateHTML: "migrateHTML" in options ? Boolean(options.migrateHTML) : true,
|
||||
dynamicPlaceholder: options.dynamic_placeholder,
|
||||
dynamicPlaceholderModelReferenceField:
|
||||
options.dynamic_placeholder_model_reference_field,
|
||||
embeddedComponents:
|
||||
"embedded_components" in options ? Boolean(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 button.o_field_translate {
|
||||
right: 40px;
|
||||
}
|
||||
|
||||
.o_field_translate .note-editable {
|
||||
padding-right: 40px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.HtmlField">
|
||||
<t t-if="this.displayReadonly">
|
||||
<HtmlViewer
|
||||
config="getReadonlyConfig()"
|
||||
migrateHTML="false"/>
|
||||
</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,96 @@
|
|||
import { MAIN_PLUGINS } from "@html_editor/plugin_sets";
|
||||
import { HtmlViewer } from "@html_editor/components/html_viewer/html_viewer";
|
||||
import { EditorVersionPlugin } from "@html_editor/core/editor_version_plugin";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { PropertyValue } from "@web/views/fields/properties/property_value";
|
||||
import { HtmlUpgradeManager } from "@html_editor/html_migrations/html_upgrade_manager";
|
||||
import { normalizeHTML } from "@html_editor/utils/html";
|
||||
import { Wysiwyg } from "@html_editor/wysiwyg";
|
||||
import { user } from "@web/core/user";
|
||||
import { useState, onWillStart, onWillUpdateProps } from "@odoo/owl";
|
||||
|
||||
patch(PropertyValue.prototype, {
|
||||
setup() {
|
||||
this.htmlUpgradeManager = new HtmlUpgradeManager();
|
||||
this.lastHtmlValue = this.propertyValue?.toString();
|
||||
onWillStart(async () => {
|
||||
this.htmlState.isPortalUser = await user.hasGroup("base.group_portal");
|
||||
});
|
||||
this.htmlState = useState({ isPortalUser: false, key: 0 });
|
||||
|
||||
onWillUpdateProps((newProps) => {
|
||||
const newValueStr = newProps.value?.toString();
|
||||
if (newProps.type === "html" && newValueStr !== this.lastHtmlValue) {
|
||||
this.htmlState.key += 1;
|
||||
this.lastHtmlValue = newValueStr;
|
||||
}
|
||||
});
|
||||
|
||||
return super.setup();
|
||||
},
|
||||
|
||||
get propertyValue() {
|
||||
const value = super.propertyValue;
|
||||
return this.props.type === "html"
|
||||
? this.htmlUpgradeManager.processForUpgrade(value || "")
|
||||
: value;
|
||||
},
|
||||
|
||||
onEditorLoad(editor) {
|
||||
this.editor = editor;
|
||||
},
|
||||
|
||||
async onEditorBlur() {
|
||||
const value = this.editor.getContent();
|
||||
if (normalizeHTML(value) !== normalizeHTML(this.lastHtmlValue)) {
|
||||
this.onValueChange(value);
|
||||
this.lastHtmlValue = value;
|
||||
}
|
||||
},
|
||||
|
||||
onWysiwygChange() {
|
||||
if (!this.editor.editable.contains(document.activeElement)) {
|
||||
// The DOM of the Wysiwyg have been changed, while the user is not editing
|
||||
// (eg the chatgpt widget), mark the field as dirty
|
||||
this.props.record.model.bus.trigger("FIELD_IS_DIRTY", true);
|
||||
this.onEditorBlur();
|
||||
}
|
||||
},
|
||||
|
||||
getConfig() {
|
||||
let plugins = [...MAIN_PLUGINS, EditorVersionPlugin];
|
||||
if (this.htmlState.isPortalUser) {
|
||||
const toRemove = ["file", "media"];
|
||||
plugins = plugins.filter(
|
||||
(plugin) =>
|
||||
!toRemove.some((p) => plugin.id === p || plugin.dependencies.includes(p))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: this.propertyValue,
|
||||
debug: !!this.env.debug,
|
||||
direction: localization.direction || "ltr",
|
||||
onChange: this.onWysiwygChange.bind(this),
|
||||
placeholder: this.props.placeholder,
|
||||
Plugins: plugins,
|
||||
dropImageAsAttachment: true,
|
||||
allowVideo: false,
|
||||
getRecordInfo: () => {
|
||||
const { resModel, resId, data, fields, id } = this.props.record;
|
||||
return { resModel, resId, data, fields, id };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
getReadonlyConfig() {
|
||||
return {
|
||||
value: this.propertyValue,
|
||||
hasFullHtml: false,
|
||||
cssAssetId: "web.assets_frontend",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
PropertyValue.components = { ...PropertyValue.components, HtmlViewer, Wysiwyg };
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
.o_property_field_value {
|
||||
.o-wysiwyg {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h4 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
h5 {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
h6 {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="web.PropertyValue" t-inherit-mode="extension">
|
||||
<xpath expr="//t[@t-elif="props.type === 'text'"]" position="before">
|
||||
<t t-elif="props.type === 'html'">
|
||||
<HtmlViewer
|
||||
t-if="this.props.readonly"
|
||||
config="getReadonlyConfig()"
|
||||
migrateHTML="false"/>
|
||||
<Wysiwyg
|
||||
t-else=""
|
||||
config="this.getConfig()"
|
||||
onLoad.bind="onEditorLoad"
|
||||
onBlur.bind="onEditorBlur"
|
||||
t-key="htmlState.key"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { MediaDialog } from "@html_editor/main/media/media_dialog/media_dialog";
|
||||
import { VideoSelector } from "@html_editor/main/media/media_dialog/video_selector";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class CustomMediaDialog extends MediaDialog {
|
||||
static defaultProps = {
|
||||
...MediaDialog.defaultProps,
|
||||
extraTabs: [{ id: "VIDEOS", title: _t("Videos"), Component: VideoSelector }],
|
||||
};
|
||||
async save() {
|
||||
if (this.errorMessages[this.state?.activeTab]) {
|
||||
this.notificationService.add(this.errorMessages[this.state.activeTab], {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.state.activeTab == "IMAGES") {
|
||||
const attachments = this.selectedMedia[this.state.activeTab];
|
||||
const preloadedAttachments = attachments.filter((attachment) => attachment.res_model);
|
||||
this.selectedMedia[this.state.activeTab] = attachments.filter(
|
||||
(attachment) => !preloadedAttachments.includes(attachment)
|
||||
);
|
||||
if (this.selectedMedia[this.state.activeTab].length > 0) {
|
||||
await super.save();
|
||||
const newAttachments = this.selectedMedia[this.state.activeTab];
|
||||
this.props.imageSave(newAttachments);
|
||||
}
|
||||
if (preloadedAttachments.length) {
|
||||
this.props.imageSave(preloadedAttachments);
|
||||
}
|
||||
} else {
|
||||
this.props.videoSave(this.selectedMedia[this.state.activeTab]);
|
||||
}
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { ImageField, imageField } from "@web/views/fields/image/image_field";
|
||||
import { CustomMediaDialog } from "./custom_media_dialog";
|
||||
import { getVideoUrl } from "@html_editor/utils/url";
|
||||
|
||||
export class X2ManyImageField extends ImageField {
|
||||
static template = "html_editor.ImageField";
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.dialog = useService("dialog");
|
||||
}
|
||||
|
||||
/**
|
||||
* New method and a new edit button is introduced here to overwrite,
|
||||
* standard behavior of opening file input box in order to update a record.
|
||||
*/
|
||||
onFileEdit(ev) {
|
||||
const isVideo = this.props.record.data.video_url;
|
||||
let mediaEl;
|
||||
if (isVideo) {
|
||||
mediaEl = document.createElement("img");
|
||||
mediaEl.dataset.src = this.props.record.data.video_url;
|
||||
}
|
||||
this.dialog.add(CustomMediaDialog, {
|
||||
visibleTabs: ["IMAGES", "VIDEOS"],
|
||||
media: mediaEl,
|
||||
activeTab: isVideo ? "VIDEOS" : "IMAGES",
|
||||
save: (el) => {}, // Simple rebound to fake its execution
|
||||
imageSave: this.onImageSave.bind(this),
|
||||
videoSave: this.onVideoSave.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
async onImageSave(attachment) {
|
||||
const attachmentRecord = await this.orm.searchRead(
|
||||
"ir.attachment",
|
||||
[["id", "=", attachment[0].id]],
|
||||
["id", "datas", "name"],
|
||||
{}
|
||||
);
|
||||
if (!attachmentRecord[0].datas) {
|
||||
// URL type attachments are mostly demo records which don't have any ir.attachment datas
|
||||
// TODO: make it work with URL type attachments
|
||||
return this.notification.add(
|
||||
`Cannot add URL type attachment "${attachmentRecord[0].name}". Please try to reupload this image.`,
|
||||
{
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
}
|
||||
await this.props.record.update({
|
||||
[this.props.name]: attachmentRecord[0].datas,
|
||||
name: attachmentRecord[0].name,
|
||||
});
|
||||
}
|
||||
|
||||
async onVideoSave(videoInfo) {
|
||||
const url = getVideoUrl(videoInfo[0].platform, videoInfo[0].videoId, videoInfo[0].params);
|
||||
await this.props.record.update({
|
||||
video_url: url.href,
|
||||
name: videoInfo[0].platform + " - [Video]",
|
||||
});
|
||||
}
|
||||
|
||||
onFileRemove() {
|
||||
const parentRecord = this.props.record._parentRecord.data;
|
||||
parentRecord[this.env.parentField].delete(this.props.record);
|
||||
}
|
||||
}
|
||||
|
||||
export const x2ManyImageField = {
|
||||
...imageField,
|
||||
component: X2ManyImageField,
|
||||
};
|
||||
|
||||
registry.category("fields").add("x2_many_image", x2ManyImageField);
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates>
|
||||
<t t-name="html_editor.ImageField" t-inherit="web.ImageField" t-inherit-mode="primary">
|
||||
<xpath expr="//div[hasclass('position-relative')]//div" position="replace">
|
||||
<div t-attf-class="position-absolute d-flex justify-content-between w-100 bottom-0 opacity-0 opacity-100-hover {{isMobile ? 'o_mobile_controls' : ''}}" aria-atomic="true" t-att-style="sizeStyle">
|
||||
<button
|
||||
t-if="props.record.data[props.name] and state.isValid"
|
||||
class="o_select_file_button btn btn-light border-0 rounded-circle m-1 p-1"
|
||||
data-tooltip="Edit"
|
||||
aria-label="Edit"
|
||||
t-on-click.prevent.stop="onFileEdit">
|
||||
<i class="fa fa-pencil fa-fw"/>
|
||||
</button>
|
||||
<button
|
||||
t-if="props.record.data[props.name] and state.isValid"
|
||||
class="o_clear_file_button btn btn-light border-0 rounded-circle m-1 p-1"
|
||||
data-tooltip="Clear"
|
||||
aria-label="Clear"
|
||||
t-on-click.prevent.stop="onFileRemove">
|
||||
<i class="fa fa-trash-o fa-fw"/>
|
||||
</button>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
|
||||
import { getVideoUrl } from "@html_editor/utils/url";
|
||||
import { useChildSubEnv } from "@odoo/owl";
|
||||
import { CustomMediaDialog } from "./custom_media_dialog";
|
||||
|
||||
export class X2ManyMediaViewer extends X2ManyField {
|
||||
static template = "html_editor.X2ManyMediaViewer";
|
||||
static props = {
|
||||
...X2ManyField.props,
|
||||
convertToWebp: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialogs = useService("dialog");
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
this.supportedFields = ["image_1920", "image_1024", "image_512", "image_256", "image_128"];
|
||||
useChildSubEnv({
|
||||
parentField: this.props.name,
|
||||
});
|
||||
}
|
||||
|
||||
addMedia() {
|
||||
this.dialogs.add(CustomMediaDialog, {
|
||||
save: (el) => {}, // Simple rebound to fake its execution
|
||||
multiImages: true,
|
||||
visibleTabs: ["IMAGES", "VIDEOS"],
|
||||
imageSave: this.onImageSave.bind(this),
|
||||
videoSave: this.onVideoSave.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
onVideoSave(videoInfo) {
|
||||
const url = getVideoUrl(videoInfo[0].platform, videoInfo[0].videoId, videoInfo[0].params);
|
||||
const videoList = this.props.record.data[this.props.name];
|
||||
videoList.addNewRecord({ position: "bottom" }).then((record) => {
|
||||
record.update({ name: videoInfo[0].platform + " - [Video]", video_url: url.href });
|
||||
});
|
||||
}
|
||||
|
||||
async onImageSave(attachments) {
|
||||
const attachmentIds = attachments.map((attachment) => attachment.id);
|
||||
const attachmentRecords = await this.orm.searchRead(
|
||||
"ir.attachment",
|
||||
[["id", "in", attachmentIds]],
|
||||
["id", "datas", "name", "mimetype"],
|
||||
{}
|
||||
);
|
||||
for (const attachment of attachmentRecords) {
|
||||
const imageList = this.props.record.data[this.props.name];
|
||||
if (!attachment.datas) {
|
||||
// URL type attachments are mostly demo records which don't have any ir.attachment datas
|
||||
// TODO: make it work with URL type attachments
|
||||
return this.notification.add(
|
||||
`Cannot add URL type attachment "${attachment.name}". Please try to reupload this image.`,
|
||||
{
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.props.convertToWebp &&
|
||||
!["image/gif", "image/svg+xml"].includes(attachment.mimetype)
|
||||
) {
|
||||
// This method is widely adapted from onFileUploaded in ImageField.
|
||||
// Upon change, make sure to verify whether the same change needs
|
||||
// to be applied on both sides.
|
||||
// Generate alternate sizes and format for reports.
|
||||
const image = document.createElement("img");
|
||||
image.src = `data:${attachment.mimetype};base64,${attachment.datas}`;
|
||||
await new Promise((resolve) => image.addEventListener("load", resolve));
|
||||
|
||||
const originalSize = Math.max(image.width, image.height);
|
||||
const smallerSizes = [1024, 512, 256, 128].filter((size) => size < originalSize);
|
||||
let referenceId = undefined;
|
||||
|
||||
for (const size of [originalSize, ...smallerSizes]) {
|
||||
const ratio = size / originalSize;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width * ratio;
|
||||
canvas.height = image.height * ratio;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
image.width,
|
||||
image.height,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
);
|
||||
|
||||
// WebP format
|
||||
const webpData = canvas.toDataURL("image/webp", 0.75).split(",")[1];
|
||||
const [resizedId] = await this.orm.call("ir.attachment", "create_unique", [
|
||||
[
|
||||
{
|
||||
name: attachment.name.replace(/\.[^/.]+$/, ".webp"),
|
||||
description: size === originalSize ? "" : `resize: ${size}`,
|
||||
datas: webpData,
|
||||
res_id: referenceId,
|
||||
res_model: "ir.attachment",
|
||||
mimetype: "image/webp",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
referenceId = referenceId || resizedId;
|
||||
|
||||
// JPEG format for compatibility
|
||||
const jpegData = canvas.toDataURL("image/jpeg", 0.75).split(",")[1];
|
||||
await this.orm.call("ir.attachment", "create_unique", [
|
||||
[
|
||||
{
|
||||
name: attachment.name.replace(/\.[^/.]+$/, ".jpg"),
|
||||
description: `resize: ${size} - format: jpeg`,
|
||||
datas: jpegData,
|
||||
res_id: resizedId,
|
||||
res_model: "ir.attachment",
|
||||
mimetype: "image/jpeg",
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(image, 0, 0, image.width, image.height);
|
||||
|
||||
const webpData = canvas.toDataURL("image/webp", 0.75).split(",")[1];
|
||||
attachment.datas = webpData;
|
||||
attachment.mimetype = "image/webp";
|
||||
attachment.name = attachment.name.replace(/\.[^/.]+$/, ".webp");
|
||||
}
|
||||
|
||||
imageList.addNewRecord({ position: "bottom" }).then((record) => {
|
||||
const activeFields = imageList.activeFields;
|
||||
const updateData = {};
|
||||
for (const field in activeFields) {
|
||||
if (attachment.datas && this.supportedFields.includes(field)) {
|
||||
updateData[field] = attachment.datas;
|
||||
updateData["name"] = attachment.name;
|
||||
}
|
||||
}
|
||||
record.update(updateData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onAdd({ context, editable } = {}) {
|
||||
this.addMedia();
|
||||
}
|
||||
}
|
||||
|
||||
export const x2ManyMediaViewer = {
|
||||
...x2ManyField,
|
||||
component: X2ManyMediaViewer,
|
||||
extractProps: (
|
||||
{ attrs, relatedFields, viewMode, views, widget, options, string },
|
||||
dynamicInfo
|
||||
) => {
|
||||
const x2ManyFieldProps = x2ManyField.extractProps(
|
||||
{ attrs, relatedFields, viewMode, views, widget, options, string },
|
||||
dynamicInfo
|
||||
);
|
||||
return {
|
||||
...x2ManyFieldProps,
|
||||
convertToWebp: options.convert_to_webp,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("fields").add("x2_many_media_viewer", x2ManyMediaViewer);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.X2ManyMediaViewer" t-inherit="web.X2ManyField" t-inherit-mode="primary">
|
||||
<xpath expr="//div[hasclass('o_x2m_control_panel')]" position="before">
|
||||
<KanbanRenderer t-if="props.viewMode" t-props="rendererProps"/>
|
||||
</xpath>
|
||||
<xpath expr="//KanbanRenderer[last()]" position="replace"/>
|
||||
</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,99 @@
|
|||
import { markup } from "@odoo/owl";
|
||||
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 `migrate(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
|
||||
* migrate 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 a change in
|
||||
* master, and minor for a change in stable), 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), "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
|
||||
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 migrate = odoo.loader.modules.get(module).migrate;
|
||||
if (!migrate) {
|
||||
console.error(
|
||||
`A "${key}" migrate function could not be found at "${module}" or it did not load.`
|
||||
);
|
||||
}
|
||||
migrate(this.element, this.env);
|
||||
}
|
||||
}
|
||||
return markup(this.element[this.containsComplexHTML ? "outerHTML" : "innerHTML"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
|
||||
// See `HtmlUpgradeManager` docstring for usage details.
|
||||
const html_upgrade = registry.category("html_editor_upgrade");
|
||||
|
||||
// Introduction of embedded components based on Knowledge Behaviors (Odoo 18).
|
||||
html_upgrade.category("1.0");
|
||||
|
||||
// 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");
|
||||
|
||||
// Knowledge embeddedViews favorite irFilters should have a `user_ids` property.
|
||||
html_upgrade.category("2.0");
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Remove the Excalidraw EmbeddedComponent and replace it with a link
|
||||
*
|
||||
* @param {HTMLElement} container
|
||||
* @param {Object} env
|
||||
*/
|
||||
export function migrate(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.after(newParagraph);
|
||||
excalidrawContainer.remove();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 migrate(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1144 1280">
|
||||
<g transform="translate(0,1280) scale(0.1,-0.1)" fill="#000000" stroke="none">
|
||||
<path d="M7166 11004 c-603 -901 -1094 -1639 -1092 -1641 1 -2 369 42 817 97
|
||||
448 55 825 101 838 103 30 3 26 14 86 -278 131 -634 218 -1317 255 -2010 13
|
||||
-246 13 -898 0 -1135 -71 -1262 -367 -2292 -883 -3065 -162 -242 -308 -418
|
||||
-541 -650 -661 -658 -1465 -1094 -2581 -1400 -1069 -293 -2283 -471 -3660
|
||||
-536 -132 -6 -242 -13 -244 -15 -6 -5 16 -190 30 -255 9 -43 13 -47 47 -53
|
||||
108 -17 1148 -2 1627 24 2760 152 4778 866 6094 2155 506 496 887 1018 1216
|
||||
1670 444 877 715 1860 819 2970 35 365 41 510 41 1000 0 581 -17 880 -76 1355
|
||||
-19 158 -65 452 -74 477 -5 14 -54 7 730 104 363 44 662 83 665 85 2 3 0 8 -6
|
||||
12 -6 4 -682 594 -1503 1312 -822 718 -1497 1306 -1501 1308 -4 2 -501 -734
|
||||
-1104 -1634z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 853 B |
|
|
@ -0,0 +1,32 @@
|
|||
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);
|
||||
// todo: remove this somehow
|
||||
if (!overlayComponents.validationSchema) {
|
||||
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,129 @@
|
|||
import { Plugin } from "@html_editor/plugin";
|
||||
import { closestBlock } from "@html_editor/utils/blocks";
|
||||
import { isVisibleTextNode } from "@html_editor/utils/dom_info";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { AlignSelector } from "./align_selector";
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
import { weakMemoize } from "@html_editor/utils/functions";
|
||||
|
||||
const alignmentItems = [
|
||||
{ mode: "left" },
|
||||
{ mode: "center" },
|
||||
{ mode: "right" },
|
||||
{ mode: "justify" },
|
||||
];
|
||||
|
||||
export class AlignPlugin extends Plugin {
|
||||
static id = "align";
|
||||
static dependencies = ["history", "selection"];
|
||||
resources = {
|
||||
user_commands: [
|
||||
{
|
||||
id: "alignLeft",
|
||||
run: () => this.setAlignment("left"),
|
||||
isAvailable: this.canSetAlignment.bind(this),
|
||||
},
|
||||
{
|
||||
id: "alignCenter",
|
||||
run: () => this.setAlignment("center"),
|
||||
isAvailable: this.canSetAlignment.bind(this),
|
||||
},
|
||||
{
|
||||
id: "alignRight",
|
||||
run: () => this.setAlignment("right"),
|
||||
isAvailable: this.canSetAlignment.bind(this),
|
||||
},
|
||||
{
|
||||
id: "justify",
|
||||
run: () => this.setAlignment("justify"),
|
||||
isAvailable: this.canSetAlignment.bind(this),
|
||||
},
|
||||
],
|
||||
toolbar_items: [
|
||||
{
|
||||
id: "alignment",
|
||||
groupId: "layout",
|
||||
description: _t("Align text"),
|
||||
Component: AlignSelector,
|
||||
props: {
|
||||
getItems: () => alignmentItems,
|
||||
getDisplay: () => this.alignment,
|
||||
onSelected: (item) => {
|
||||
this.setAlignment(item.mode);
|
||||
},
|
||||
},
|
||||
isAvailable: this.canSetAlignment.bind(this),
|
||||
},
|
||||
],
|
||||
|
||||
/** Handlers */
|
||||
selectionchange_handlers: this.updateAlignmentParams.bind(this),
|
||||
post_undo_handlers: this.updateAlignmentParams.bind(this),
|
||||
post_redo_handlers: this.updateAlignmentParams.bind(this),
|
||||
remove_all_formats_handlers: this.setAlignment.bind(this),
|
||||
|
||||
/** Predicates */
|
||||
has_format_predicates: (node) => closestBlock(node)?.style.textAlign,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.alignment = reactive({ displayName: "" });
|
||||
this.canSetAlignmentMemoized = weakMemoize(
|
||||
(selection) => isHtmlContentSupported(selection) && this.getBlocksToAlign().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
get alignmentMode() {
|
||||
const sel = this.dependencies.selection.getSelectionData().deepEditableSelection;
|
||||
const block = closestBlock(sel?.anchorNode);
|
||||
const textAlign = this.getTextAlignment(block);
|
||||
return ["center", "right", "justify"].includes(textAlign) ? textAlign : "left";
|
||||
}
|
||||
|
||||
getTextAlignment(block) {
|
||||
const { direction, textAlign } = getComputedStyle(block);
|
||||
if (textAlign === "start") {
|
||||
return direction === "rtl" ? "right" : "left";
|
||||
} else if (textAlign === "end") {
|
||||
return direction === "rtl" ? "left" : "right";
|
||||
}
|
||||
return textAlign;
|
||||
}
|
||||
|
||||
getBlocksToAlign() {
|
||||
return this.dependencies.selection
|
||||
.getTargetedNodes()
|
||||
.filter((node) => isVisibleTextNode(node) || node.nodeName === "BR")
|
||||
.map((node) => closestBlock(node))
|
||||
.filter((block) => block.isContentEditable);
|
||||
}
|
||||
|
||||
setAlignment(mode = "") {
|
||||
const visitedBlocks = new Set();
|
||||
let isAlignmentUpdated = false;
|
||||
|
||||
for (const block of this.getBlocksToAlign()) {
|
||||
if (!visitedBlocks.has(block)) {
|
||||
const currentTextAlign = this.getTextAlignment(block);
|
||||
if (currentTextAlign !== mode) {
|
||||
block.style.textAlign = mode;
|
||||
isAlignmentUpdated = true;
|
||||
}
|
||||
visitedBlocks.add(block);
|
||||
}
|
||||
}
|
||||
if (mode && isAlignmentUpdated) {
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
this.updateAlignmentParams();
|
||||
}
|
||||
|
||||
canSetAlignment(selection) {
|
||||
return this.canSetAlignmentMemoized(selection);
|
||||
}
|
||||
|
||||
updateAlignmentParams() {
|
||||
this.alignment.displayName = this.alignmentMode;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Component, useState } from "@odoo/owl";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { toolbarButtonProps } from "@html_editor/main/toolbar/toolbar";
|
||||
import { useDropdownAutoVisibility } from "@html_editor/dropdown_autovisibility_hook";
|
||||
import { useChildRef } from "@web/core/utils/hooks";
|
||||
|
||||
export class AlignSelector extends Component {
|
||||
static template = "html_editor.AlignSelector";
|
||||
static props = {
|
||||
getItems: Function,
|
||||
getDisplay: Function,
|
||||
onSelected: Function,
|
||||
...toolbarButtonProps,
|
||||
};
|
||||
static components = { Dropdown, DropdownItem };
|
||||
|
||||
setup() {
|
||||
this.items = this.props.getItems();
|
||||
this.state = useState(this.props.getDisplay());
|
||||
this.menuRef = useChildRef();
|
||||
useDropdownAutoVisibility(this.env.overlayState, this.menuRef);
|
||||
}
|
||||
|
||||
onSelected(item) {
|
||||
this.props.onSelected(item);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
.oe_dropdown_item_menu {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.oe_dropdown_item_menu_selected {
|
||||
background-color: rgba(117, 167, 249, 0.3);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.AlignSelector">
|
||||
<Dropdown menuClass="'o-we-toolbar-dropdown'" bottomSheet="false" menuRef="menuRef">
|
||||
<button class="btn btn-light" t-att-title="props.title" name="text_align">
|
||||
<span class="px-1 d-flex align-items-center">
|
||||
<i t-att-class="`fa fa-align-${state.displayName}`"/>
|
||||
</span>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div data-prevent-closing-overlay="true">
|
||||
<t t-foreach="items" t-as="item" t-key="item_index">
|
||||
<button
|
||||
t-attf-class="btn btn-light fa fa-align-{{item.mode}}"
|
||||
t-att-class="{ active: item.mode === state.displayName }"
|
||||
t-on-click="() => this.onSelected(item)"
|
||||
t-on-pointerdown.prevent="() => {}"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -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,157 @@
|
|||
import { Plugin } from "@html_editor/plugin";
|
||||
import { fillShrunkPhrasingParent, fixNonEditableFirstChild } 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";
|
||||
import { closestBlock } from "@html_editor/utils/blocks";
|
||||
import { isParagraphRelatedElement } from "../utils/dom_info";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
|
||||
function isAvailable(selection) {
|
||||
return (
|
||||
isHtmlContentSupported(selection) &&
|
||||
!closestElement(selection.anchorNode, ".o_editor_banner")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef { Object } BannerShared
|
||||
* @property { BannerPlugin['insertBanner'] } insertBanner
|
||||
*/
|
||||
|
||||
export class BannerPlugin extends Plugin {
|
||||
static id = "banner";
|
||||
// sanitize plugin is required to handle `contenteditable` attribute.
|
||||
static dependencies = ["baseContainer", "history", "dom", "emoji", "selection", "sanitize"];
|
||||
static shared = ["insertBanner"];
|
||||
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"),
|
||||
move_node_blacklist_selectors: ".o_editor_banner *",
|
||||
move_node_whitelist_selectors: ".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, containerClass = "", contentClass = "") {
|
||||
containerClass = containerClass ? `${containerClass} ` : "";
|
||||
contentClass = contentClass ? `${contentClass} ` : "";
|
||||
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
const blockEl = closestBlock(selection.anchorNode);
|
||||
let baseContainer;
|
||||
if (isParagraphRelatedElement(blockEl)) {
|
||||
baseContainer = this.document.createElement(blockEl.nodeName);
|
||||
baseContainer.append(...blockEl.childNodes);
|
||||
} else if (blockEl.nodeName === "LI") {
|
||||
baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
baseContainer.append(...blockEl.childNodes);
|
||||
fillShrunkPhrasingParent(blockEl);
|
||||
} else {
|
||||
baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
fillShrunkPhrasingParent(baseContainer);
|
||||
}
|
||||
const baseContainerHtml = baseContainer.outerHTML;
|
||||
const bannerElement = parseHTML(
|
||||
this.document,
|
||||
`<div class="${containerClass}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="${contentClass}o_editor_banner_content o-contenteditable-true w-100 px-3">
|
||||
${baseContainerHtml}
|
||||
</div>
|
||||
</div>`
|
||||
).childNodes[0];
|
||||
this.dependencies.dom.insert(bannerElement);
|
||||
const baseContainerNodeName = this.dependencies.baseContainer.getDefaultNodeName();
|
||||
const nextNode = this.dependencies.baseContainer.isCandidateForBaseContainer(blockEl)
|
||||
? blockEl.nodeName
|
||||
: baseContainerNodeName;
|
||||
this.dependencies.dom.setBlock({ tagName: nextNode });
|
||||
fixNonEditableFirstChild(this.editable, bannerElement, baseContainerNodeName);
|
||||
this.dependencies.selection.setCursorEnd(
|
||||
bannerElement.querySelector(`.o_editor_banner_content > ${baseContainer.tagName}`)
|
||||
);
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
|
||||
onBannerEmojiChange(iconElement) {
|
||||
this.dependencies.emoji.showEmojiPicker({
|
||||
target: iconElement,
|
||||
onSelect: (emoji) => {
|
||||
iconElement.textContent = emoji;
|
||||
this.dependencies.history.addStep();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component, useState, onWillDestroy, status, markup } from "@odoo/owl";
|
||||
|
||||
const POSTPROCESS_GENERATED_CONTENT = (content, baseContainer) => {
|
||||
let lines = content.split("\n");
|
||||
if (baseContainer.toUpperCase() === "P") {
|
||||
// P has a margin bottom which is used as an interline, no need to
|
||||
// keep empty lines in that case.
|
||||
lines = lines.filter((line) => line.trim().length);
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
let parentUl, parentOl;
|
||||
let lineIndex = 0;
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith("- ")) {
|
||||
// Create or continue an unordered list.
|
||||
parentUl = parentUl || document.createElement("ul");
|
||||
const li = document.createElement("li");
|
||||
li.innerText = line.trim().slice(2);
|
||||
parentUl.appendChild(li);
|
||||
} else if (
|
||||
(parentOl && line.startsWith(`${parentOl.children.length + 1}. `)) ||
|
||||
(!parentOl && line.startsWith("1. ") && lines[lineIndex + 1]?.startsWith("2. "))
|
||||
) {
|
||||
// Create or continue an ordered list (only if the line starts
|
||||
// with the next number in the current ordered list (or 1 if no
|
||||
// ordered list was in progress and it's followed by a 2).
|
||||
parentOl = parentOl || document.createElement("ol");
|
||||
const li = document.createElement("li");
|
||||
li.innerText = line.slice(line.indexOf(".") + 2);
|
||||
parentOl.appendChild(li);
|
||||
} else if (line.trim().length === 0) {
|
||||
const emptyLine = document.createElement("DIV");
|
||||
emptyLine.append(document.createElement("BR"));
|
||||
fragment.appendChild(emptyLine);
|
||||
} else {
|
||||
// Insert any list in progress, and a new block for the current
|
||||
// line.
|
||||
[parentUl, parentOl].forEach((list) => list && fragment.appendChild(list));
|
||||
parentUl = parentOl = undefined;
|
||||
const block = document.createElement(line.startsWith("Title: ") ? "h2" : baseContainer);
|
||||
block.innerText = line;
|
||||
fragment.appendChild(block);
|
||||
}
|
||||
lineIndex += 1;
|
||||
}
|
||||
[parentUl, parentOl].forEach((list) => list && fragment.appendChild(list));
|
||||
return fragment;
|
||||
};
|
||||
|
||||
export class ChatGPTDialog extends Component {
|
||||
static template = "";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
insert: { type: Function },
|
||||
close: { type: Function },
|
||||
sanitize: { type: Function },
|
||||
baseContainer: { type: String, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
baseContainer: "DIV",
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.notificationService = useService("notification");
|
||||
this.state = useState({ selectedMessageId: null });
|
||||
onWillDestroy(() => this.pendingRpcPromise?.abort());
|
||||
}
|
||||
|
||||
selectMessage(ev) {
|
||||
this.state.selectedMessageId = +ev.currentTarget.getAttribute("data-message-id");
|
||||
}
|
||||
|
||||
insertMessage(ev) {
|
||||
this.selectMessage(ev);
|
||||
this._confirm();
|
||||
}
|
||||
|
||||
formatContent(content) {
|
||||
const fragment = POSTPROCESS_GENERATED_CONTENT(content, this.props.baseContainer);
|
||||
let result = "";
|
||||
for (const child of fragment.children) {
|
||||
this.props.sanitize(child, { IN_PLACE: true });
|
||||
result += child.outerHTML;
|
||||
}
|
||||
return markup(result);
|
||||
}
|
||||
|
||||
generate(prompt, callback) {
|
||||
const protectedCallback = (...args) => {
|
||||
if (status(this) !== "destroyed") {
|
||||
delete this.pendingRpcPromise;
|
||||
return callback(...args);
|
||||
}
|
||||
};
|
||||
this.pendingRpcPromise = rpc(
|
||||
"/html_editor/generate_text",
|
||||
{
|
||||
prompt,
|
||||
conversation_history: this.state.conversationHistory,
|
||||
},
|
||||
{ silent: true }
|
||||
);
|
||||
return this.pendingRpcPromise
|
||||
.then((content) => protectedCallback(content))
|
||||
.catch((error) => protectedCallback(_t(error.data?.message || error.message), true));
|
||||
}
|
||||
|
||||
_cancel() {
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
_confirm() {
|
||||
try {
|
||||
this.props.close();
|
||||
const text = this.state.messages.find(
|
||||
(message) => message.id === this.state.selectedMessageId
|
||||
)?.text;
|
||||
this.notificationService.add(_t("Your content was successfully generated."), {
|
||||
title: _t("Content generated"),
|
||||
type: "success",
|
||||
});
|
||||
const fragment = POSTPROCESS_GENERATED_CONTENT(text || "", this.props.baseContainer);
|
||||
this.props.sanitize(fragment, { IN_PLACE: true });
|
||||
this.props.insert(fragment);
|
||||
} catch (e) {
|
||||
this.props.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
|
||||
@keyframes fade {
|
||||
0%,100% { opacity: 0 }
|
||||
30%,70% { opacity: 1 }
|
||||
}
|
||||
.o-chatgpt-content {
|
||||
position: absolute;
|
||||
background: rgba(1, 186, 210, 0.5);
|
||||
opacity: 0;
|
||||
animation: fade 1.5s ease-in-out;
|
||||
z-index: 1;
|
||||
outline: 2px dashed #01bad2;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.o-chatgpt-translated > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.o-message-error {
|
||||
color: #d44c59;
|
||||
font-weight: bold;
|
||||
--bg-opacity: 0.25;
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { useState } from "@odoo/owl";
|
||||
import { ChatGPTDialog } from "./chatgpt_dialog";
|
||||
|
||||
export class ChatGPTTranslateDialog extends ChatGPTDialog {
|
||||
static template = "html_editor.ChatGPTTranslateDialog";
|
||||
static props = {
|
||||
...super.props,
|
||||
originalText: String,
|
||||
language: String,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.state = useState({
|
||||
...this.state,
|
||||
conversationHistory: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are a translation assistant. You goal is to translate text while maintaining the original format and" +
|
||||
"respecting specific instructions. \n" +
|
||||
"Instructions: \n" +
|
||||
"- You must respect the format (wrapping the translated text between <generated_text> and </generated_text>)\n" +
|
||||
"- Do not write HTML.",
|
||||
},
|
||||
],
|
||||
messages: [],
|
||||
translationInProgress: true,
|
||||
});
|
||||
this.translate();
|
||||
}
|
||||
|
||||
async translate() {
|
||||
const query = `Translate <generated_text>${this.props.originalText}</generated_text> to ${this.props.language}`;
|
||||
const messageId = new Date().getTime();
|
||||
await this.generate(query, (content, isError) => {
|
||||
let translatedText = content
|
||||
.replace(/^[\s\S]*<generated_text>/, "")
|
||||
.replace(/<\/generated_text>[\s\S]*$/, "");
|
||||
if (!this.formatContent(translatedText).length) {
|
||||
isError = true;
|
||||
translatedText = "You didn't select any text.";
|
||||
}
|
||||
this.state.translationInProgress = false;
|
||||
if (!isError) {
|
||||
// There was no error, add the response to the history.
|
||||
this.state.conversationHistory.push(
|
||||
{
|
||||
role: "user",
|
||||
content: query,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content,
|
||||
}
|
||||
);
|
||||
}
|
||||
this.state.messages.push({
|
||||
author: "assistant",
|
||||
text: translatedText,
|
||||
id: messageId,
|
||||
isError,
|
||||
});
|
||||
this.state.selectedMessageId = messageId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="html_editor.ChatGPTTranslateDialog">
|
||||
<Dialog size="'lg'" title.translate="Translate with AI">
|
||||
<div t-if="state.translationInProgress" class="d-flex">
|
||||
<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>Translating...</em>
|
||||
</p>
|
||||
</div>
|
||||
<t t-else="">
|
||||
<t t-set="message" t-value="state.messages[0]" />
|
||||
<div t-att-data-message-id="message.id"
|
||||
t-att-class="message.isError ? 'o-message-error border-danger bg-danger p-2' : ''"
|
||||
class="o-chatgpt-translated">
|
||||
<t t-out="formatContent(message.text)" />
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary" t-on-click="_confirm"
|
||||
t-att-disabled="state.translationInProgress || state.messages[0].isError">Insert</button>
|
||||
<button class="btn btn-secondary" t-on-click="_cancel">Cancel</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Plugin } from "@html_editor/plugin";
|
||||
import { closestElement } from "@html_editor/utils/dom_traversal";
|
||||
import { ChatGPTTranslateDialog } from "@html_editor/main/chatgpt/chatgpt_translate_dialog";
|
||||
import { LanguageSelector } from "@html_editor/main/chatgpt/language_selector";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
import { user } from "@web/core/user";
|
||||
import { isContentEditable } from "@html_editor/utils/dom_info";
|
||||
|
||||
export class ChatGPTTranslatePlugin extends Plugin {
|
||||
static id = "chatgpt_translate";
|
||||
static dependencies = [
|
||||
"baseContainer",
|
||||
"selection",
|
||||
"history",
|
||||
"dom",
|
||||
"sanitize",
|
||||
"dialog",
|
||||
"split",
|
||||
];
|
||||
resources = {
|
||||
toolbar_groups: withSequence(50, {
|
||||
id: "ai",
|
||||
}),
|
||||
toolbar_items: [
|
||||
{
|
||||
id: "translate",
|
||||
groupId: "ai",
|
||||
description: _t("Translate with AI"),
|
||||
isAvailable: (selection) => !selection.isCollapsed && user.userId,
|
||||
isDisabled: this.isNotReplaceableByAI.bind(this),
|
||||
Component: LanguageSelector,
|
||||
props: {
|
||||
onSelected: (language) => this.openDialog({ language }),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
isNotReplaceableByAI(selection = this.dependencies.selection.getEditableSelection()) {
|
||||
const isEmpty = !selection.textContent().replace(/\s+/g, "");
|
||||
const cannotReplace = this.dependencies.selection
|
||||
.getTargetedNodes()
|
||||
.find((el) => this.dependencies.split.isUnsplittable(el) || !isContentEditable(el));
|
||||
return cannotReplace || isEmpty;
|
||||
}
|
||||
|
||||
openDialog(params = {}) {
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
const dialogParams = {
|
||||
insert: (content) => {
|
||||
const insertedNodes = this.dependencies.dom.insert(content);
|
||||
this.dependencies.history.addStep();
|
||||
// Add a frame around the inserted content to highlight it for 2
|
||||
// seconds.
|
||||
const start = insertedNodes?.length && closestElement(insertedNodes[0]);
|
||||
const end =
|
||||
insertedNodes?.length &&
|
||||
closestElement(insertedNodes[insertedNodes.length - 1]);
|
||||
if (start && end) {
|
||||
const divContainer = this.editable.parentElement;
|
||||
let [parent, left, top] = [
|
||||
start.offsetParent,
|
||||
start.offsetLeft,
|
||||
start.offsetTop - start.scrollTop,
|
||||
];
|
||||
while (parent && !parent.contains(divContainer)) {
|
||||
left += parent.offsetLeft;
|
||||
top += parent.offsetTop - parent.scrollTop;
|
||||
parent = parent.offsetParent;
|
||||
}
|
||||
let [endParent, endTop] = [end.offsetParent, end.offsetTop - end.scrollTop];
|
||||
while (endParent && !endParent.contains(divContainer)) {
|
||||
endTop += endParent.offsetTop - endParent.scrollTop;
|
||||
endParent = endParent.offsetParent;
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("o-chatgpt-content");
|
||||
const FRAME_PADDING = 3;
|
||||
div.style.left = `${left - FRAME_PADDING}px`;
|
||||
div.style.top = `${top - FRAME_PADDING}px`;
|
||||
div.style.width = `${
|
||||
Math.max(start.offsetWidth, end.offsetWidth) + FRAME_PADDING * 2
|
||||
}px`;
|
||||
div.style.height = `${endTop + end.offsetHeight - top + FRAME_PADDING * 2}px`;
|
||||
divContainer.prepend(div);
|
||||
setTimeout(() => div.remove(), 2000);
|
||||
}
|
||||
},
|
||||
...params,
|
||||
};
|
||||
dialogParams.baseContainer = this.dependencies.baseContainer.getDefaultNodeName();
|
||||
// collapse to end
|
||||
const sanitize = this.dependencies.sanitize.sanitize;
|
||||
const originalText = selection.textContent() || "";
|
||||
this.dependencies.dialog.addDialog(ChatGPTTranslateDialog, {
|
||||
...dialogParams,
|
||||
originalText,
|
||||
sanitize,
|
||||
});
|
||||
if (this.services.ui.isSmall) {
|
||||
// TODO: Find a better way and avoid modifying range
|
||||
// HACK: In the case of opening through dropdown:
|
||||
// - when dropdown open, it keep the element focused before the open
|
||||
// - when opening the dialog through the dropdown, the dropdown closes
|
||||
// - upon close, the generic code of the dropdown sets focus on the kept element (in our case, the editable)
|
||||
// - we need to remove the range after the generic code of the dropdown is triggered so we hack it by removing the range in the next tick
|
||||
Promise.resolve().then(() => {
|
||||
// If the dialog is opened on a small screen, remove all selection
|
||||
// because the selection can be seen through the dialog on some devices.
|
||||
this.document.getSelection()?.removeAllRanges();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.oe-language-icon {
|
||||
fill: #fff;
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { useChildRef, useService } from "@web/core/utils/hooks";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { loadLanguages } from "@web/core/l10n/translation";
|
||||
import { jsToPyLocale } from "@web/core/l10n/utils";
|
||||
import { toolbarButtonProps } from "@html_editor/main/toolbar/toolbar";
|
||||
import { user } from "@web/core/user";
|
||||
import { useDropdownAutoVisibility } from "@html_editor/dropdown_autovisibility_hook";
|
||||
|
||||
export class LanguageSelector extends Component {
|
||||
static template = "html_editor.LanguageSelector";
|
||||
static props = {
|
||||
...toolbarButtonProps,
|
||||
onSelected: { type: Function },
|
||||
};
|
||||
static components = { Dropdown, DropdownItem };
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.state = useState({
|
||||
languages: [],
|
||||
});
|
||||
this.menuRef = useChildRef();
|
||||
useDropdownAutoVisibility(this.env.overlayState, this.menuRef);
|
||||
onWillStart(() => {
|
||||
if (user.userId) {
|
||||
const userLang = jsToPyLocale(user.lang);
|
||||
loadLanguages(this.orm).then((res) => {
|
||||
const userLangIndex = res.findIndex((lang) => lang[0] === userLang);
|
||||
if (userLangIndex !== -1) {
|
||||
const [userLangItem] = res.splice(userLangIndex, 1);
|
||||
res.unshift(userLangItem);
|
||||
}
|
||||
this.state.languages = res;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
onSelected(language) {
|
||||
this.props.onSelected(language);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.oe-language-icon {
|
||||
fill: #000;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.LanguageSelector">
|
||||
<t t-if="state.languages.length === 1">
|
||||
<t t-call="html_editor.translateButton">
|
||||
<t t-set="onClick" t-value="() => this.onSelected(state.languages[0][1])"/>
|
||||
</t>
|
||||
</t>
|
||||
<Dropdown t-else="" menuRef="menuRef">
|
||||
<t t-call="html_editor.translateButton">
|
||||
<t t-set="onClick" t-value="() => {}"/>
|
||||
</t>
|
||||
<t t-set-slot="content">
|
||||
<div data-prevent-closing-overlay="true">
|
||||
<t t-foreach="state.languages" t-as="language" t-key="language[0]">
|
||||
<DropdownItem class="'user-select-none'" onSelected="() => this.onSelected(language[1])">
|
||||
<div class="lang" t-esc="language[1]"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
|
||||
<t t-name="html_editor.translateButton">
|
||||
<button class="btn btn-light" name="translate" t-att-title="props.title"
|
||||
t-att-disabled="props.isDisabled" t-on-click="onClick">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg class="oe-language-icon" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="796 796 200 200" enable-background="new 796 796 200 200" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M973.166,818.5H818.833c-12.591,0-22.833,10.243-22.833,22.833v109.333c0,12.59,10.243,22.833,22.833,22.833h154.333
|
||||
c12.59,0,22.834-10.243,22.834-22.833V841.333C996,828.743,985.756,818.5,973.166,818.5z M896,961.5h-77.167
|
||||
c-5.973,0-10.833-4.859-10.833-10.833V841.333c0-5.974,4.86-10.833,10.833-10.833H896V961.5z M978.58,872.129
|
||||
c-0.547,9.145-5.668,27.261-20.869,39.845c4.615,1.022,9.629,1.573,14.92,1.573v12c-10.551,0-20.238-1.919-28.469-5.325
|
||||
c-7.689,3.301-16.969,5.325-28.125,5.325v-12c5.132,0,9.924-0.501,14.366-1.498c-8.412-7.016-13.382-16.311-13.382-26.78h11.999
|
||||
c0,8.857,5.66,16.517,14.884,21.623c4.641-2.66,8.702-6.112,12.164-10.351c5.628-6.886,8.502-14.521,9.754-20.042h-49.785v-12
|
||||
h22.297v-11.986h12V864.5h21.055c1.986,0,3.902,0.831,5.258,2.28C977.986,868.199,978.697,870.155,978.58,872.129z"/>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M839.035,914.262l-4.45,11.258h-15.971l26.355-61.09h15.971l25.746,61.09h-16.583l-4.363-11.258H839.035z
|
||||
M852.475,879.876l-8.902,22.604h17.629L852.475,879.876z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Plugin } from "@html_editor/plugin";
|
||||
import { closestBlock } from "@html_editor/utils/blocks";
|
||||
import { unwrapContents } from "@html_editor/utils/dom";
|
||||
import { closestElement, firstLeaf } from "@html_editor/utils/dom_traversal";
|
||||
import { baseContainerGlobalSelector } from "@html_editor/utils/base_container";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
|
||||
const REGEX_BOOTSTRAP_COLUMN = /(?:^| )col(-[a-zA-Z]+)?(-\d+)?(?= |$)/;
|
||||
|
||||
function isUnremovableColumn(node, root) {
|
||||
const isColumnInnerStructure =
|
||||
node.nodeName === "DIV" && [...node.classList].some((cls) => /^row$|^col$|^col-/.test(cls));
|
||||
|
||||
if (!isColumnInnerStructure) {
|
||||
return false;
|
||||
}
|
||||
if (!root) {
|
||||
return true;
|
||||
}
|
||||
const closestColumnContainer = closestElement(node, "div.o_text_columns");
|
||||
return !root.contains(closestColumnContainer);
|
||||
}
|
||||
|
||||
function columnIsAvailable(numberOfColumns) {
|
||||
return (selection) => {
|
||||
const row = closestElement(selection.anchorNode, ".o_text_columns .row");
|
||||
return !(row && row.childElementCount === numberOfColumns);
|
||||
};
|
||||
}
|
||||
|
||||
export class ColumnPlugin extends Plugin {
|
||||
static id = "column";
|
||||
static dependencies = ["baseContainer", "selection", "history", "dom"];
|
||||
resources = {
|
||||
user_commands: [
|
||||
{
|
||||
id: "columnize",
|
||||
title: _t("Columnize"),
|
||||
description: _t("Convert into columns"),
|
||||
icon: "fa-columns",
|
||||
run: this.columnize.bind(this),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
],
|
||||
powerbox_items: [
|
||||
{
|
||||
title: _t("2 columns"),
|
||||
description: _t("Convert into 2 columns"),
|
||||
categoryId: "structure",
|
||||
isAvailable: columnIsAvailable(2),
|
||||
commandId: "columnize",
|
||||
commandParams: { numberOfColumns: 2 },
|
||||
},
|
||||
{
|
||||
title: _t("3 columns"),
|
||||
description: _t("Convert into 3 columns"),
|
||||
categoryId: "structure",
|
||||
isAvailable: columnIsAvailable(3),
|
||||
commandId: "columnize",
|
||||
commandParams: { numberOfColumns: 3 },
|
||||
},
|
||||
{
|
||||
title: _t("4 columns"),
|
||||
description: _t("Convert into 4 columns"),
|
||||
categoryId: "structure",
|
||||
isAvailable: columnIsAvailable(4),
|
||||
commandId: "columnize",
|
||||
commandParams: { numberOfColumns: 4 },
|
||||
},
|
||||
{
|
||||
title: _t("Remove columns"),
|
||||
description: _t("Back to one column"),
|
||||
categoryId: "structure",
|
||||
isAvailable: (selection) =>
|
||||
!!closestElement(selection.anchorNode, ".o_text_columns .row"),
|
||||
commandId: "columnize",
|
||||
commandParams: { numberOfColumns: 0 },
|
||||
},
|
||||
],
|
||||
hints: [
|
||||
{
|
||||
selector: `.odoo-editor-editable .o_text_columns div[class^='col-'],
|
||||
.odoo-editor-editable .o_text_columns div[class^='col-']>${baseContainerGlobalSelector}:first-child`,
|
||||
text: _t("Empty column"),
|
||||
},
|
||||
],
|
||||
unremovable_node_predicates: isUnremovableColumn,
|
||||
power_buttons_visibility_predicates: ({ anchorNode }) =>
|
||||
!closestElement(anchorNode, ".o_text_columns"),
|
||||
move_node_whitelist_selectors: ".o_text_columns",
|
||||
move_node_blacklist_selectors: ".o_text_columns *",
|
||||
hint_targets_providers: (selectionData) => {
|
||||
if (!selectionData.documentSelection) {
|
||||
return [];
|
||||
}
|
||||
const anchorNode = selectionData.documentSelection.anchorNode;
|
||||
const columnContainer = closestElement(anchorNode, "div.o_text_columns");
|
||||
if (!columnContainer) {
|
||||
return [];
|
||||
}
|
||||
const closestColumn = closestElement(anchorNode, "div[class^='col-']");
|
||||
const closestBlockEl = closestBlock(anchorNode);
|
||||
return [...columnContainer.querySelectorAll("div[class^='col-']")]
|
||||
.map((column) => {
|
||||
const block = closestBlock(firstLeaf(column));
|
||||
return column === closestColumn && block !== closestBlockEl ? null : block;
|
||||
})
|
||||
.filter(Boolean);
|
||||
},
|
||||
};
|
||||
|
||||
columnize({ numberOfColumns, addParagraphAfter = true } = {}) {
|
||||
const selectionToRestore = this.dependencies.selection.getEditableSelection();
|
||||
const anchor = selectionToRestore.anchorNode;
|
||||
const hasColumns = !!closestElement(anchor, ".o_text_columns");
|
||||
if (hasColumns) {
|
||||
if (numberOfColumns) {
|
||||
this.changeColumnsNumber(anchor, numberOfColumns);
|
||||
} else {
|
||||
this.removeColumns(anchor);
|
||||
}
|
||||
} else if (numberOfColumns) {
|
||||
this.createColumns(anchor, numberOfColumns, addParagraphAfter);
|
||||
}
|
||||
this.dependencies.selection.setSelection(selectionToRestore);
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
|
||||
removeColumns(anchor) {
|
||||
const container = closestElement(anchor, ".o_text_columns");
|
||||
const rows = unwrapContents(container);
|
||||
for (const row of rows) {
|
||||
const columns = unwrapContents(row);
|
||||
for (const column of columns) {
|
||||
unwrapContents(column);
|
||||
// const columnContents = unwrapContents(column);
|
||||
// for (const node of columnContents) {
|
||||
// resetOuids(node);
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createColumns(anchor, numberOfColumns, addParagraphAfter) {
|
||||
const container = this.document.createElement("div");
|
||||
if (!closestElement(anchor, ".container")) {
|
||||
container.classList.add("container");
|
||||
}
|
||||
container.classList.add("o_text_columns", "o-contenteditable-false");
|
||||
const row = this.document.createElement("div");
|
||||
row.classList.add("row");
|
||||
container.append(row);
|
||||
const block = closestBlock(anchor);
|
||||
// resetOuids(block);
|
||||
const columnSize = Math.floor(12 / numberOfColumns);
|
||||
const columns = [];
|
||||
for (let i = 0; i < numberOfColumns; i++) {
|
||||
const column = this.document.createElement("div");
|
||||
column.classList.add(`col-${columnSize}`, "o-contenteditable-true");
|
||||
row.append(column);
|
||||
columns.push(column);
|
||||
}
|
||||
if (addParagraphAfter) {
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
baseContainer.append(this.document.createElement("br"));
|
||||
block.after(baseContainer);
|
||||
}
|
||||
columns.shift().append(block);
|
||||
for (const column of columns) {
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
baseContainer.append(this.document.createElement("br"));
|
||||
column.append(baseContainer);
|
||||
}
|
||||
this.dependencies.dom.insert(container);
|
||||
}
|
||||
|
||||
changeColumnsNumber(anchor, numberOfColumns) {
|
||||
const row = closestElement(anchor, ".row");
|
||||
const columns = [...row.children];
|
||||
const columnSize = Math.floor(12 / numberOfColumns);
|
||||
const diff = numberOfColumns - columns.length;
|
||||
if (!diff) {
|
||||
return;
|
||||
}
|
||||
for (const column of columns) {
|
||||
column.className = column.className.replace(
|
||||
REGEX_BOOTSTRAP_COLUMN,
|
||||
`col$1-${columnSize}`
|
||||
);
|
||||
}
|
||||
if (diff > 0) {
|
||||
// Add extra columns.
|
||||
let lastColumn = columns[columns.length - 1];
|
||||
for (let i = 0; i < diff; i++) {
|
||||
const column = this.document.createElement("div");
|
||||
column.classList.add(`col-${columnSize}`, "o-contenteditable-true");
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
baseContainer.append(this.document.createElement("br"));
|
||||
column.append(baseContainer);
|
||||
lastColumn.after(column);
|
||||
lastColumn = column;
|
||||
}
|
||||
} else if (diff < 0) {
|
||||
// Remove superfluous columns.
|
||||
const contents = [];
|
||||
for (let i = diff; i < 0; i++) {
|
||||
const column = columns.pop();
|
||||
const columnContents = unwrapContents(column);
|
||||
// for (const node of columnContents) {
|
||||
// resetOuids(node);
|
||||
// }
|
||||
contents.unshift(...columnContents);
|
||||
}
|
||||
columns[columns.length - 1].append(...contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { Plugin } from "@html_editor/plugin";
|
||||
import { EmojiPicker } from "@web/core/emoji_picker/emoji_picker";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
/**
|
||||
* @typedef { Object } EmojiShared
|
||||
* @property { EmojiPlugin['showEmojiPicker'] } showEmojiPicker
|
||||
*/
|
||||
|
||||
export class EmojiPlugin extends Plugin {
|
||||
static id = "emoji";
|
||||
static dependencies = ["history", "overlay", "dom", "selection"];
|
||||
static shared = ["showEmojiPicker"];
|
||||
resources = {
|
||||
user_commands: [
|
||||
{
|
||||
id: "addEmoji",
|
||||
title: _t("Emoji"),
|
||||
description: _t("Add an emoji"),
|
||||
icon: "fa-smile-o",
|
||||
run: this.showEmojiPicker.bind(this),
|
||||
},
|
||||
],
|
||||
powerbox_items: [
|
||||
{
|
||||
categoryId: "widget",
|
||||
commandId: "addEmoji",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.overlay = this.dependencies.overlay.createOverlay(EmojiPicker, {
|
||||
hasAutofocus: true,
|
||||
className: "popover",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {HTMLElement} options.target - The target element to position the overlay.
|
||||
* @param {Function} [options.onSelect] - The callback function to handle the selection of an emoji.
|
||||
* If not provided, the emoji will be inserted into the editor and a step will be trigerred.
|
||||
*/
|
||||
showEmojiPicker({ target, onSelect } = {}) {
|
||||
this.overlay.open({
|
||||
props: {
|
||||
close: () => {
|
||||
this.overlay.close();
|
||||
this.dependencies.selection.focusEditable();
|
||||
},
|
||||
onSelect: (str) => {
|
||||
if (onSelect) {
|
||||
onSelect(str);
|
||||
return;
|
||||
}
|
||||
this.dependencies.dom.insert(str);
|
||||
this.dependencies.history.addStep();
|
||||
},
|
||||
},
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { Plugin } from "@html_editor/plugin";
|
||||
import { cleanTextNode } from "@html_editor/utils/dom";
|
||||
import { isTextNode, isZwnbsp } from "@html_editor/utils/dom_info";
|
||||
import { prepareUpdate } from "@html_editor/utils/dom_state";
|
||||
import { descendants, selectElements } from "@html_editor/utils/dom_traversal";
|
||||
import { leftPos, rightPos } from "@html_editor/utils/position";
|
||||
import { callbacksForCursorUpdate } from "@html_editor/utils/selection";
|
||||
|
||||
/** @typedef {import("../core/selection_plugin").Cursors} Cursors */
|
||||
|
||||
/**
|
||||
* @typedef { Object } FeffShared
|
||||
* @property { FeffPlugin['addFeff'] } addFeff
|
||||
* @property { FeffPlugin['removeFeffs'] } removeFeffs
|
||||
*/
|
||||
|
||||
/**
|
||||
* This plugin manages the insertion and removal of the zero-width no-break
|
||||
* space character (U+FEFF). These characters enable the user to place the
|
||||
* cursor in positions that would otherwise not be easy or possible, such as
|
||||
* between two contenteditable=false elements, or at the end (but inside) of a
|
||||
* link.
|
||||
*/
|
||||
export class FeffPlugin extends Plugin {
|
||||
static id = "feff";
|
||||
static dependencies = ["selection"];
|
||||
static shared = ["addFeff", "removeFeffs"];
|
||||
|
||||
resources = {
|
||||
normalize_handlers: this.updateFeffs.bind(this),
|
||||
clean_for_save_handlers: this.cleanForSave.bind(this),
|
||||
intangible_char_for_keyboard_navigation_predicates: (ev, char, lastSkipped) =>
|
||||
// Skip first FEFF, but not the second one (unless shift is pressed).
|
||||
char === "\uFEFF" && (ev.shiftKey || lastSkipped !== "\uFEFF"),
|
||||
clipboard_content_processors: this.processContentForClipboard.bind(this),
|
||||
clipboard_text_processors: (text) => text.replace(/\ufeff/g, ""),
|
||||
};
|
||||
|
||||
cleanForSave({ root, preserveSelection = false }) {
|
||||
if (preserveSelection) {
|
||||
const cursors = this.getCursors();
|
||||
this.removeFeffs(root, cursors);
|
||||
cursors.restore();
|
||||
} else {
|
||||
this.removeFeffs(root, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} root
|
||||
* @param {Cursors} [cursors]
|
||||
* @param {Object} [options]
|
||||
*/
|
||||
removeFeffs(root, cursors, { exclude = () => false } = {}) {
|
||||
const hasFeff = (node) => isTextNode(node) && node.textContent.includes("\ufeff");
|
||||
const isEditable = (node) => node.parentElement.isContentEditable;
|
||||
const composedFilter = (node) => hasFeff(node) && isEditable(node) && !exclude(node);
|
||||
|
||||
for (const node of descendants(root).filter(composedFilter)) {
|
||||
// Remove all FEFF within a `prepareUpdate` to make sure to make <br>
|
||||
// nodes visible if needed.
|
||||
const restoreSpaces = prepareUpdate(...leftPos(node), ...rightPos(node));
|
||||
cleanTextNode(node, "\ufeff", cursors);
|
||||
restoreSpaces();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @param {'before'|'after'|'prepend'|'append'} position
|
||||
* @param {Cursors} [cursors]
|
||||
* @returns {Node}
|
||||
*/
|
||||
addFeff(element, position, cursors) {
|
||||
const feff = this.document.createTextNode("\ufeff");
|
||||
cursors?.update(callbacksForCursorUpdate[position](element, feff));
|
||||
element[position](feff);
|
||||
return feff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a FEFF before and after each element that matches the selectors
|
||||
* provided by the registered providers.
|
||||
*
|
||||
* @param {Element} root
|
||||
* @param {Cursors} cursors
|
||||
* @returns {Node[]}
|
||||
*/
|
||||
padWithFeffs(root, cursors) {
|
||||
const combinedSelector = this.getResource("selectors_for_feff_providers")
|
||||
.map((provider) => provider())
|
||||
.join(", ");
|
||||
if (!combinedSelector) {
|
||||
return [];
|
||||
}
|
||||
const elements = [...selectElements(root, combinedSelector)];
|
||||
const isEditable = (node) => node.parentElement?.isContentEditable;
|
||||
const feffNodes = elements
|
||||
.filter(isEditable)
|
||||
.flatMap((el) => {
|
||||
const addFeff = (position) => this.addFeff(el, position, cursors);
|
||||
return [
|
||||
isZwnbsp(el.previousSibling) ? el.previousSibling : addFeff("before"),
|
||||
isZwnbsp(el.nextSibling) ? el.nextSibling : addFeff("after"),
|
||||
];
|
||||
})
|
||||
// Avoid sequential FEFFs
|
||||
.filter((feff, i, array) => !(i > 0 && areCloseSiblings(array[i - 1], feff)));
|
||||
return feffNodes;
|
||||
}
|
||||
|
||||
updateFeffs(root) {
|
||||
const cursors = this.getCursors();
|
||||
// Pad based on selectors
|
||||
const feffNodesBasedOnSelectors = this.padWithFeffs(root, cursors);
|
||||
// Custom feff adding
|
||||
// Each provider is responsible for adding (or keeping) FEFF nodes and
|
||||
// returning a list of them.
|
||||
const customFeffNodes = this.getResource("feff_providers").flatMap((p) => p(root, cursors));
|
||||
const feffNodesToKeep = new Set([...feffNodesBasedOnSelectors, ...customFeffNodes]);
|
||||
this.removeFeffs(root, cursors, {
|
||||
exclude: (node) =>
|
||||
feffNodesToKeep.has(node) ||
|
||||
this.getResource("legit_feff_predicates").some((predicate) => predicate(node)),
|
||||
});
|
||||
cursors.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retuns a patched version of cursors in which `restore` does nothing
|
||||
* unless `update` has been called at least once.
|
||||
*/
|
||||
getCursors() {
|
||||
const cursors = this.dependencies.selection.preserveSelection();
|
||||
const originalUpdate = cursors.update.bind(cursors);
|
||||
const originalRestore = cursors.restore.bind(cursors);
|
||||
let shouldRestore = false;
|
||||
cursors.update = (...args) => {
|
||||
shouldRestore = true;
|
||||
return originalUpdate(...args);
|
||||
};
|
||||
cursors.restore = () => {
|
||||
if (shouldRestore) {
|
||||
originalRestore();
|
||||
}
|
||||
};
|
||||
return cursors;
|
||||
}
|
||||
|
||||
processContentForClipboard(clonedContent) {
|
||||
descendants(clonedContent)
|
||||
.filter(isTextNode)
|
||||
.filter((node) => node.textContent.includes("\ufeff"))
|
||||
.forEach((node) => (node.textContent = node.textContent.replace(/\ufeff/g, "")));
|
||||
return clonedContent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two nodes are consecutive siblings, ignoring empty text nodes between
|
||||
* them.
|
||||
*
|
||||
* @param {Node} a
|
||||
* @param {Node} b
|
||||
*/
|
||||
function areCloseSiblings(a, b) {
|
||||
let next = a.nextSibling;
|
||||
// skip empty text nodes
|
||||
while (next && isTextNode(next) && !next.textContent) {
|
||||
next = next.nextSibling;
|
||||
}
|
||||
return next === b;
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { Component, useState } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { applyOpacityToGradient, isColorGradient } from "@web/core/utils/colors";
|
||||
import { GradientPicker } from "./gradient_picker/gradient_picker";
|
||||
|
||||
const DEFAULT_GRADIENT_COLORS = [
|
||||
"linear-gradient(135deg, rgb(255, 204, 51) 0%, rgb(226, 51, 255) 100%)",
|
||||
"linear-gradient(135deg, rgb(102, 153, 255) 0%, rgb(255, 51, 102) 100%)",
|
||||
"linear-gradient(135deg, rgb(47, 128, 237) 0%, rgb(178, 255, 218) 100%)",
|
||||
"linear-gradient(135deg, rgb(203, 94, 238) 0%, rgb(75, 225, 236) 100%)",
|
||||
"linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%)",
|
||||
"linear-gradient(135deg, rgb(255, 222, 69) 0%, rgb(69, 33, 0) 100%)",
|
||||
"linear-gradient(135deg, rgb(222, 222, 222) 0%, rgb(69, 69, 69) 100%)",
|
||||
"linear-gradient(135deg, rgb(255, 222, 202) 0%, rgb(202, 115, 69) 100%)",
|
||||
];
|
||||
|
||||
class ColorPickerGradientTab extends Component {
|
||||
static template = "html_editor.ColorPickerGradientTab";
|
||||
static components = { GradientPicker };
|
||||
static props = {
|
||||
applyColor: Function,
|
||||
onColorClick: Function,
|
||||
onColorPreview: Function,
|
||||
onColorPointerOver: Function,
|
||||
onColorPointerOut: Function,
|
||||
onFocusin: Function,
|
||||
onFocusout: Function,
|
||||
setOnCloseCallback: { type: Function, optional: true },
|
||||
setOperationCallbacks: { type: Function, optional: true },
|
||||
defaultOpacity: { type: Number, optional: true },
|
||||
noTransparency: { type: Boolean, optional: true },
|
||||
selectedColor: { type: String, optional: true },
|
||||
"*": { optional: true },
|
||||
};
|
||||
setup() {
|
||||
this.state = useState({
|
||||
showGradientPicker: false,
|
||||
});
|
||||
this.applyOpacityToGradient = applyOpacityToGradient;
|
||||
this.DEFAULT_GRADIENT_COLORS = DEFAULT_GRADIENT_COLORS;
|
||||
}
|
||||
|
||||
getCurrentGradientColor() {
|
||||
if (isColorGradient(this.props.selectedColor)) {
|
||||
return this.props.selectedColor;
|
||||
}
|
||||
}
|
||||
|
||||
toggleGradientPicker() {
|
||||
this.state.showGradientPicker = !this.state.showGradientPicker;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("color_picker_tabs").add(
|
||||
"html_editor.gradient",
|
||||
{
|
||||
id: "gradient",
|
||||
name: _t("Gradient"),
|
||||
component: ColorPickerGradientTab,
|
||||
},
|
||||
{ sequence: 60 }
|
||||
);
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
.o_gradient_color_button {
|
||||
border-width: 0px;
|
||||
background: unset;
|
||||
|
||||
&:hover, &:focus {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.o_color_button.o_gradient_color_button {
|
||||
&:focus,
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_custom_gradient_button[style*="background-image"] {
|
||||
background: unset;
|
||||
}
|
||||
|
||||
// custom gradients
|
||||
.custom-gradient-configurator {
|
||||
.gradient-checkers {
|
||||
background-image: url('/web/static/img/transparent.png');
|
||||
background-size: var(--PreviewAlphaBg-background-size, 10px) auto;
|
||||
padding: 10px 0;
|
||||
margin-bottom: -20px;
|
||||
}
|
||||
.gradient-preview {
|
||||
padding: 10px 0;
|
||||
cursor: copy;
|
||||
}
|
||||
.gradient-colors {
|
||||
height: 18px;
|
||||
div {
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
}
|
||||
input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
input::-moz-range-thumb {
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
input[type=range]:focus-visible {
|
||||
outline: none;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 1px var(--bg, $white), 0 0 0 3px var(--o-color-picker-active-color, $o-enterprise-action-color);
|
||||
}
|
||||
&::-moz-range-thumb {
|
||||
box-shadow: 0 0 0 1px var(--bg, $white), 0 0 0 3px var(--o-color-picker-active-color, $o-enterprise-action-color);
|
||||
}
|
||||
&::-ms-thumb {
|
||||
box-shadow: 0 0 0 1px var(--bg, $white), 0 0 0 3px var(--o-color-picker-active-color, $o-enterprise-action-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-gradient-configurator + .o_colorpicker_widget {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.gradient-color-bin {
|
||||
position: relative;
|
||||
margin: 0 12px;
|
||||
height: 22px;
|
||||
> a.btn {
|
||||
padding: 0 2px 2px;
|
||||
margin-top: 0;
|
||||
margin-left: -12px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.ColorPickerGradientTab">
|
||||
<t t-set="currentGradient" t-value="getCurrentGradientColor()" />
|
||||
<div class="o_colorpicker_sections p-2 d-grid gap-1" style="grid-template-columns: 1fr 1fr;"
|
||||
t-on-click="props.onColorClick" t-on-mouseover="props.onColorPointerOver"
|
||||
t-on-mouseout="props.onColorPointerOut" t-on-focusin="props.onFocusin" t-on-focusout="props.onFocusout">
|
||||
<t t-set="gradientsWithOpacity" t-value="DEFAULT_GRADIENT_COLORS.map((gradient) => applyOpacityToGradient(gradient, props.defaultOpacity))" />
|
||||
<t t-foreach="gradientsWithOpacity" t-as="gradient" t-key="gradient">
|
||||
<button class="w-100 m-0 o_color_button o_color_picker_button o_gradient_color_button btn p-0"
|
||||
t-att-class="{'selected': currentGradient?.includes(gradient)}"
|
||||
t-attf-style="background-image: #{gradient};" t-att-data-color="gradient"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="px-2">
|
||||
<button t-attf-style="{{ currentGradient ? `background-image: ${currentGradient}` : '' }};"
|
||||
class="w-50 border btn mb-2 o_custom_gradient_button o_color_picker_button"
|
||||
t-att-class="{'selected': currentGradient and !gradientsWithOpacity.includes(currentGradient)}"
|
||||
t-att-data-color="currentGradient" t-on-click="toggleGradientPicker" title="Define a custom gradient">
|
||||
Custom
|
||||
</button>
|
||||
<GradientPicker t-if="state.showGradientPicker"
|
||||
onGradientChange.bind="props.applyColor"
|
||||
onGradientPreview.bind="props.onColorPreview"
|
||||
setOnCloseCallback.bind="props.setOnCloseCallback"
|
||||
setOperationCallbacks.bind="props.setOperationCallbacks"
|
||||
selectedGradient="getCurrentGradientColor()"
|
||||
noTransparency="props.noTransparency"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,527 @@
|
|||
import { Plugin } from "@html_editor/plugin";
|
||||
import {
|
||||
BG_CLASSES_REGEX,
|
||||
COLOR_COMBINATION_CLASSES_REGEX,
|
||||
hasAnyNodesColor,
|
||||
hasColor,
|
||||
TEXT_CLASSES_REGEX,
|
||||
} from "@html_editor/utils/color";
|
||||
import { fillEmpty, unwrapContents } from "@html_editor/utils/dom";
|
||||
import {
|
||||
isEmptyBlock,
|
||||
isRedundantElement,
|
||||
isTextNode,
|
||||
isWhitespace,
|
||||
isZwnbsp,
|
||||
} from "@html_editor/utils/dom_info";
|
||||
import { closestElement, descendants, selectElements } from "@html_editor/utils/dom_traversal";
|
||||
import { isColorGradient, rgbaToHex } from "@web/core/utils/colors";
|
||||
import { backgroundImageCssToParts, backgroundImagePartsToCss } from "@html_editor/utils/image";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
import { isBlock } from "@html_editor/utils/blocks";
|
||||
|
||||
const COLOR_COMBINATION_CLASSES = [1, 2, 3, 4, 5].map((i) => `o_cc${i}`);
|
||||
const COLOR_COMBINATION_SELECTOR = COLOR_COMBINATION_CLASSES.map((c) => `.${c}`).join(", ");
|
||||
|
||||
/**
|
||||
* @typedef { Object } ColorShared
|
||||
* @property { ColorPlugin['colorElement'] } colorElement
|
||||
* @property { ColorPlugin['removeAllColor'] } removeAllColor
|
||||
* @property { ColorPlugin['getElementColors'] } getElementColors
|
||||
* @property { ColorPlugin['applyColor'] } applyColor
|
||||
*/
|
||||
export class ColorPlugin extends Plugin {
|
||||
static id = "color";
|
||||
static dependencies = ["selection", "split", "history", "format"];
|
||||
static shared = [
|
||||
"colorElement",
|
||||
"removeAllColor",
|
||||
"getElementColors",
|
||||
"getColorCombination",
|
||||
"applyColor",
|
||||
];
|
||||
resources = {
|
||||
user_commands: [
|
||||
{
|
||||
id: "applyColor",
|
||||
run: ({ color, mode }) => {
|
||||
this.applyColor(color, mode);
|
||||
this.dependencies.history.addStep();
|
||||
},
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
],
|
||||
/** Handlers */
|
||||
remove_all_formats_handlers: this.removeAllColor.bind(this),
|
||||
color_combination_getters: getColorCombinationFromClass,
|
||||
|
||||
/** Predicates */
|
||||
has_format_predicates: [
|
||||
(node) => hasColor(closestElement(node), "color"),
|
||||
(node) => hasColor(closestElement(node), "backgroundColor"),
|
||||
],
|
||||
format_class_predicates: (className) =>
|
||||
TEXT_CLASSES_REGEX.test(className) || BG_CLASSES_REGEX.test(className),
|
||||
normalize_handlers: this.normalize.bind(this),
|
||||
};
|
||||
|
||||
normalize(root) {
|
||||
for (const el of selectElements(root, "font")) {
|
||||
if (isRedundantElement(el)) {
|
||||
unwrapContents(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getElementColors(el) {
|
||||
const elStyle = getComputedStyle(el);
|
||||
const backgroundImage = elStyle.backgroundImage;
|
||||
const gradient = backgroundImageCssToParts(backgroundImage).gradient;
|
||||
const hasGradient = isColorGradient(gradient);
|
||||
const hasTextGradientClass = el.classList.contains("text-gradient");
|
||||
|
||||
let backgroundColor = elStyle.backgroundColor;
|
||||
for (const processor of this.getResource("get_background_color_processors")) {
|
||||
backgroundColor = processor(backgroundColor);
|
||||
}
|
||||
|
||||
return {
|
||||
color: hasGradient && hasTextGradientClass ? gradient : rgbaToHex(elStyle.color),
|
||||
backgroundColor:
|
||||
hasGradient && !hasTextGradientClass ? gradient : rgbaToHex(backgroundColor),
|
||||
};
|
||||
}
|
||||
|
||||
removeAllColor() {
|
||||
const colorModes = ["color", "backgroundColor"];
|
||||
let someColorWasRemoved = true;
|
||||
while (someColorWasRemoved) {
|
||||
someColorWasRemoved = false;
|
||||
for (const mode of colorModes) {
|
||||
let max = 40;
|
||||
const hasAnySelectedNodeColor = (mode) => {
|
||||
const nodes = this.dependencies.selection
|
||||
.getTargetedNodes()
|
||||
.filter(
|
||||
(n) =>
|
||||
isTextNode(n) ||
|
||||
(mode === "backgroundColor" &&
|
||||
n.classList.contains("o_selected_td"))
|
||||
);
|
||||
return hasAnyNodesColor(nodes, mode);
|
||||
};
|
||||
while (hasAnySelectedNodeColor(mode) && max > 0) {
|
||||
this.applyColor("", mode);
|
||||
someColorWasRemoved = true;
|
||||
max--;
|
||||
}
|
||||
if (max === 0) {
|
||||
someColorWasRemoved = false;
|
||||
throw new Error("Infinite Loop in removeAllColor().");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a css or class color on the current selection (wrapped in <font>).
|
||||
*
|
||||
* @param {string} color hexadecimal or bg-name/text-name class
|
||||
* @param {string} mode 'color' or 'backgroundColor'
|
||||
* @param {boolean} [previewMode=false] true - apply color in preview mode
|
||||
*/
|
||||
applyColor(color, mode, previewMode = false) {
|
||||
this.dependencies.selection.selectAroundNonEditable();
|
||||
if (mode === "backgroundColor") {
|
||||
for (const processor of this.getResource("apply_background_color_processors")) {
|
||||
color = processor(color, mode);
|
||||
}
|
||||
}
|
||||
if (this.delegateTo("color_apply_overrides", color, mode, previewMode)) {
|
||||
return;
|
||||
}
|
||||
let selection = this.dependencies.selection.getEditableSelection();
|
||||
let targetedNodes;
|
||||
// Get the <font> nodes to color
|
||||
if (selection.isCollapsed) {
|
||||
let zws;
|
||||
if (
|
||||
selection.anchorNode.nodeType !== Node.TEXT_NODE &&
|
||||
selection.anchorNode.textContent !== "\u200b"
|
||||
) {
|
||||
zws = selection.anchorNode;
|
||||
} else {
|
||||
zws = this.dependencies.format.insertAndSelectZws();
|
||||
}
|
||||
selection = this.dependencies.selection.setSelection(
|
||||
{
|
||||
anchorNode: zws,
|
||||
anchorOffset: 0,
|
||||
},
|
||||
{ normalize: false }
|
||||
);
|
||||
targetedNodes = [zws];
|
||||
} else {
|
||||
selection = this.dependencies.split.splitSelection();
|
||||
targetedNodes = this.dependencies.selection
|
||||
.getTargetedNodes()
|
||||
.filter(
|
||||
(node) =>
|
||||
this.dependencies.selection.isNodeEditable(node) && node.nodeName !== "T"
|
||||
);
|
||||
if (isEmptyBlock(selection.endContainer)) {
|
||||
targetedNodes.push(selection.endContainer, ...descendants(selection.endContainer));
|
||||
}
|
||||
}
|
||||
|
||||
const findTopMostDecoration = (current) => {
|
||||
const decoration = closestElement(current.parentNode, "s, u");
|
||||
return decoration?.textContent === current.textContent
|
||||
? findTopMostDecoration(decoration)
|
||||
: current;
|
||||
};
|
||||
|
||||
const hexColor = rgbaToHex(color).toLowerCase();
|
||||
const selectedNodes = targetedNodes
|
||||
.filter((node) => {
|
||||
if (mode === "backgroundColor" && color) {
|
||||
return !closestElement(node, "table.o_selected_table");
|
||||
}
|
||||
if (closestElement(node).classList.contains("o_default_color")) {
|
||||
return false;
|
||||
}
|
||||
const li = closestElement(node, "li");
|
||||
if (li && color && this.dependencies.selection.areNodeContentsFullySelected(li)) {
|
||||
return rgbaToHex(li.style.color).toLowerCase() !== hexColor;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((node) => findTopMostDecoration(node));
|
||||
|
||||
const targetedFieldNodes = new Set(
|
||||
this.dependencies.selection
|
||||
.getTargetedNodes()
|
||||
.map((n) => closestElement(n, "*[t-field],*[t-out],*[t-esc]"))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const getFonts = (selectedNodes) =>
|
||||
selectedNodes.flatMap((node) => {
|
||||
let font =
|
||||
closestElement(node, "font") ||
|
||||
closestElement(
|
||||
node,
|
||||
'[style*="color"]:not(li), [style*="background-color"]:not(li), [style*="background-image"]:not(li)'
|
||||
) ||
|
||||
closestElement(node, "span");
|
||||
if (font && font.querySelector(".fa")) {
|
||||
return font;
|
||||
}
|
||||
const children = font && descendants(font);
|
||||
const hasInlineGradient = font && isColorGradient(font.style["background-image"]);
|
||||
const isFullySelected =
|
||||
children && children.every((child) => selectedNodes.includes(child));
|
||||
const isTextGradient =
|
||||
hasInlineGradient && font.classList.contains("text-gradient");
|
||||
const shouldReplaceExistingGradient =
|
||||
isFullySelected &&
|
||||
((mode === "color" && isTextGradient) ||
|
||||
(mode === "backgroundColor" && !isTextGradient));
|
||||
if (
|
||||
font &&
|
||||
font.nodeName !== "T" &&
|
||||
(font.nodeName !== "SPAN" || font.style[mode] || font.style.backgroundImage) &&
|
||||
(isColorGradient(color) ||
|
||||
color === "" ||
|
||||
!hasInlineGradient ||
|
||||
shouldReplaceExistingGradient) &&
|
||||
!this.dependencies.split.isUnsplittable(font)
|
||||
) {
|
||||
// Partially selected <font>: split it.
|
||||
const selectedChildren = children.filter((child) =>
|
||||
selectedNodes.includes(child)
|
||||
);
|
||||
if (selectedChildren.length) {
|
||||
if (isBlock(font)) {
|
||||
const colorStyles = ["color", "background-color", "background-image"];
|
||||
const newFont = this.document.createElement("font");
|
||||
for (const style of colorStyles) {
|
||||
const styleValue = font.style[style];
|
||||
if (styleValue) {
|
||||
this.colorElement(newFont, styleValue, style);
|
||||
font.style.removeProperty(style);
|
||||
}
|
||||
}
|
||||
newFont.append(...font.childNodes);
|
||||
font.append(newFont);
|
||||
font = newFont;
|
||||
}
|
||||
const closestGradientEl = closestElement(
|
||||
node,
|
||||
'font[style*="background-image"], span[style*="background-image"]'
|
||||
);
|
||||
const isGradientBeingUpdated = closestGradientEl && isColorGradient(color);
|
||||
const splitnode = isGradientBeingUpdated ? closestGradientEl : font;
|
||||
font = this.dependencies.split.splitAroundUntil(
|
||||
selectedChildren,
|
||||
splitnode
|
||||
);
|
||||
if (isGradientBeingUpdated) {
|
||||
const classRegex =
|
||||
mode === "color" ? TEXT_CLASSES_REGEX : BG_CLASSES_REGEX;
|
||||
// When updating a gradient, remove color applied to
|
||||
// its descendants.This ensures the gradient remains
|
||||
// visible without being overwritten by a descendant's color.
|
||||
for (const node of descendants(font)) {
|
||||
if (
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node.style[mode] || classRegex.test(node.className))
|
||||
) {
|
||||
this.colorElement(node, "", mode);
|
||||
node.style.webkitTextFillColor = "";
|
||||
if (!node.getAttribute("style")) {
|
||||
unwrapContents(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
mode === "color" &&
|
||||
(font.style.webkitTextFillColor ||
|
||||
(closestGradientEl &&
|
||||
closestGradientEl.classList.contains("text-gradient") &&
|
||||
!shouldReplaceExistingGradient))
|
||||
) {
|
||||
font.style.webkitTextFillColor = color;
|
||||
}
|
||||
} else {
|
||||
font = [];
|
||||
}
|
||||
} else if (
|
||||
(node.nodeType === Node.TEXT_NODE && !isZwnbsp(node)) ||
|
||||
(node.nodeName === "BR" && isEmptyBlock(node.parentNode)) ||
|
||||
(node.nodeType === Node.ELEMENT_NODE &&
|
||||
["inline", "inline-block"].includes(getComputedStyle(node).display) &&
|
||||
!isWhitespace(node.textContent) &&
|
||||
!node.classList.contains("btn") &&
|
||||
!node.querySelector("font") &&
|
||||
node.nodeName !== "A" &&
|
||||
!(node.nodeName === "SPAN" && node.style["fontSize"]))
|
||||
) {
|
||||
// Node is a visible text or inline node without font nor a button:
|
||||
// wrap it in a <font>.
|
||||
const previous = node.previousSibling;
|
||||
const classRegex = mode === "color" ? BG_CLASSES_REGEX : TEXT_CLASSES_REGEX;
|
||||
if (
|
||||
previous &&
|
||||
previous.nodeName === "FONT" &&
|
||||
!previous.style[mode === "color" ? "backgroundColor" : "color"] &&
|
||||
!classRegex.test(previous.className) &&
|
||||
selectedNodes.includes(previous.firstChild) &&
|
||||
selectedNodes.includes(previous.lastChild)
|
||||
) {
|
||||
// Directly follows a fully selected <font> that isn't
|
||||
// colored in the other mode: append to that.
|
||||
font = previous;
|
||||
} else {
|
||||
// No <font> found: insert a new one.
|
||||
font = this.document.createElement("font");
|
||||
node.after(font);
|
||||
if (isTextGradient && mode === "color") {
|
||||
font.style.webkitTextFillColor = color;
|
||||
}
|
||||
}
|
||||
if (node.textContent) {
|
||||
font.appendChild(node);
|
||||
} else {
|
||||
fillEmpty(font);
|
||||
}
|
||||
} else {
|
||||
font = []; // Ignore non-text or invisible text nodes.
|
||||
}
|
||||
return font;
|
||||
});
|
||||
|
||||
for (const fieldNode of targetedFieldNodes) {
|
||||
this.colorElement(fieldNode, color, mode);
|
||||
}
|
||||
|
||||
let fonts = getFonts(selectedNodes);
|
||||
// Dirty fix as the previous call could have unconnected elements
|
||||
// because of the `splitAroundUntil`. Another call should provide he
|
||||
// correct list of fonts.
|
||||
if (!fonts.every((font) => font.isConnected)) {
|
||||
fonts = getFonts(selectedNodes);
|
||||
}
|
||||
|
||||
// Color the selected <font>s and remove uncolored fonts.
|
||||
const fontsSet = new Set(fonts);
|
||||
for (const font of fontsSet) {
|
||||
this.colorElement(font, color, mode);
|
||||
if (
|
||||
!hasColor(font, "color") &&
|
||||
!hasColor(font, "backgroundColor") &&
|
||||
["FONT", "SPAN"].includes(font.nodeName) &&
|
||||
(!font.hasAttribute("style") || !color)
|
||||
) {
|
||||
for (const child of [...font.childNodes]) {
|
||||
font.parentNode.insertBefore(child, font);
|
||||
}
|
||||
font.parentNode.removeChild(font);
|
||||
fontsSet.delete(font);
|
||||
}
|
||||
}
|
||||
this.dependencies.selection.setSelection(selection, { normalize: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a css or class color (fore- or background-) to an element.
|
||||
* Replace the color that was already there if any.
|
||||
*
|
||||
* @param {Element} element
|
||||
* @param {string} color hexadecimal or bg-name/text-name class
|
||||
* @param {'color'|'backgroundColor'} mode 'color' or 'backgroundColor'
|
||||
*/
|
||||
colorElement(element, color, mode) {
|
||||
let parts = backgroundImageCssToParts(element.style["background-image"]);
|
||||
const oldClassName = element.getAttribute("class") || "";
|
||||
|
||||
if (element.matches(COLOR_COMBINATION_SELECTOR)) {
|
||||
removePresetGradient(element);
|
||||
}
|
||||
|
||||
const hasGradientStyle = element.style.backgroundImage.includes("-gradient");
|
||||
if (mode === "backgroundColor") {
|
||||
if (!color) {
|
||||
element.classList.remove("o_cc", ...COLOR_COMBINATION_CLASSES);
|
||||
}
|
||||
const hasGradient = getComputedStyle(element).backgroundImage.includes("-gradient");
|
||||
delete parts.gradient;
|
||||
let newBackgroundImage = backgroundImagePartsToCss(parts);
|
||||
// we override the bg image if the new bg image is empty, but the previous one is a gradient.
|
||||
if (hasGradient && !newBackgroundImage) {
|
||||
newBackgroundImage = "none";
|
||||
}
|
||||
element.style.backgroundImage = newBackgroundImage;
|
||||
element.style["background-color"] = "";
|
||||
}
|
||||
|
||||
const newClassName = oldClassName
|
||||
.replace(mode === "color" ? TEXT_CLASSES_REGEX : BG_CLASSES_REGEX, "")
|
||||
.replace(/\btext-gradient\b/g, "") // cannot be combined with setting a background
|
||||
.replace(/\s+/, " ");
|
||||
if (oldClassName !== newClassName) {
|
||||
element.setAttribute("class", newClassName);
|
||||
}
|
||||
if (color.startsWith("text") || color.startsWith("bg-")) {
|
||||
element.style[mode] = "";
|
||||
element.classList.add(color);
|
||||
} else if (isColorGradient(color)) {
|
||||
element.style[mode] = "";
|
||||
parts.gradient = color;
|
||||
if (mode === "color") {
|
||||
element.style["background-color"] = "";
|
||||
element.classList.add("text-gradient");
|
||||
}
|
||||
this.applyColorStyle(element, "background-image", backgroundImagePartsToCss(parts));
|
||||
} else {
|
||||
delete parts.gradient;
|
||||
if (hasGradientStyle && !backgroundImagePartsToCss(parts)) {
|
||||
element.style["background-image"] = "";
|
||||
}
|
||||
// Change camelCase to kebab-case.
|
||||
mode = mode.replace("backgroundColor", "background-color");
|
||||
this.applyColorStyle(element, mode, color);
|
||||
}
|
||||
|
||||
// It was decided that applying a color combination removes any "color"
|
||||
// value (custom color, color classes, gradients, ...). Changing any
|
||||
// "color", including color combinations, should still not remove the
|
||||
// other background layers though (image, video, shape, ...).
|
||||
if (color.startsWith("o_cc")) {
|
||||
parts = backgroundImageCssToParts(element.style["background-image"]);
|
||||
element.classList.remove(...COLOR_COMBINATION_CLASSES);
|
||||
element.classList.add("o_cc", color);
|
||||
|
||||
const hasBackgroundColor = !!getComputedStyle(element).backgroundColor;
|
||||
const hasGradient = getComputedStyle(element).backgroundImage.includes("-gradient");
|
||||
const backgroundImage = element.style["background-image"];
|
||||
// Override gradient background image if coming from css rather than inline style.
|
||||
if (hasBackgroundColor && hasGradient && !backgroundImage) {
|
||||
element.style.backgroundImage = "none";
|
||||
}
|
||||
}
|
||||
|
||||
this.fixColorCombination(element, color);
|
||||
}
|
||||
/**
|
||||
* There is a limitation with css. The defining a background image and a
|
||||
* background gradient is done only by setting one style (background-image).
|
||||
* If there is a class (in this case o_cc[1-5]) that defines a gradient, it
|
||||
* will be overridden by the background-image property.
|
||||
*
|
||||
* This function will set the gradient of the o_cc in the background-image
|
||||
* so that setting an image in the background-image property will not
|
||||
* override the gradient.
|
||||
*/
|
||||
fixColorCombination(element, color) {
|
||||
const parts = backgroundImageCssToParts(element.style["background-image"]);
|
||||
const hasBackgroundColor =
|
||||
element.style["background-color"] ||
|
||||
!!element.className.match(/\bbg-/) ||
|
||||
parts.gradient;
|
||||
|
||||
if (!hasBackgroundColor && (isColorGradient(color) || color.startsWith("o_cc"))) {
|
||||
element.style["background-image"] = "";
|
||||
parts.gradient = backgroundImageCssToParts(
|
||||
// Compute the style from o_cc class.
|
||||
getComputedStyle(element).backgroundImage
|
||||
).gradient;
|
||||
element.style["background-image"] = backgroundImagePartsToCss(parts);
|
||||
}
|
||||
}
|
||||
|
||||
getColorCombination(el, actionParam) {
|
||||
for (const handler of this.getResource("color_combination_getters")) {
|
||||
const value = handler(el, actionParam);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @param {string} cssProp
|
||||
* @param {string} cssValue
|
||||
*/
|
||||
applyColorStyle(element, mode, color) {
|
||||
if (this.delegateTo("apply_color_style_overrides", element, mode, color)) {
|
||||
return;
|
||||
}
|
||||
element.style[mode] = color;
|
||||
}
|
||||
}
|
||||
|
||||
function getColorCombinationFromClass(el) {
|
||||
return el.className.match?.(COLOR_COMBINATION_CLASSES_REGEX)?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the gradient of the element only if it is the inheritance from the o_cc selector.
|
||||
*/
|
||||
function removePresetGradient(element) {
|
||||
const oldBackgroundImage = element.style["background-image"];
|
||||
const parts = backgroundImageCssToParts(oldBackgroundImage);
|
||||
const currentGradient = parts.gradient;
|
||||
element.style.removeProperty("background-image");
|
||||
const styleWithoutGradient = getComputedStyle(element);
|
||||
const presetGradient = backgroundImageCssToParts(styleWithoutGradient.backgroundImage).gradient;
|
||||
if (presetGradient !== currentGradient) {
|
||||
const withGradient = backgroundImagePartsToCss(parts);
|
||||
element.style["background-image"] = withGradient === "none" ? "" : withGradient;
|
||||
} else {
|
||||
delete parts.gradient;
|
||||
const withoutGradient = backgroundImagePartsToCss(parts);
|
||||
element.style["background-image"] = withoutGradient === "none" ? "" : withoutGradient;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { isColorGradient } from "@web/core/utils/colors";
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import {
|
||||
useColorPicker,
|
||||
DEFAULT_COLORS,
|
||||
DEFAULT_THEME_COLOR_VARS,
|
||||
} from "@web/core/color_picker/color_picker";
|
||||
import { effect } from "@web/core/utils/reactive";
|
||||
import { toolbarButtonProps } from "../toolbar/toolbar";
|
||||
import { getCSSVariableValue, getHtmlStyle } from "@html_editor/utils/formatting";
|
||||
import { useChildRef } from "@web/core/utils/hooks";
|
||||
import { useDropdownAutoVisibility } from "@html_editor/dropdown_autovisibility_hook";
|
||||
|
||||
export class ColorSelector extends Component {
|
||||
static template = "html_editor.ColorSelector";
|
||||
static props = {
|
||||
...toolbarButtonProps,
|
||||
mode: { type: String },
|
||||
type: { type: String },
|
||||
getSelectedColors: Function,
|
||||
applyColor: Function,
|
||||
applyColorPreview: Function,
|
||||
applyColorResetPreview: Function,
|
||||
getUsedCustomColors: Function,
|
||||
getTargetedElements: Function,
|
||||
colorPrefix: { type: String },
|
||||
enabledTabs: { type: Array, optional: true },
|
||||
cssVarColorPrefix: { type: String, optional: true },
|
||||
onClose: Function,
|
||||
};
|
||||
static defaultProps = {
|
||||
cssVarColorPrefix: "",
|
||||
enabledTabs: ["solid", "gradient", "custom"],
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({});
|
||||
const htmlStyle = getHtmlStyle(document);
|
||||
const defaultThemeColors = DEFAULT_THEME_COLOR_VARS.map((color) =>
|
||||
getCSSVariableValue(color, htmlStyle)
|
||||
);
|
||||
this.solidColors = [
|
||||
...DEFAULT_COLORS.flat(),
|
||||
...defaultThemeColors,
|
||||
getCSSVariableValue("body-color", htmlStyle), // Default applied color
|
||||
"#00000000", //Default Background color
|
||||
];
|
||||
effect(
|
||||
(selectedColors) => {
|
||||
this.state.selectedColor = selectedColors[this.props.mode];
|
||||
this.state.defaultTab = this.getCorrespondingColorTab(
|
||||
selectedColors[this.props.mode]
|
||||
);
|
||||
this.state.getTargetedElements = this.props.getTargetedElements;
|
||||
this.state.mode = this.props.mode;
|
||||
},
|
||||
[this.props.getSelectedColors()]
|
||||
);
|
||||
|
||||
const colorPickerRef = useChildRef();
|
||||
this.colorPicker = useColorPicker(
|
||||
"root",
|
||||
{
|
||||
state: this.state,
|
||||
applyColor: this.props.applyColor,
|
||||
applyColorPreview: this.props.applyColorPreview,
|
||||
applyColorResetPreview: this.props.applyColorResetPreview,
|
||||
getUsedCustomColors: this.props.getUsedCustomColors,
|
||||
colorPrefix: this.props.colorPrefix,
|
||||
enabledTabs: this.props.enabledTabs,
|
||||
cssVarColorPrefix: this.props.cssVarColorPrefix,
|
||||
},
|
||||
{
|
||||
env: this.__owl__.childEnv,
|
||||
onClose: () => {
|
||||
this.props.applyColorResetPreview();
|
||||
this.props.onClose();
|
||||
},
|
||||
ref: colorPickerRef,
|
||||
}
|
||||
);
|
||||
useDropdownAutoVisibility(this.env.overlayState, colorPickerRef);
|
||||
}
|
||||
|
||||
getCorrespondingColorTab(color) {
|
||||
if (!color || this.solidColors.includes(color.toUpperCase())) {
|
||||
return "solid";
|
||||
} else if (isColorGradient(color)) {
|
||||
return "gradient";
|
||||
} else {
|
||||
return "custom";
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedColorStyle() {
|
||||
if (isColorGradient(this.state.selectedColor)) {
|
||||
return `border-bottom: 2px solid transparent; border-image: ${this.state.selectedColor}; border-image-slice: 1`;
|
||||
}
|
||||
return `border-bottom: 2px solid ${this.state.selectedColor}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="html_editor.ColorSelector">
|
||||
<button t-ref="root" class="btn btn-light" t-attf-class="o-select-color-{{props.type}} {{this.colorPicker.isOpen ? 'active' : ''}}" t-att-title="props.title" t-att-disabled="props.isDisabled">
|
||||
<t t-if="props.type === 'foreground'">
|
||||
<i class="fa fa-fw fa-font py-1" t-att-style="this.getSelectedColorStyle()"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-fw fa-paint-brush py-1" t-att-style="this.getSelectedColorStyle()"/>
|
||||
</t>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
import { Plugin } from "@html_editor/plugin";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { ColorSelector } from "./color_selector";
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { isTextNode } from "@html_editor/utils/dom_info";
|
||||
import { closestElement } from "@html_editor/utils/dom_traversal";
|
||||
import { isCSSColor, RGBA_REGEX, rgbaToHex } from "@web/core/utils/colors";
|
||||
|
||||
const RGBA_OPACITY = 0.6;
|
||||
const HEX_OPACITY = "99";
|
||||
|
||||
/**
|
||||
* @typedef { Object } ColorUIShared
|
||||
* @property { ColorUIPlugin['getPropsForColorSelector'] } getPropsForColorSelector
|
||||
*/
|
||||
|
||||
export class ColorUIPlugin extends Plugin {
|
||||
static id = "colorUi";
|
||||
static dependencies = ["color", "history", "selection"];
|
||||
static shared = ["getPropsForColorSelector"];
|
||||
resources = {
|
||||
toolbar_items: [
|
||||
{
|
||||
id: "forecolor",
|
||||
groupId: "decoration",
|
||||
namespaces: ["compact", "expanded"],
|
||||
description: _t("Apply Font Color"),
|
||||
Component: ColorSelector,
|
||||
props: this.getPropsForColorSelector("foreground"),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "backcolor",
|
||||
groupId: "decoration",
|
||||
description: _t("Apply Background Color"),
|
||||
Component: ColorSelector,
|
||||
props: this.getPropsForColorSelector("background"),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
],
|
||||
selectionchange_handlers: this.updateSelectedColor.bind(this),
|
||||
get_background_color_processors: this.getBackgroundColorProcessor.bind(this),
|
||||
apply_background_color_processors: this.applyBackgroundColorProcessor.bind(this),
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.selectedColors = reactive({ color: "", backgroundColor: "" });
|
||||
this.previewableApplyColor = this.dependencies.history.makePreviewableOperation(
|
||||
(color, mode, previewMode) =>
|
||||
this.dependencies.color.applyColor(color, mode, previewMode)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'foreground'|'background'} type
|
||||
*/
|
||||
getPropsForColorSelector(type) {
|
||||
const mode = type === "foreground" ? "color" : "backgroundColor";
|
||||
return {
|
||||
type,
|
||||
mode,
|
||||
|
||||
getUsedCustomColors: () => this.getUsedCustomColors(mode),
|
||||
getSelectedColors: () => this.selectedColors,
|
||||
applyColor: (color) => this.applyColorCommit({ color, mode }),
|
||||
applyColorPreview: (color) => this.applyColorPreview({ color, mode }),
|
||||
applyColorResetPreview: this.applyColorResetPreview.bind(this),
|
||||
colorPrefix: mode === "color" ? "text-" : "bg-",
|
||||
onClose: () => this.dependencies.selection.focusEditable(),
|
||||
getTargetedElements: () => {
|
||||
const nodes = this.dependencies.selection.getTargetedNodes().filter(isTextNode);
|
||||
return nodes.map((node) => closestElement(node));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a css or class color on the current selection (wrapped in <font>).
|
||||
*
|
||||
* @param {Object} param
|
||||
* @param {string} param.color hexadecimal or bg-name/text-name class
|
||||
* @param {string} param.mode 'color' or 'backgroundColor'
|
||||
*/
|
||||
applyColorCommit({ color, mode }) {
|
||||
this.previewableApplyColor.commit(color, mode);
|
||||
this.updateSelectedColor();
|
||||
}
|
||||
/**
|
||||
* Apply a css or class color on the current selection (wrapped in <font>)
|
||||
* in preview mode so that it can be reset.
|
||||
*
|
||||
* @param {Object} param
|
||||
* @param {string} param.color hexadecimal or bg-name/text-name class
|
||||
* @param {string} param.mode 'color' or 'backgroundColor'
|
||||
*/
|
||||
applyColorPreview({ color, mode }) {
|
||||
// Preview the color before applying it.
|
||||
this.previewableApplyColor.preview(color, mode, true);
|
||||
this.updateSelectedColor();
|
||||
}
|
||||
/**
|
||||
* Reset the color applied in preview mode.
|
||||
*/
|
||||
applyColorResetPreview() {
|
||||
this.previewableApplyColor.revert();
|
||||
this.updateSelectedColor();
|
||||
}
|
||||
|
||||
getUsedCustomColors(mode) {
|
||||
const allFont = this.editable.querySelectorAll("font");
|
||||
const usedCustomColors = new Set();
|
||||
for (const font of allFont) {
|
||||
if (isCSSColor(font.style[mode])) {
|
||||
usedCustomColors.add(rgbaToHex(font.style[mode]));
|
||||
}
|
||||
}
|
||||
return usedCustomColors;
|
||||
}
|
||||
|
||||
updateSelectedColor() {
|
||||
const nodes = this.dependencies.selection.getTargetedNodes().filter(isTextNode);
|
||||
if (nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
const el = closestElement(nodes[0]);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(this.selectedColors, this.dependencies.color.getElementColors(el));
|
||||
}
|
||||
|
||||
getBackgroundColorProcessor(backgroundColor) {
|
||||
const activeTab = document
|
||||
.querySelector(".o_font_color_selector button.active")
|
||||
?.innerHTML.trim();
|
||||
if (backgroundColor.startsWith("rgba") && (!activeTab || activeTab === "Solid")) {
|
||||
// Buttons in the solid tab of color selector have no
|
||||
// opacity, hence to match selected color correctly,
|
||||
// we need to remove applied 0.6 opacity.
|
||||
const values = backgroundColor.match(RGBA_REGEX) || [];
|
||||
const alpha = parseFloat(values.pop()); // Extract alpha value
|
||||
if (alpha === RGBA_OPACITY) {
|
||||
backgroundColor = `rgb(${values.slice(0, 3).join(", ")})`; // Remove alpha
|
||||
}
|
||||
}
|
||||
return backgroundColor;
|
||||
}
|
||||
|
||||
applyBackgroundColorProcessor(brackgroundColor) {
|
||||
const activeTab = document
|
||||
.querySelector(".o_font_color_selector button.active")
|
||||
?.innerHTML.trim();
|
||||
if (activeTab === "Solid" && brackgroundColor.startsWith("#")) {
|
||||
// Apply default transparency to selected solid tab colors in background
|
||||
// mode to make text highlighting more usable between light and dark modes.
|
||||
brackgroundColor += HEX_OPACITY;
|
||||
}
|
||||
return brackgroundColor;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { Plugin } from "@html_editor/plugin";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { FontFamilySelector } from "@html_editor/main/font/font_family_selector";
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { closestElement } from "../../utils/dom_traversal";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
|
||||
export const defaultFontFamily = {
|
||||
name: "Default system font",
|
||||
nameShort: "Default",
|
||||
fontFamily: false,
|
||||
};
|
||||
export const fontFamilyItems = [
|
||||
defaultFontFamily,
|
||||
{ name: "Arial (sans-serif)", nameShort: "Arial", fontFamily: "Arial, sans-serif" },
|
||||
{ name: "Verdana (sans-serif)", nameShort: "Verdana", fontFamily: "Verdana, sans-serif" },
|
||||
{ name: "Tahoma (sans-serif)", nameShort: "Tahoma", fontFamily: "Tahoma, sans-serif" },
|
||||
{
|
||||
name: "Trebuchet MS (sans-serif)",
|
||||
nameShort: "Trebuchet",
|
||||
fontFamily: '"Trebuchet MS", sans-serif',
|
||||
},
|
||||
{
|
||||
name: "Courier New (monospace)",
|
||||
nameShort: "Courier",
|
||||
fontFamily: '"Courier New", monospace',
|
||||
},
|
||||
];
|
||||
|
||||
export class FontFamilyPlugin extends Plugin {
|
||||
static id = "fontFamily";
|
||||
static dependencies = ["split", "selection", "dom", "format", "font"];
|
||||
fontFamily = reactive({ displayName: defaultFontFamily.nameShort });
|
||||
resources = {
|
||||
toolbar_items: [
|
||||
withSequence(15, {
|
||||
id: "font-family",
|
||||
groupId: "font",
|
||||
description: _t("Select font family"),
|
||||
Component: FontFamilySelector,
|
||||
props: {
|
||||
fontFamilyItems: fontFamilyItems,
|
||||
currentFontFamily: this.fontFamily,
|
||||
onSelected: (item) => {
|
||||
this.dependencies.format.formatSelection("fontFamily", {
|
||||
applyStyle: item.fontFamily !== false,
|
||||
formatProps: item,
|
||||
});
|
||||
this.fontFamily.displayName = item.nameShort;
|
||||
},
|
||||
},
|
||||
isAvailable: isHtmlContentSupported,
|
||||
}),
|
||||
],
|
||||
/** Handlers */
|
||||
selectionchange_handlers: this.updateCurrentFontFamily.bind(this),
|
||||
post_undo_handlers: this.updateCurrentFontFamily.bind(this),
|
||||
post_redo_handlers: this.updateCurrentFontFamily.bind(this),
|
||||
};
|
||||
|
||||
updateCurrentFontFamily(ev) {
|
||||
const selelectionData = this.dependencies.selection.getSelectionData();
|
||||
if (!selelectionData.documentSelectionIsInEditable) {
|
||||
return;
|
||||
}
|
||||
const anchorElement = closestElement(selelectionData.editableSelection.anchorNode);
|
||||
const anchorElementFontFamily = getComputedStyle(anchorElement).fontFamily;
|
||||
const currentFontItem =
|
||||
anchorElementFontFamily &&
|
||||
fontFamilyItems.find((item) => item.fontFamily === anchorElementFontFamily);
|
||||
|
||||
this.fontFamily.displayName = (currentFontItem || defaultFontFamily).nameShort;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { toolbarButtonProps } from "@html_editor/main/toolbar/toolbar";
|
||||
import { useDropdownAutoVisibility } from "@html_editor/dropdown_autovisibility_hook";
|
||||
import { useChildRef } from "@web/core/utils/hooks";
|
||||
|
||||
export class FontFamilySelector extends Component {
|
||||
static template = "html_editor.FontFamilySelector";
|
||||
static props = {
|
||||
document: { optional: true },
|
||||
fontFamilyItems: Object,
|
||||
currentFontFamily: Object,
|
||||
onSelected: Function,
|
||||
...toolbarButtonProps,
|
||||
};
|
||||
static components = { Dropdown, DropdownItem };
|
||||
|
||||
setup() {
|
||||
this.menuRef = useChildRef();
|
||||
useDropdownAutoVisibility(this.env.overlayState, this.menuRef);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.FontFamilySelector">
|
||||
<Dropdown menuClass="'o_font_family_selector_menu'" menuRef="menuRef">
|
||||
<button class="btn btn-light" t-att-title="props.title" name="font_family" t-att-disabled="props.isDisabled">
|
||||
<span class="px-1" t-esc="props.currentFontFamily.displayName"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div data-prevent-closing-overlay="true">
|
||||
<t t-foreach="props.fontFamilyItems" t-as="item" t-key="item_index">
|
||||
<DropdownItem
|
||||
attrs="{ name: item.nameShort }"
|
||||
onSelected="() => props.onSelected(item)" t-on-pointerdown.prevent="() => {}">
|
||||
<t t-esc="item.name"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,621 @@
|
|||
import { Plugin } from "@html_editor/plugin";
|
||||
import { isBlock, closestBlock } from "@html_editor/utils/blocks";
|
||||
import { fillEmpty, unwrapContents } from "@html_editor/utils/dom";
|
||||
import { leftLeafOnlyNotBlockPath } from "@html_editor/utils/dom_state";
|
||||
import {
|
||||
isParagraphRelatedElement,
|
||||
isRedundantElement,
|
||||
isEmptyBlock,
|
||||
isVisibleTextNode,
|
||||
isZWS,
|
||||
} from "@html_editor/utils/dom_info";
|
||||
import {
|
||||
ancestors,
|
||||
childNodes,
|
||||
closestElement,
|
||||
createDOMPathGenerator,
|
||||
descendants,
|
||||
selectElements,
|
||||
} from "@html_editor/utils/dom_traversal";
|
||||
import {
|
||||
convertNumericToUnit,
|
||||
getCSSVariableValue,
|
||||
getHtmlStyle,
|
||||
getFontSizeDisplayValue,
|
||||
FONT_SIZE_CLASSES,
|
||||
} from "@html_editor/utils/formatting";
|
||||
import { DIRECTIONS } from "@html_editor/utils/position";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { FontSelector } from "./font_selector";
|
||||
import {
|
||||
getBaseContainerSelector,
|
||||
SUPPORTED_BASE_CONTAINER_NAMES,
|
||||
} from "@html_editor/utils/base_container";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { FontSizeSelector } from "./font_size_selector";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
import { weakMemoize } from "@html_editor/utils/functions";
|
||||
|
||||
export const fontSizeItems = [
|
||||
{ variableName: "display-1-font-size", className: "display-1-fs" },
|
||||
{ variableName: "display-2-font-size", className: "display-2-fs" },
|
||||
{ variableName: "display-3-font-size", className: "display-3-fs" },
|
||||
{ variableName: "display-4-font-size", className: "display-4-fs" },
|
||||
{ variableName: "h1-font-size", className: "h1-fs" },
|
||||
{ variableName: "h2-font-size", className: "h2-fs" },
|
||||
{ variableName: "h3-font-size", className: "h3-fs" },
|
||||
{ variableName: "h4-font-size", className: "h4-fs" },
|
||||
{ variableName: "h5-font-size", className: "h5-fs" },
|
||||
{ variableName: "h6-font-size", className: "h6-fs" },
|
||||
{ variableName: "font-size-base", className: "base-fs" },
|
||||
{ variableName: "small-font-size", className: "o_small-fs" },
|
||||
];
|
||||
|
||||
const rightLeafOnlyNotBlockPath = createDOMPathGenerator(DIRECTIONS.RIGHT, {
|
||||
leafOnly: true,
|
||||
stopTraverseFunction: isBlock,
|
||||
stopFunction: isBlock,
|
||||
});
|
||||
|
||||
const headingTags = ["H1", "H2", "H3", "H4", "H5", "H6"];
|
||||
const handledElemSelector = [...headingTags, "PRE", "BLOCKQUOTE"].join(", ");
|
||||
|
||||
export class FontPlugin extends Plugin {
|
||||
static id = "font";
|
||||
static dependencies = [
|
||||
"baseContainer",
|
||||
"input",
|
||||
"split",
|
||||
"selection",
|
||||
"dom",
|
||||
"format",
|
||||
"lineBreak",
|
||||
];
|
||||
resources = {
|
||||
font_items: [
|
||||
withSequence(10, {
|
||||
name: _t("Header 1 Display 1"),
|
||||
tagName: "h1",
|
||||
extraClass: "display-1",
|
||||
}),
|
||||
...[
|
||||
{ name: _t("Header 1"), tagName: "h1" },
|
||||
{ name: _t("Header 2"), tagName: "h2" },
|
||||
{ name: _t("Header 3"), tagName: "h3" },
|
||||
{ name: _t("Header 4"), tagName: "h4" },
|
||||
{ name: _t("Header 5"), tagName: "h5" },
|
||||
{ name: _t("Header 6"), tagName: "h6" },
|
||||
].map((item) => withSequence(20, item)),
|
||||
withSequence(30, {
|
||||
name: _t("Normal"),
|
||||
tagName: "div",
|
||||
// for the FontSelector component
|
||||
selector: getBaseContainerSelector("DIV"),
|
||||
}),
|
||||
withSequence(40, { name: _t("Paragraph"), tagName: "p" }),
|
||||
withSequence(50, { name: _t("Code"), tagName: "pre" }),
|
||||
withSequence(60, { name: _t("Quote"), tagName: "blockquote" }),
|
||||
],
|
||||
user_commands: [
|
||||
{
|
||||
id: "setTagHeading1",
|
||||
title: _t("Heading 1"),
|
||||
description: _t("Big section heading"),
|
||||
icon: "fa-header",
|
||||
run: () => this.dependencies.dom.setBlock({ tagName: "H1" }),
|
||||
isAvailable: this.blockFormatIsAvailable.bind(this),
|
||||
},
|
||||
{
|
||||
id: "setTagHeading2",
|
||||
title: _t("Heading 2"),
|
||||
description: _t("Medium section heading"),
|
||||
icon: "fa-header",
|
||||
run: () => this.dependencies.dom.setBlock({ tagName: "H2" }),
|
||||
isAvailable: this.blockFormatIsAvailable.bind(this),
|
||||
},
|
||||
{
|
||||
id: "setTagHeading3",
|
||||
title: _t("Heading 3"),
|
||||
description: _t("Small section heading"),
|
||||
icon: "fa-header",
|
||||
run: () => this.dependencies.dom.setBlock({ tagName: "H3" }),
|
||||
isAvailable: this.blockFormatIsAvailable.bind(this),
|
||||
},
|
||||
{
|
||||
id: "setTagParagraph",
|
||||
title: _t("Text"),
|
||||
description: _t("Paragraph block"),
|
||||
icon: "fa-paragraph",
|
||||
run: () => {
|
||||
this.dependencies.dom.setBlock({
|
||||
tagName: this.dependencies.baseContainer.getDefaultNodeName(),
|
||||
});
|
||||
},
|
||||
isAvailable: this.blockFormatIsAvailable.bind(this),
|
||||
},
|
||||
{
|
||||
id: "setTagQuote",
|
||||
title: _t("Quote"),
|
||||
description: _t("Add a blockquote section"),
|
||||
icon: "fa-quote-right",
|
||||
run: () => this.dependencies.dom.setBlock({ tagName: "blockquote" }),
|
||||
isAvailable: this.blockFormatIsAvailable.bind(this),
|
||||
},
|
||||
{
|
||||
id: "setTagPre",
|
||||
title: _t("Code"),
|
||||
description: _t("Add a code section"),
|
||||
icon: "fa-code",
|
||||
run: () => this.dependencies.dom.setBlock({ tagName: "pre" }),
|
||||
isAvailable: this.blockFormatIsAvailable.bind(this),
|
||||
},
|
||||
],
|
||||
toolbar_groups: [
|
||||
withSequence(10, {
|
||||
id: "font",
|
||||
}),
|
||||
],
|
||||
toolbar_items: [
|
||||
withSequence(10, {
|
||||
id: "font",
|
||||
groupId: "font",
|
||||
namespaces: ["compact", "expanded"],
|
||||
description: _t("Select font style"),
|
||||
Component: FontSelector,
|
||||
props: {
|
||||
getItems: () => this.availableFontItems,
|
||||
getDisplay: () => this.font,
|
||||
onSelected: (item) => {
|
||||
this.dependencies.dom.setBlock({
|
||||
tagName: item.tagName,
|
||||
extraClass: item.extraClass,
|
||||
});
|
||||
this.updateFontSelectorParams();
|
||||
},
|
||||
},
|
||||
isAvailable: this.blockFormatIsAvailable.bind(this),
|
||||
}),
|
||||
withSequence(20, {
|
||||
id: "font-size",
|
||||
groupId: "font",
|
||||
namespaces: ["compact", "expanded"],
|
||||
description: _t("Select font size"),
|
||||
Component: FontSizeSelector,
|
||||
props: {
|
||||
getItems: () => this.fontSizeItems,
|
||||
getDisplay: () => this.fontSize,
|
||||
onFontSizeInput: (size) => {
|
||||
this.dependencies.format.formatSelection("fontSize", {
|
||||
formatProps: { size },
|
||||
applyStyle: true,
|
||||
});
|
||||
this.updateFontSizeSelectorParams();
|
||||
},
|
||||
onSelected: (item) => {
|
||||
this.dependencies.format.formatSelection("setFontSizeClassName", {
|
||||
formatProps: { className: item.className },
|
||||
applyStyle: true,
|
||||
});
|
||||
this.updateFontSizeSelectorParams();
|
||||
},
|
||||
onBlur: () => this.dependencies.selection.focusEditable(),
|
||||
document: this.document,
|
||||
},
|
||||
isAvailable: isHtmlContentSupported,
|
||||
}),
|
||||
],
|
||||
powerbox_categories: withSequence(5, { id: "format", name: _t("Format") }),
|
||||
powerbox_items: [
|
||||
{
|
||||
categoryId: "format",
|
||||
commandId: "setTagHeading1",
|
||||
},
|
||||
{
|
||||
categoryId: "format",
|
||||
commandId: "setTagHeading2",
|
||||
},
|
||||
{
|
||||
categoryId: "format",
|
||||
commandId: "setTagHeading3",
|
||||
},
|
||||
{
|
||||
categoryId: "format",
|
||||
commandId: "setTagParagraph",
|
||||
},
|
||||
{
|
||||
categoryId: "format",
|
||||
commandId: "setTagQuote",
|
||||
},
|
||||
{
|
||||
categoryId: "format",
|
||||
commandId: "setTagPre",
|
||||
},
|
||||
],
|
||||
hints: [
|
||||
{ selector: "H1", text: _t("Heading 1") },
|
||||
{ selector: "H2", text: _t("Heading 2") },
|
||||
{ selector: "H3", text: _t("Heading 3") },
|
||||
{ selector: "H4", text: _t("Heading 4") },
|
||||
{ selector: "H5", text: _t("Heading 5") },
|
||||
{ selector: "H6", text: _t("Heading 6") },
|
||||
{ selector: "PRE", text: _t("Code") },
|
||||
{ selector: "BLOCKQUOTE", text: _t("Quote") },
|
||||
],
|
||||
|
||||
/** Handlers */
|
||||
input_handlers: this.onInput.bind(this),
|
||||
selectionchange_handlers: [
|
||||
this.updateFontSelectorParams.bind(this),
|
||||
this.updateFontSizeSelectorParams.bind(this),
|
||||
],
|
||||
post_undo_handlers: [
|
||||
this.updateFontSelectorParams.bind(this),
|
||||
this.updateFontSizeSelectorParams.bind(this),
|
||||
],
|
||||
post_redo_handlers: [
|
||||
this.updateFontSelectorParams.bind(this),
|
||||
this.updateFontSizeSelectorParams.bind(this),
|
||||
],
|
||||
normalize_handlers: this.normalize.bind(this),
|
||||
|
||||
/** Overrides */
|
||||
split_element_block_overrides: [
|
||||
this.handleSplitBlockHeading.bind(this),
|
||||
this.handleSplitBlockPRE.bind(this),
|
||||
this.handleSplitBlockquote.bind(this),
|
||||
],
|
||||
delete_backward_overrides: withSequence(20, this.handleDeleteBackward.bind(this)),
|
||||
delete_backward_word_overrides: this.handleDeleteBackward.bind(this),
|
||||
|
||||
/** Processors */
|
||||
clipboard_content_processors: this.processContentForClipboard.bind(this),
|
||||
before_insert_processors: this.handleInsertWithinPre.bind(this),
|
||||
|
||||
format_class_predicates: (className) =>
|
||||
[...FONT_SIZE_CLASSES, "o_default_font_size"].includes(className),
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.fontSize = reactive({ displayName: "" });
|
||||
this.font = reactive({ displayName: "" });
|
||||
this.blockFormatIsAvailableMemoized = weakMemoize(
|
||||
(selection) => isHtmlContentSupported(selection) && this.dependencies.dom.canSetBlock()
|
||||
);
|
||||
this.availableFontItems = this.getResource("font_items").filter(
|
||||
({ tagName }) =>
|
||||
!SUPPORTED_BASE_CONTAINER_NAMES.includes(tagName.toUpperCase()) ||
|
||||
this.config.baseContainers.includes(tagName.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
normalize(root) {
|
||||
for (const el of selectElements(root, "strong, b, span[style*='font-weight: bolder']")) {
|
||||
if (isRedundantElement(el)) {
|
||||
unwrapContents(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get fontName() {
|
||||
const sel = this.dependencies.selection.getSelectionData().deepEditableSelection;
|
||||
// if (!sel) {
|
||||
// return "Normal";
|
||||
// }
|
||||
const anchorNode = sel.anchorNode;
|
||||
const block = closestBlock(anchorNode);
|
||||
const tagName = block.tagName.toLowerCase();
|
||||
|
||||
const matchingItems = this.availableFontItems.filter((item) =>
|
||||
item.selector ? block.matches(item.selector) : item.tagName === tagName
|
||||
);
|
||||
|
||||
const matchingItemsWitoutExtraClass = matchingItems.filter((item) => !item.extraClass);
|
||||
|
||||
if (!matchingItems.length) {
|
||||
return _t("Normal");
|
||||
}
|
||||
|
||||
return (
|
||||
matchingItems.find((item) => block.classList.contains(item.extraClass)) ||
|
||||
(matchingItemsWitoutExtraClass.length && matchingItemsWitoutExtraClass[0])
|
||||
).name;
|
||||
}
|
||||
|
||||
get fontSizeName() {
|
||||
const sel = this.dependencies.selection.getSelectionData().deepEditableSelection;
|
||||
if (!sel) {
|
||||
return fontSizeItems[0].name;
|
||||
}
|
||||
return Math.round(getFontSizeDisplayValue(sel, this.document));
|
||||
}
|
||||
|
||||
get fontSizeItems() {
|
||||
const style = getHtmlStyle(this.document);
|
||||
const nameAlreadyUsed = new Set();
|
||||
return fontSizeItems
|
||||
.flatMap((item) => {
|
||||
const strValue = getCSSVariableValue(item.variableName, style);
|
||||
if (!strValue) {
|
||||
return [];
|
||||
}
|
||||
const remValue = parseFloat(strValue);
|
||||
const pxValue = convertNumericToUnit(remValue, "rem", "px", style);
|
||||
const roundedValue = Math.round(pxValue);
|
||||
if (nameAlreadyUsed.has(roundedValue)) {
|
||||
return [];
|
||||
}
|
||||
nameAlreadyUsed.add(roundedValue);
|
||||
|
||||
return [{ ...item, tagName: "span", name: roundedValue }];
|
||||
})
|
||||
.sort((a, b) => a.name - b.name);
|
||||
}
|
||||
|
||||
blockFormatIsAvailable(selection) {
|
||||
return this.blockFormatIsAvailableMemoized(selection);
|
||||
}
|
||||
|
||||
// @todo @phoenix: Move this to a specific Pre/CodeBlock plugin?
|
||||
/**
|
||||
* Specific behavior for pre: insert newline (\n) in text or insert p at
|
||||
* end.
|
||||
*/
|
||||
handleSplitBlockPRE({ targetNode, targetOffset }) {
|
||||
const closestPre = closestElement(targetNode, "pre");
|
||||
const closestBlockNode = closestBlock(targetNode);
|
||||
if (
|
||||
!closestPre ||
|
||||
(closestBlockNode.nodeName !== "PRE" &&
|
||||
((closestBlockNode.textContent && !isZWS(closestBlockNode)) ||
|
||||
closestBlockNode.nextSibling))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Nodes to the right of the split position.
|
||||
const nodesAfterTarget = [...rightLeafOnlyNotBlockPath(targetNode, targetOffset)];
|
||||
if (
|
||||
!nodesAfterTarget.length ||
|
||||
(nodesAfterTarget.length === 1 && nodesAfterTarget[0].nodeName === "BR") ||
|
||||
isEmptyBlock(closestBlockNode)
|
||||
) {
|
||||
// Remove the last empty block node within pre tag
|
||||
const [beforeElement, afterElement] = this.dependencies.split.splitElementBlock({
|
||||
targetNode,
|
||||
targetOffset,
|
||||
blockToSplit: closestBlockNode,
|
||||
});
|
||||
const isPreBlock = beforeElement.nodeName === "PRE";
|
||||
const baseContainer = isPreBlock
|
||||
? this.dependencies.baseContainer.createBaseContainer()
|
||||
: afterElement;
|
||||
if (isPreBlock) {
|
||||
baseContainer.replaceChildren(...afterElement.childNodes);
|
||||
afterElement.replaceWith(baseContainer);
|
||||
} else {
|
||||
beforeElement.remove();
|
||||
closestPre.after(afterElement);
|
||||
}
|
||||
const dir = closestBlockNode.getAttribute("dir") || closestPre.getAttribute("dir");
|
||||
if (dir) {
|
||||
baseContainer.setAttribute("dir", dir);
|
||||
}
|
||||
this.dependencies.selection.setCursorStart(baseContainer);
|
||||
} else {
|
||||
const lineBreak = this.document.createElement("br");
|
||||
targetNode.insertBefore(lineBreak, targetNode.childNodes[targetOffset]);
|
||||
this.dependencies.selection.setCursorEnd(lineBreak);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific behavior for blockquote: insert p at end and remove the last
|
||||
* empty node.
|
||||
*/
|
||||
handleSplitBlockquote({ targetNode, targetOffset, blockToSplit }) {
|
||||
const closestQuote = closestElement(targetNode, "blockquote");
|
||||
const closestBlockNode = closestBlock(targetNode);
|
||||
const blockQuotedir = closestQuote && closestQuote.getAttribute("dir");
|
||||
|
||||
if (!closestQuote || closestBlockNode.nodeName !== "BLOCKQUOTE") {
|
||||
// If the closestBlockNode is the last element child of its parent
|
||||
// and the parent is a blockquote
|
||||
// we should move the current block ouside of the blockquote.
|
||||
if (
|
||||
closestBlockNode.parentElement === closestQuote &&
|
||||
closestBlockNode.parentElement.lastElementChild === closestBlockNode &&
|
||||
!closestBlockNode.textContent
|
||||
) {
|
||||
closestQuote.after(closestBlockNode);
|
||||
|
||||
if (blockQuotedir && !closestBlockNode.getAttribute("dir")) {
|
||||
closestBlockNode.setAttribute("dir", blockQuotedir);
|
||||
}
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode: closestBlockNode,
|
||||
anchorOffset: 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
const previousElementSibling = selection.anchorNode?.childNodes[selection.anchorOffset - 1];
|
||||
const nextElementSibling = selection.anchorNode?.childNodes[selection.anchorOffset];
|
||||
// Double enter at the end of blockquote => we should break out of the blockquote element.
|
||||
if (previousElementSibling?.tagName === "BR" && nextElementSibling?.tagName === "BR") {
|
||||
nextElementSibling.remove();
|
||||
previousElementSibling.remove();
|
||||
this.dependencies.split.splitElementBlock({
|
||||
targetNode,
|
||||
targetOffset,
|
||||
blockToSplit,
|
||||
});
|
||||
this.dependencies.dom.setBlock({
|
||||
tagName: this.dependencies.baseContainer.getDefaultNodeName(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
this.dependencies.lineBreak.insertLineBreakElement({ targetNode, targetOffset });
|
||||
return true;
|
||||
}
|
||||
|
||||
// @todo @phoenix: Move this to a specific Heading plugin?
|
||||
/**
|
||||
* Specific behavior for headings: do not split in two if cursor at the end but
|
||||
* instead create a paragraph.
|
||||
* Cursor end of line: <h1>title[]</h1> + ENTER <=> <h1>title</h1><p>[]<br/></p>
|
||||
* Cursor in the line: <h1>tit[]le</h1> + ENTER <=> <h1>tit</h1><h1>[]le</h1>
|
||||
*/
|
||||
handleSplitBlockHeading(params) {
|
||||
const closestHeading = closestElement(params.targetNode, (element) =>
|
||||
headingTags.includes(element.tagName)
|
||||
);
|
||||
if (closestHeading) {
|
||||
const [, newElement] = this.dependencies.split.splitElementBlock(params);
|
||||
// @todo @phoenix: if this condition can be anticipated before the split,
|
||||
// handle the splitBlock only in such case.
|
||||
if (
|
||||
newElement &&
|
||||
headingTags.includes(newElement.tagName) &&
|
||||
!descendants(newElement).some(isVisibleTextNode)
|
||||
) {
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
const dir = newElement.getAttribute("dir");
|
||||
if (dir) {
|
||||
baseContainer.setAttribute("dir", dir);
|
||||
}
|
||||
baseContainer.replaceChildren(...newElement.childNodes);
|
||||
newElement.replaceWith(baseContainer);
|
||||
this.dependencies.selection.setCursorStart(baseContainer);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an empty heading, blockquote or pre at the beginning of the
|
||||
* editable into a baseContainer.
|
||||
*/
|
||||
handleDeleteBackward({ startContainer, startOffset, endContainer, endOffset }) {
|
||||
// Detect if cursor is at the start of the editable (collapsed range).
|
||||
const rangeIsCollapsed = startContainer === endContainer && startOffset === endOffset;
|
||||
if (!rangeIsCollapsed) {
|
||||
return;
|
||||
}
|
||||
// Check if cursor is inside an empty heading, blockquote or pre.
|
||||
const closestHandledElement = closestElement(endContainer, handledElemSelector);
|
||||
if (!closestHandledElement || closestHandledElement.textContent.length) {
|
||||
return;
|
||||
}
|
||||
// Check if unremovable.
|
||||
if (this.getResource("unremovable_node_predicates").some((p) => p(closestHandledElement))) {
|
||||
return;
|
||||
}
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
baseContainer.append(...closestHandledElement.childNodes);
|
||||
closestHandledElement.after(baseContainer);
|
||||
closestHandledElement.remove();
|
||||
this.dependencies.selection.setCursorStart(baseContainer);
|
||||
return true;
|
||||
}
|
||||
|
||||
onInput(ev) {
|
||||
if (ev.data !== " ") {
|
||||
return;
|
||||
}
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
const blockEl = closestBlock(selection.anchorNode);
|
||||
const leftDOMPath = leftLeafOnlyNotBlockPath(selection.anchorNode);
|
||||
let spaceOffset = selection.anchorOffset;
|
||||
let leftLeaf = leftDOMPath.next().value;
|
||||
while (leftLeaf) {
|
||||
// Calculate spaceOffset by adding lengths of previous text nodes
|
||||
// to correctly find offset position for selection within inline
|
||||
// elements. e.g. <p>ab<strong>cd []e</strong></p>
|
||||
spaceOffset += leftLeaf.length;
|
||||
leftLeaf = leftDOMPath.next().value;
|
||||
}
|
||||
const precedingText = blockEl.textContent.substring(0, spaceOffset);
|
||||
if (/^(#{1,6})\s$/.test(precedingText)) {
|
||||
const numberOfHash = precedingText.length - 1;
|
||||
const headingToBe = headingTags[numberOfHash - 1];
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode: blockEl.firstChild,
|
||||
anchorOffset: 0,
|
||||
focusNode: selection.focusNode,
|
||||
focusOffset: selection.focusOffset,
|
||||
});
|
||||
this.dependencies.selection.extractContent(
|
||||
this.dependencies.selection.getEditableSelection()
|
||||
);
|
||||
fillEmpty(blockEl);
|
||||
this.dependencies.dom.setBlock({ tagName: headingToBe });
|
||||
}
|
||||
}
|
||||
|
||||
updateFontSelectorParams() {
|
||||
this.font.displayName = this.fontName;
|
||||
}
|
||||
|
||||
updateFontSizeSelectorParams() {
|
||||
this.fontSize.displayName = this.fontSizeName;
|
||||
}
|
||||
|
||||
processContentForClipboard(clonedContents, selection) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return clonedContents;
|
||||
}
|
||||
|
||||
handleInsertWithinPre(insertContainer, block) {
|
||||
if (block.nodeName !== "PRE") {
|
||||
return insertContainer;
|
||||
}
|
||||
for (const cb of this.getResource("before_insert_within_pre_processors")) {
|
||||
insertContainer = cb(insertContainer);
|
||||
}
|
||||
const isDeepestBlock = (node) =>
|
||||
isBlock(node) && ![...node.querySelectorAll("*")].some(isBlock);
|
||||
let linebreak;
|
||||
const processNode = (node) => {
|
||||
const children = childNodes(node);
|
||||
if (isDeepestBlock(node) && node.nextSibling) {
|
||||
linebreak = this.document.createTextNode("\n");
|
||||
node.append(linebreak);
|
||||
}
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
unwrapContents(node);
|
||||
}
|
||||
for (const child of children) {
|
||||
processNode(child);
|
||||
}
|
||||
};
|
||||
for (const node of childNodes(insertContainer)) {
|
||||
processNode(node);
|
||||
}
|
||||
return insertContainer;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
$padding-pre: map-get($spacers, 2) map-get($spacers, 3);
|
||||
|
||||
pre {
|
||||
padding: $padding-pre;
|
||||
border: $border-width solid $border-color;
|
||||
border-radius: $border-radius;
|
||||
background-color: $gray-100;
|
||||
color: $gray-900;
|
||||
|
||||
&.o-we-hint::after {
|
||||
padding: $padding-pre;
|
||||
}
|
||||
}
|
||||
|
||||
$padding-blockquote: $spacer/2 $spacer;
|
||||
|
||||
blockquote {
|
||||
padding: $padding-blockquote;
|
||||
border-left: 5px solid;
|
||||
border-color: map-get($grays, '300');
|
||||
font-style: italic;
|
||||
|
||||
&.o-we-hint::after {
|
||||
padding: $padding-blockquote;
|
||||
}
|
||||
}
|
||||
|
||||
.odoo-editor-editable :is(h1, h2, h3, h4, h5, h6):not(:first-child) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Component, useState } from "@odoo/owl";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { toolbarButtonProps } from "@html_editor/main/toolbar/toolbar";
|
||||
import { useDropdownAutoVisibility } from "@html_editor/dropdown_autovisibility_hook";
|
||||
import { useChildRef } from "@web/core/utils/hooks";
|
||||
|
||||
export class FontSelector extends Component {
|
||||
static template = "html_editor.FontSelector";
|
||||
static props = {
|
||||
...toolbarButtonProps,
|
||||
getItems: Function,
|
||||
getDisplay: Function,
|
||||
onSelected: Function,
|
||||
};
|
||||
static components = { Dropdown, DropdownItem };
|
||||
|
||||
setup() {
|
||||
this.items = this.props.getItems();
|
||||
this.state = useState(this.props.getDisplay());
|
||||
this.menuRef = useChildRef();
|
||||
useDropdownAutoVisibility(this.env.overlayState, this.menuRef);
|
||||
}
|
||||
|
||||
onSelected(item) {
|
||||
this.props.onSelected(item);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o_font_selector_menu {
|
||||
--dropdown-min-width: none;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.FontSelector">
|
||||
<Dropdown menuClass="'o_font_selector_menu'" menuRef="menuRef">
|
||||
<button class="btn btn-light" t-att-title="props.title" name="font" t-att-disabled="props.isDisabled">
|
||||
<span class="px-1" t-esc="state.displayName"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div data-prevent-closing-overlay="true">
|
||||
<t t-foreach="items" t-as="item" t-key="item_index">
|
||||
<DropdownItem
|
||||
attrs="{ name: item.tagName }"
|
||||
onSelected="() => this.onSelected(item)" t-on-pointerdown.prevent="() => {}">
|
||||
<t t-esc="item.name"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o-we-font-size-item-bg {
|
||||
background-color: $gray-200;
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { Component, onMounted, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { toolbarButtonProps } from "@html_editor/main/toolbar/toolbar";
|
||||
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
import { cookie } from "@web/core/browser/cookie";
|
||||
import { getCSSVariableValue, getHtmlStyle } from "@html_editor/utils/formatting";
|
||||
import { useDropdownAutoVisibility } from "@html_editor/dropdown_autovisibility_hook";
|
||||
import { useChildRef } from "@web/core/utils/hooks";
|
||||
|
||||
const MAX_FONT_SIZE = 144;
|
||||
|
||||
export class FontSizeSelector extends Component {
|
||||
static template = "html_editor.FontSizeSelector";
|
||||
static props = {
|
||||
getItems: Function,
|
||||
getDisplay: Function,
|
||||
onFontSizeInput: Function,
|
||||
onSelected: Function,
|
||||
onBlur: { type: Function, optional: true },
|
||||
document: { validate: (p) => p.nodeType === Node.DOCUMENT_NODE },
|
||||
...toolbarButtonProps,
|
||||
};
|
||||
static components = { Dropdown, DropdownItem };
|
||||
|
||||
setup() {
|
||||
this.items = this.props.getItems();
|
||||
this.state = useState(this.props.getDisplay());
|
||||
this.dropdown = useDropdownState();
|
||||
this.menuRef = useChildRef();
|
||||
useDropdownAutoVisibility(this.env.overlayState, this.menuRef);
|
||||
this.iframeContentRef = useRef("iframeContent");
|
||||
this.debouncedCustomFontSizeInput = useDebounced(this.onCustomFontSizeInput, 200);
|
||||
|
||||
onMounted(() => {
|
||||
const iframeEl = this.iframeContentRef.el;
|
||||
|
||||
const initFontSizeInput = () => {
|
||||
const iframeDoc = iframeEl.contentWindow.document;
|
||||
|
||||
// Skip if already initialized.
|
||||
if (this.fontSizeInput || !iframeDoc.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fontSizeInput = iframeDoc.createElement("input");
|
||||
const isDarkMode = cookie.get("color_scheme") === "dark";
|
||||
const htmlStyle = getHtmlStyle(document);
|
||||
const backgroundColor = getCSSVariableValue(
|
||||
isDarkMode ? "gray-200" : "white",
|
||||
htmlStyle
|
||||
);
|
||||
const color = getCSSVariableValue("black", htmlStyle);
|
||||
Object.assign(iframeDoc.body.style, {
|
||||
padding: "0",
|
||||
margin: "0",
|
||||
});
|
||||
Object.assign(this.fontSizeInput.style, {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
textAlign: "center",
|
||||
backgroundColor: backgroundColor,
|
||||
color: color,
|
||||
});
|
||||
this.fontSizeInput.type = "text";
|
||||
this.fontSizeInput.name = "font-size-input";
|
||||
this.fontSizeInput.autocomplete = "off";
|
||||
this.fontSizeInput.value = this.state.displayName;
|
||||
iframeDoc.body.appendChild(this.fontSizeInput);
|
||||
this.fontSizeInput.addEventListener("click", () => {
|
||||
if (!this.dropdown.isOpen) {
|
||||
this.dropdown.open();
|
||||
}
|
||||
});
|
||||
this.fontSizeInput.addEventListener("input", this.debouncedCustomFontSizeInput);
|
||||
this.fontSizeInput.addEventListener(
|
||||
"keydown",
|
||||
this.onKeyDownFontSizeInput.bind(this)
|
||||
);
|
||||
};
|
||||
if (iframeEl.contentDocument.readyState === "complete") {
|
||||
initFontSizeInput();
|
||||
} else {
|
||||
// in firefox, iframe is not immediately available. we need to wait
|
||||
// for it to be ready before mounting.
|
||||
iframeEl.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
initFontSizeInput();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.fontSizeInput) {
|
||||
// Update `fontSizeInputValue` whenever the font size changes.
|
||||
this.fontSizeInput.value = this.state.displayName;
|
||||
}
|
||||
},
|
||||
() => [this.state.displayName]
|
||||
);
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.fontSizeInput) {
|
||||
// Focus input on dropdown open, blur on close.
|
||||
if (this.dropdown.isOpen) {
|
||||
this.fontSizeInput.select();
|
||||
} else if (
|
||||
this.iframeContentRef.el?.contains(this.props.document.activeElement)
|
||||
) {
|
||||
this.fontSizeInput.blur();
|
||||
this.props.onBlur?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
() => [this.dropdown.isOpen]
|
||||
);
|
||||
}
|
||||
|
||||
onCustomFontSizeInput(ev) {
|
||||
let fontSize = parseInt(ev.target.value, 10);
|
||||
if (fontSize > 0) {
|
||||
fontSize = Math.min(fontSize, MAX_FONT_SIZE);
|
||||
if (this.state.displayName !== fontSize) {
|
||||
this.props.onFontSizeInput(`${fontSize}px`);
|
||||
} else {
|
||||
// Reset input if state.displayName does not change.
|
||||
this.fontSizeInput.value = this.state.displayName;
|
||||
}
|
||||
}
|
||||
this.fontSizeInput.focus();
|
||||
}
|
||||
|
||||
onKeyDownFontSizeInput(ev) {
|
||||
if (["Enter", "Tab"].includes(ev.key) && this.dropdown.isOpen) {
|
||||
this.dropdown.close();
|
||||
} else if (["ArrowUp", "ArrowDown"].includes(ev.key)) {
|
||||
const fontSizeSelectorMenu = document.querySelector(".o_font_size_selector_menu div");
|
||||
if (!fontSizeSelectorMenu) {
|
||||
return;
|
||||
}
|
||||
ev.target.blur();
|
||||
const fontSizeMenuItemToFocus =
|
||||
ev.key === "ArrowUp"
|
||||
? fontSizeSelectorMenu.lastElementChild
|
||||
: fontSizeSelectorMenu.firstElementChild;
|
||||
if (fontSizeMenuItemToFocus) {
|
||||
fontSizeMenuItemToFocus.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSelected(item) {
|
||||
this.props.onSelected(item);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.o_font_size_selector_menu {
|
||||
--dropdown-min-width: none;
|
||||
}
|
||||
.o-we-font-size-item-bg {
|
||||
background-color: $gray-300;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.FontSizeSelector">
|
||||
<Dropdown state="dropdown" menuClass="'o_font_size_selector_menu'" menuRef="menuRef">
|
||||
<button class="btn btn-light" t-att-title="props.title" name="font_size_selector" t-att-disabled="props.isDisabled">
|
||||
<iframe t-ref="iframeContent" style="width: 4ch; height:100%;"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div data-prevent-closing-overlay="true">
|
||||
<t t-foreach="items" t-as="item" t-key="item_index">
|
||||
<DropdownItem onSelected="() => this.onSelected(item)" t-on-pointerdown.prevent="() => {}">
|
||||
<t t-out="item.name"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
import { Component, onWillUpdateProps, useState, useRef } from "@odoo/owl";
|
||||
import { CustomColorPicker as ColorPicker } from "@web/core/color_picker/custom_color_picker/custom_color_picker";
|
||||
import {
|
||||
isColorGradient,
|
||||
standardizeGradient,
|
||||
rgbaToHex,
|
||||
convertCSSColorToRgba,
|
||||
} from "@web/core/utils/colors";
|
||||
|
||||
export class GradientPicker extends Component {
|
||||
static components = { ColorPicker };
|
||||
static template = "html_editor.GradientPicker";
|
||||
static props = {
|
||||
onGradientChange: { type: Function, optional: true },
|
||||
onGradientPreview: { type: Function, optional: true },
|
||||
setOnCloseCallback: { type: Function, optional: true },
|
||||
setOperationCallbacks: { type: Function, optional: true },
|
||||
selectedGradient: { type: String, optional: true },
|
||||
noTransparency: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
type: "linear",
|
||||
angle: 135,
|
||||
currentColorIndex: 0,
|
||||
size: "closest-side",
|
||||
});
|
||||
this.positions = useState({ x: 25, y: 25 });
|
||||
this.colors = useState([
|
||||
{ hex: "#DF7CC4", percentage: 0 },
|
||||
{ hex: "#6C3582", percentage: 100 },
|
||||
]);
|
||||
this.cssGradients = useState({ preview: "", linear: "", radial: "", sliderThumbStyle: "" });
|
||||
this.knobRef = useRef("gradientAngleKnob");
|
||||
|
||||
if (this.props.selectedGradient && isColorGradient(this.props.selectedGradient)) {
|
||||
// initialization of the gradient with the selected value
|
||||
this.setGradientFromString(this.props.selectedGradient);
|
||||
} else {
|
||||
// initialization of the gradient with default value
|
||||
this.onColorGradientChange();
|
||||
}
|
||||
|
||||
onWillUpdateProps((newProps) => {
|
||||
if (newProps.selectedGradient) {
|
||||
this.setGradientFromString(newProps.selectedGradient);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setGradientFromString(gradient) {
|
||||
if (!gradient || !isColorGradient(gradient)) {
|
||||
return;
|
||||
}
|
||||
gradient = standardizeGradient(gradient);
|
||||
const colors = [
|
||||
...gradient.matchAll(
|
||||
/(#[0-9a-f]{6}|rgba?\(\s*[0-9]+\s*,\s*[0-9]+\s*,\s*[0-9]+\s*[,\s*[0-9.]*]?\s*\)|[a-z]+)\s*([[0-9]+%]?)/g
|
||||
),
|
||||
].filter((color) => rgbaToHex(color[1]) !== "#");
|
||||
|
||||
this.colors.splice(0, this.colors.length);
|
||||
for (const color of colors) {
|
||||
this.colors.push({ hex: rgbaToHex(color[1]), percentage: color[2].replace("%", "") });
|
||||
}
|
||||
|
||||
const isLinear = gradient.startsWith("linear-gradient(");
|
||||
if (isLinear) {
|
||||
const angle = gradient.match(/(-?[0-9]+)deg/);
|
||||
if (angle) {
|
||||
this.state.angle = parseInt(angle[1]);
|
||||
}
|
||||
} else {
|
||||
this.state.type = "radial";
|
||||
const sizeMatch = gradient.match(/(closest|farthest)-(side|corner)/);
|
||||
const size = sizeMatch ? sizeMatch[0] : "farthest-corner";
|
||||
this.state.size = size;
|
||||
|
||||
const position = gradient.match(/ at ([0-9]+)% ([0-9]+)%/) || ["", "50", "50"];
|
||||
this.positions.x = position[1];
|
||||
this.positions.y = position[2];
|
||||
}
|
||||
|
||||
this.updateCssGradients();
|
||||
}
|
||||
|
||||
selectType(type) {
|
||||
this.state.type = type;
|
||||
this.onColorGradientChange();
|
||||
}
|
||||
|
||||
onAngleChange(ev) {
|
||||
const angle = parseInt(ev.target.value);
|
||||
if (!isNaN(angle)) {
|
||||
const clampedAngle = Math.min(Math.max(angle, 0), 360);
|
||||
ev.target.value = clampedAngle;
|
||||
this.state.angle = clampedAngle;
|
||||
this.onColorGradientChange();
|
||||
}
|
||||
}
|
||||
|
||||
onPositionChange(position, ev) {
|
||||
const inputValue = parseFloat(ev.target.value);
|
||||
if (!isNaN(inputValue)) {
|
||||
const clampedValue = Math.min(Math.max(inputValue, 0), 100);
|
||||
ev.target.value = clampedValue;
|
||||
this.positions[position] = clampedValue;
|
||||
this.onColorGradientChange();
|
||||
}
|
||||
}
|
||||
|
||||
onColorChange(color) {
|
||||
const hex = rgbaToHex(color.cssColor);
|
||||
this.colors[this.state.currentColorIndex].hex = hex;
|
||||
this.onColorGradientChange();
|
||||
}
|
||||
|
||||
onColorPreview(color) {
|
||||
const hex = rgbaToHex(color.cssColor);
|
||||
this.colors[this.state.currentColorIndex].hex = hex;
|
||||
this.onColorGradientPreview();
|
||||
}
|
||||
|
||||
onSizeChange(size) {
|
||||
this.state.size = size;
|
||||
this.onColorGradientChange();
|
||||
}
|
||||
|
||||
onColorPercentageChange(colorIndex, ev) {
|
||||
this.state.currentColorIndex = colorIndex;
|
||||
this.colors[colorIndex].percentage = ev.target.value;
|
||||
this.sortColors();
|
||||
this.onColorGradientChange();
|
||||
}
|
||||
|
||||
onGradientPreviewClick(ev) {
|
||||
const width = parseInt(window.getComputedStyle(ev.target).width, 10);
|
||||
const percentage = Math.round((100 * ev.offsetX) / width);
|
||||
this.addColorStop(percentage);
|
||||
}
|
||||
|
||||
addColorStop(percentage) {
|
||||
let color;
|
||||
|
||||
let previousColor = this.colors.findLast((color) => color.percentage <= percentage);
|
||||
let nextColor = this.colors.find((color) => color.percentage > percentage);
|
||||
if (!previousColor && nextColor) {
|
||||
// Click position is before the first color
|
||||
color = nextColor.hex;
|
||||
} else if (!nextColor && previousColor) {
|
||||
// Click position is after the last color
|
||||
color = previousColor.hex;
|
||||
} else if (nextColor && previousColor) {
|
||||
const previousRatio =
|
||||
(nextColor.percentage - percentage) /
|
||||
(nextColor.percentage - previousColor.percentage);
|
||||
const nextRatio = 1 - previousRatio;
|
||||
|
||||
previousColor = convertCSSColorToRgba(previousColor.hex);
|
||||
nextColor = convertCSSColorToRgba(nextColor.hex);
|
||||
|
||||
const red = Math.round(previousRatio * previousColor.red + nextRatio * nextColor.red);
|
||||
const green = Math.round(
|
||||
previousRatio * previousColor.green + nextRatio * nextColor.green
|
||||
);
|
||||
const blue = Math.round(
|
||||
previousRatio * previousColor.blue + nextRatio * nextColor.blue
|
||||
);
|
||||
const opacity = Math.round(
|
||||
previousRatio * previousColor.opacity + nextRatio * nextColor.opacity
|
||||
);
|
||||
color = `rgba(${red}, ${green}, ${blue}, ${opacity / 100})`;
|
||||
}
|
||||
|
||||
this.colors.push({ hex: color, percentage });
|
||||
this.sortColors();
|
||||
this.state.currentColorIndex = this.colors.findIndex(
|
||||
(color) => color.percentage === percentage
|
||||
);
|
||||
this.onColorGradientChange();
|
||||
}
|
||||
|
||||
removeColor(colorIndex) {
|
||||
if (this.colors.length <= 2) {
|
||||
return;
|
||||
}
|
||||
this.colors.splice(colorIndex, 1);
|
||||
this.state.currentColorIndex = 0;
|
||||
this.onColorGradientChange();
|
||||
}
|
||||
|
||||
sortColors() {
|
||||
this.colors = this.colors.sort((a, b) => a.percentage - b.percentage);
|
||||
}
|
||||
|
||||
updateCssGradients() {
|
||||
const gradientColors = this.colors
|
||||
.map((color) => `${color.hex} ${color.percentage}%`)
|
||||
.join(", ");
|
||||
let sliderThumbStyle = "";
|
||||
// color the slider thumb with the color of the gradient
|
||||
for (let i = 0; i < this.colors.length; i++) {
|
||||
const selector = `.gradient-colors div:nth-child(${i + 1}) input[type="range"]`;
|
||||
const style = `background-color: ${this.colors[i].hex};`;
|
||||
sliderThumbStyle += `${selector}::-webkit-slider-thumb { ${style} }\n`;
|
||||
sliderThumbStyle += `${selector}::-moz-range-thumb { ${style} }\n`;
|
||||
}
|
||||
|
||||
this.cssGradients.preview = `linear-gradient(90deg, ${gradientColors})`;
|
||||
this.cssGradients.linear = `linear-gradient(${this.state.angle}deg, ${gradientColors})`;
|
||||
this.cssGradients.radial = `radial-gradient(circle ${this.state.size} at ${this.positions.x}% ${this.positions.y}%, ${gradientColors})`;
|
||||
this.cssGradients.sliderThumbStyle = sliderThumbStyle;
|
||||
}
|
||||
|
||||
onColorGradientChange() {
|
||||
this.updateCssGradients();
|
||||
this.props?.onGradientChange(this.cssGradients[this.state.type]);
|
||||
}
|
||||
|
||||
onColorGradientPreview() {
|
||||
this.updateCssGradients();
|
||||
this.props.onGradientPreview?.({ gradient: this.cssGradients[this.state.type] });
|
||||
}
|
||||
|
||||
get currentColorHex() {
|
||||
return this.colors?.[this.state.currentColorIndex]?.hex || "#000000";
|
||||
}
|
||||
|
||||
onKnobMouseDown(ev) {
|
||||
const knobEl = this.knobRef.el;
|
||||
if (!knobEl) {
|
||||
return;
|
||||
}
|
||||
const knobRadius = knobEl.offsetWidth / 2;
|
||||
const knobRect = knobEl.getBoundingClientRect();
|
||||
const centerX = knobRect.left + knobRadius;
|
||||
const centerY = knobRect.top + knobRadius;
|
||||
|
||||
const updateAngle = (ev) => {
|
||||
// calculate the differences between the mouse position and the
|
||||
// center of the knob
|
||||
const distanceX = ev.clientX - centerX;
|
||||
const distanceY = ev.clientY - centerY;
|
||||
|
||||
// calculate the angle between the center and the mouse position
|
||||
const angle = Math.atan2(distanceY, distanceX) * (180 / Math.PI);
|
||||
this.state.angle = Math.round((angle + 360) % 360);
|
||||
};
|
||||
|
||||
updateAngle(ev);
|
||||
this.onColorGradientChange();
|
||||
|
||||
const onKnobMouseMove = (ev) => {
|
||||
updateAngle(ev);
|
||||
this.onColorGradientChange();
|
||||
};
|
||||
const onKnobMouseUp = () => document.removeEventListener("mousemove", onKnobMouseMove);
|
||||
|
||||
document.addEventListener("mousemove", onKnobMouseMove);
|
||||
document.addEventListener("mouseup", onKnobMouseUp, { once: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
.gradient-angle-knob {
|
||||
--radius: 15px;
|
||||
--thumb-size: calc(var(--radius) * 0.5);
|
||||
|
||||
width: calc(var(--radius) * 2);
|
||||
height: calc(var(--radius) * 2);
|
||||
cursor: grab;
|
||||
border-radius: 50%;
|
||||
border: solid 2px;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.gradient-angle-thumb {
|
||||
width: var(--thumb-size);
|
||||
height: var(--thumb-size);
|
||||
background: black;
|
||||
border-radius: 50%;
|
||||
transform: rotate(var(--angle)) translateX(calc(var(--radius) - var(--thumb-size)));
|
||||
transform-origin: center center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.o_color_gradient_input {
|
||||
font-size: 11px;
|
||||
|
||||
input {
|
||||
font-family: monospace !important;
|
||||
font-size: 12px;
|
||||
width: 5ch !important;
|
||||
padding: 0 2px !important;
|
||||
background-color: transparent;
|
||||
border: 1px solid !important;
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue