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:
Ernad Husremovic 2026-03-09 15:31:13 +01:00
parent 4b94f0abc5
commit f866779561
1513 changed files with 396049 additions and 358525 deletions

View file

@ -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];

View file

@ -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;
}
}
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}
}
}

View file

@ -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>

View file

@ -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);
});
}
}

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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);
}
}
}
}

View file

@ -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 &nbsp.
if (!preEl) {
// Replace consecutive spaces by alternating nbsp.
modifiedTextFragment = textFragment.replace(/( {2,})/g, (match) => {
let alternateValue = false;
return match.replace(/ /g, () => {
alternateValue = !alternateValue;
const replaceContent = alternateValue ? "\u00A0" : " ";
return replaceContent;
});
});
}
this.dependencies.dom.insert(modifiedTextFragment);
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);
}
});
}

View file

@ -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();
}
}
}
}

View file

@ -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

View file

@ -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,
});
});
}
}

View file

@ -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");
}
}
}
}

View file

@ -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;
}
}
}

View file

@ -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

View file

@ -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);
}
}

View file

@ -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();
}
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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");
}
}

View file

@ -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");
});
}
}

View file

@ -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

View file

@ -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)),
})
);
}
}

View file

@ -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();
}
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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]
);
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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 };

View file

@ -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;
}
}

View file

@ -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=&#34;props.type === 'text'&#34;]" 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>

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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>

View file

@ -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);

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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"]);
}
}

View file

@ -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");

View file

@ -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();
}
}

View file

@ -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");
}
}
}

View file

@ -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

View file

@ -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");
}
}

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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>

View file

@ -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;
}

View file

@ -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();
},
});
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -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;
});
}
}

View file

@ -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>

View file

@ -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();
});
}
}
}

View file

@ -0,0 +1,3 @@
.oe-language-icon {
fill: #fff;
}

View file

@ -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);
}
}

View file

@ -0,0 +1,3 @@
.oe-language-icon {
fill: #000;
}

View file

@ -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>

View file

@ -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);
}
}
}

View file

@ -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,
});
}
}

View file

@ -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;
}

View file

@ -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 }
);

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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}`;
}
}

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -0,0 +1,3 @@
.o_font_selector_menu {
--dropdown-min-width: none;
}

View file

@ -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>

View file

@ -0,0 +1,3 @@
.o-we-font-size-item-bg {
background-color: $gray-200;
}

View file

@ -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);
}
}

View file

@ -0,0 +1,6 @@
.o_font_size_selector_menu {
--dropdown-min-width: none;
}
.o-we-font-size-item-bg {
background-color: $gray-300;
}

View file

@ -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>

View file

@ -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 });
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,140 @@
<templates xml:space="preserve">
<t t-name="html_editor.GradientPicker">
<div class="d-flex flex-column">
<div class="align-items-center d-flex justify-content-between my-2 o_type_row">
Type
<span class="d-flex justify-content-between">
<button t-attf-class="btn btn-sm btn-light {{ this.state.type === 'linear' ? 'active' : ''}}" t-on-click="() => this.selectType('linear')"> Linear </button>
<button t-attf-class="btn btn-sm btn-light {{ this.state.type === 'radial' ? 'active' : ''}}" t-on-click="() => this.selectType('radial')"> Radial </button>
</span>
</div>
<div t-if="this.state.type === 'linear'" class="d-flex align-items-center justify-content-between mb-1">
Angle
<span class="d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-center align-items-center my-auto me-2 position-relative gradient-angle-knob"
t-ref="gradientAngleKnob"
t-attf-style="--angle: #{this.state.angle}deg;"
t-on-mousedown="onKnobMouseDown">
<div class="position-absolute gradient-angle-thumb"></div>
</div>
<div class="o_color_gradient_input d-flex align-items-center">
<input
name="angle"
type="number"
min="0"
max="360"
t-att-value="this.state.angle"
t-on-change="onAngleChange"
/>
<label class="flex-grow-0 ms-1 mb-0">deg</label>
</div>
</span>
</div>
<div t-else="" class="d-flex flex-column gap-1 mb-1">
Size
<div class="d-flex align-items-center justify-content-between">
<button t-on-click="() => this.onSizeChange('closest-side')" t-attf-class="btn btn-light p-0 {{ this.state.size === 'closest-side' ? `active` : ''}}" title="Extend to the closest side">
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="45" viewBox="0 0 16 20" fill="none">
<rect x="1.5" y="3.5" width="13" height="13" stroke="#AAAAAD"></rect>
<path d="M3 4H9V8C9 8.55228 8.55228 9 8 9H4C3.44772 9 3 8.55228 3 8V4Z" fill="#8C8C92"></path>
<path d="M6 11C7.65685 11 9 9.65685 9 8C9 6.34315 7.65685 5 6 5C4.34315 5 3 6.34315 3 8C3 9.65685 4.34315 11 6 11Z" fill="#74747B"></path>
<path d="M6 10C7.10457 10 8 9.10457 8 8C8 6.89543 7.10457 6 6 6C4.89543 6 4 6.89543 4 8C4 9.10457 4.89543 10 6 10Z" fill="white"></path>
<rect x="2" y="3" width="12" height="1" fill="white"></rect>
</svg>
</button>
<button t-on-click="() => this.onSizeChange('closest-corner')" t-attf-class="btn btn-light p-0 {{ this.state.size === 'closest-corner' ? `active` : ''}}" title="Extend to the closest corner">
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="45" viewBox="0 0 16 20" fill="none">
<rect x="1.5" y="3.5" width="13" height="13" stroke="#AAAAAD"></rect>
<path d="M2 4H9V7C9 9.20914 7.20914 11 5 11H2V4Z" fill="#8C8C92"></path>
<path d="M6 11C7.65685 11 9 9.65685 9 8C9 6.34315 7.65685 5 6 5C4.34315 5 3 6.34315 3 8C3 9.65685 4.34315 11 6 11Z" fill="#74747B"></path>
<path d="M6 10C7.10457 10 8 9.10457 8 8C8 6.89543 7.10457 6 6 6C4.89543 6 4 6.89543 4 8C4 9.10457 4.89543 10 6 10Z" fill="white"></path>
<rect x="1" y="3" width="8" height="1" fill="white"></rect>
<rect x="1" y="11" width="8" height="1" transform="rotate(-90 1 11)" fill="white"></rect>
</svg>
</button>
<button t-on-click="() => this.onSizeChange('farthest-side')" t-attf-class="btn btn-light p-0 {{ this.state.size === 'farthest-side' ? `active` : ''}}" title="Extend to the farthest side">
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="45" viewBox="0 0 16 20" fill="none">
<rect x="1.5" y="3.5" width="13" height="13" stroke="#AAAAAD"></rect>
<path d="M7 12C10.3137 12 14 10.2091 14 8C14 5.79086 10.3137 4 7 4C3.68629 4 2 5.79086 2 8C2 10.2091 3.68629 12 7 12Z" fill="#8C8C92"></path>
<path d="M6 11C7.65685 11 9 9.65685 9 8C9 6.34315 7.65685 5 6 5C4.34315 5 3 6.34315 3 8C3 9.65685 4.34315 11 6 11Z" fill="#74747B"></path>
<path d="M6 10C7.10457 10 8 9.10457 8 8C8 6.89543 7.10457 6 6 6C4.89543 6 4 6.89543 4 8C4 9.10457 4.89543 10 6 10Z" fill="white"></path>
<rect x="14" y="4" width="1" height="12" fill="white"></rect>
</svg>
</button>
<button t-on-click="() => this.onSizeChange('farthest-corner')" t-attf-class="btn btn-light p-0 {{ this.state.size === 'farthest-corner' ? `active` : ''}}" title="Extend to the farthest corner">
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="45" viewBox="0 0 16 20" fill="none">
<rect x="1.5" y="3.5" width="13" height="13" stroke="#AAAAAD"></rect>
<path d="M2 4H14V10C14 13.3137 11.3137 16 8 16H2V4Z" fill="#8C8C92"></path>
<path d="M6 11C7.65685 11 9 9.65685 9 8C9 6.34315 7.65685 5 6 5C4.34315 5 3 6.34315 3 8C3 9.65685 4.34315 11 6 11Z" fill="#74747B"></path>
<path d="M6 10C7.10457 10 8 9.10457 8 8C8 6.89543 7.10457 6 6 6C4.89543 6 4 6.89543 4 8C4 9.10457 4.89543 10 6 10Z" fill="white"></path>
<rect x="15" y="17" width="7" height="0.999999" transform="rotate(-180 15 17)" fill="white"></rect>
<rect x="15" y="10" width="7" height="1" transform="rotate(90 15 10)" fill="white"></rect>
</svg>
</button>
</div>
<div class="d-flex align-items-center justify-content-between">
Position X
<span class="o_color_gradient_input">
<input
name="positionX"
type="number"
min="0"
max="100"
t-att-value="this.positions['x']"
t-on-change="(ev) => this.onPositionChange('x', ev)"
/>
</span>
</div>
<div class="d-flex align-items-center justify-content-between">
Position Y
<span class="o_color_gradient_input">
<input
name="positionY"
type="number"
min="0"
max="100"
t-att-value="this.positions['y']"
t-on-change="(ev) => this.onPositionChange('y', ev)"
/>
</span>
</div>
</div>
<div class="custom-gradient-configurator">
<div class="gradient-checkers"></div>
<div class="gradient-preview" t-attf-style="background-image: #{this.cssGradients.preview};"
t-on-click="this.onGradientPreviewClick" >
</div>
<style><t t-out="this.cssGradients.sliderThumbStyle" /></style>
<div class="gradient-colors">
<div t-foreach="this.colors" t-as="color" t-key="color_index">
<input type="range" min="0" max="100" t-att-value="color.percentage"
t-att-class="{'active': this.state.currentColorIndex === color_index}"
t-attf-name="custom gradient percentage color #{color_index+1}"
t-on-click="() => this.state.currentColorIndex = color_index"
t-on-change="(ev) => this.onColorPercentageChange(color_index, ev)" />
</div>
</div>
<div class="gradient-color-bin">
<t t-if="this.colors.length > 2">
<a t-on-click="() => this.removeColor(this.state.currentColorIndex)"
t-attf-style="left: #{this.colors[this.state.currentColorIndex].percentage}%"
type="button" class="btn btn-sm btn-light"><i class="fa fa-fw fa-trash" role="img"/></a>
</t>
</div>
</div>
<ColorPicker
onColorSelect.bind="onColorChange"
onColorPreview.bind="onColorPreview"
setOnCloseCallback.bind="props.setOnCloseCallback"
setOperationCallbacks.bind="props.setOperationCallbacks"
defaultColor="this.currentColorHex"
selectedColor="this.currentColorHex"
noTransparency="props.noTransparency"
/>
</div>
</t>
</templates>

View file

@ -0,0 +1,22 @@
.o-we-hint {
position: relative;
&:after {
content: attr(o-we-hint-text);
position: absolute;
top: 0;
left: 0;
display: block;
color: inherit;
opacity: 0.4;
pointer-events: none;
text-align: inherit;
width: 100%;
}
}
div[class*="col-"][class*="o-we-hint"] {
&:after {
left: calc(var(--gutter-x, 0)* .5);
}
}

View file

@ -0,0 +1,87 @@
import { Plugin } from "@html_editor/plugin";
import { isEmptyBlock, isProtected } from "@html_editor/utils/dom_info";
import { removeClass } from "@html_editor/utils/dom";
import { selectElements } from "@html_editor/utils/dom_traversal";
import { closestBlock } from "../utils/blocks";
export class HintPlugin extends Plugin {
static id = "hint";
static dependencies = ["history", "selection"];
resources = {
/** Handlers */
selectionchange_handlers: this.updateHints.bind(this),
external_history_step_handlers: () => {
this.clearHints();
this.updateHints();
},
normalize_handlers: this.normalize.bind(this),
clean_for_save_handlers: ({ root }) => this.clearHints(root),
content_updated_handlers: this.updateHints.bind(this),
hint_targets_providers: (selectionData, editable) => {
if (!selectionData.currentSelectionIsInEditable || !selectionData.documentSelection) {
return [];
}
const blockEl = closestBlock(selectionData.documentSelection.anchorNode);
if (this.dependencies.selection.isNodeEditable(blockEl)) {
return [blockEl];
} else {
return [];
}
},
system_classes: ["o-we-hint"],
system_attributes: ["o-we-hint-text"],
};
setup() {
this.updateHints(this.editable);
}
destroy() {
super.destroy();
this.clearHints();
}
normalize() {
this.clearHints();
this.updateHints();
}
/**
* @param {HTMLElement} [root]
*/
updateHints() {
const selectionData = this.dependencies.selection.getSelectionData();
const editableSelection = selectionData.editableSelection;
this.clearHints();
if (editableSelection.isCollapsed) {
const hints = this.getResource("hints");
for (const provideTargets of this.getResource("hint_targets_providers")) {
for (const target of provideTargets(selectionData, this.editable)) {
const nodeHint = hints.find((h) => target.matches(h.selector))?.text;
if (target && nodeHint && isEmptyBlock(target) && !isProtected(target)) {
this.makeHint(target, nodeHint);
}
}
}
}
}
makeHint(el, text) {
this.dispatchTo("make_hint_handlers", el);
el.setAttribute("o-we-hint-text", text);
el.classList.add("o-we-hint");
}
removeHint(el) {
el.removeAttribute("o-we-hint-text");
removeClass(el, "o-we-hint");
this.getResource("system_style_properties").forEach((n) => el.style.removeProperty(n));
}
clearHints(root = this.editable) {
for (const elem of selectElements(root, ".o-we-hint")) {
this.removeHint(elem);
}
}
}

View file

@ -0,0 +1,101 @@
import { Plugin } from "@html_editor/plugin";
import { splitTextNode } from "@html_editor/utils/dom";
import { closestElement } from "@html_editor/utils/dom_traversal";
import { DIRECTIONS } from "@html_editor/utils/position";
export class InlineCodePlugin extends Plugin {
static id = "inlineCode";
static dependencies = ["selection", "history", "input"];
resources = {
input_handlers: this.onInput.bind(this),
};
onInput(ev) {
const selection = this.dependencies.selection.getEditableSelection();
if (ev.data !== "`" || closestElement(selection.anchorNode, "code")) {
return;
}
// We just inserted a backtick, check if there was another
// one in the text.
let textNode = selection.startContainer;
let offset = selection.startOffset;
let sibling = textNode.previousSibling;
while (sibling && sibling.nodeType === Node.TEXT_NODE) {
offset += sibling.textContent.length;
sibling.textContent += textNode.textContent;
textNode.remove();
textNode = sibling;
sibling = textNode.previousSibling;
}
sibling = textNode.nextSibling;
while (sibling && sibling.nodeType === Node.TEXT_NODE) {
textNode.textContent += sibling.textContent;
sibling.remove();
sibling = textNode.nextSibling;
}
const textHasTwoTicks = /`.*`/.test(textNode.textContent);
// We don't apply the code tag if there is no content between the two `
if (textHasTwoTicks && textNode.textContent.replace(/`/g, "").length) {
this.dependencies.selection.setSelection({
anchorNode: textNode,
anchorOffset: offset,
});
this.dependencies.history.addStep();
const insertedBacktickIndex = offset - 1;
const textBeforeInsertedBacktick = textNode.textContent.substring(
0,
insertedBacktickIndex - 1
);
let startOffset, endOffset;
const isClosingForward = textBeforeInsertedBacktick.includes("`");
if (isClosingForward) {
// There is a backtick before the new backtick.
startOffset = textBeforeInsertedBacktick.lastIndexOf("`");
endOffset = insertedBacktickIndex;
} else {
// There is a backtick after the new backtick.
const textAfterInsertedBacktick = textNode.textContent.substring(offset);
startOffset = insertedBacktickIndex;
endOffset = offset + textAfterInsertedBacktick.indexOf("`");
}
// Split around the backticks if needed so text starts
// and ends with a backtick.
if (endOffset && endOffset < textNode.textContent.length) {
splitTextNode(textNode, endOffset + 1, DIRECTIONS.LEFT);
}
if (startOffset) {
splitTextNode(textNode, startOffset);
}
// Remove ticks.
textNode.textContent = textNode.textContent.substring(
1,
textNode.textContent.length - 1
);
// Insert code element.
const codeElement = this.document.createElement("code");
codeElement.classList.add("o_inline_code");
textNode.before(codeElement);
codeElement.append(textNode);
if (
!codeElement.previousSibling ||
codeElement.previousSibling.nodeType !== Node.TEXT_NODE
) {
codeElement.before(document.createTextNode("\u200B"));
}
if (isClosingForward) {
// Move selection out of code element.
codeElement.after(document.createTextNode("\u200B"));
this.dependencies.selection.setSelection({
anchorNode: codeElement.nextSibling,
anchorOffset: 1,
});
} else {
this.dependencies.selection.setSelection({
anchorNode: codeElement.firstChild,
anchorOffset: 0,
});
}
}
this.dependencies.history.addStep();
}
}

View file

@ -0,0 +1,8 @@
import { registry } from "@web/core/registry";
const commandCategoryRegistry = registry.category("command_categories");
// A shortcut conflict occurs when actions are bound to the same
// shortcut as the command palette. To avoid this, those actions can be
// added to the command palette itself within this high priority category
// so that they appear first in the results.
commandCategoryRegistry.add("shortcut_conflict", {}, { sequence: 5 });

View file

@ -0,0 +1,19 @@
.odoo-editor-editable a.o_link_in_selection:not(.btn) {
background-color: #a6e3e2;
color: black !important;
border: 1px dashed #008f8c;
margin: -1px;
}
.odoo-editor-editable {
&[contenteditable=true], [contenteditable=true] {
&.btn, .btn {
user-select: auto;
cursor: text;
}
}
[contenteditable=false] .btn {
cursor: pointer;
}
}

View file

@ -0,0 +1,116 @@
import { closestElement } from "@html_editor/utils/dom_traversal";
import { URL_REGEX, cleanZWChars } from "./utils";
import { isImageUrl } from "@html_editor/utils/url";
import { Plugin } from "@html_editor/plugin";
import { childNodeIndex } from "@html_editor/utils/position";
export class LinkPastePlugin extends Plugin {
static id = "linkPaste";
static dependencies = ["link", "clipboard", "selection", "dom", "history"];
resources = {
before_paste_handlers: this.selectFullySelectedLink.bind(this),
paste_text_overrides: this.handlePasteText.bind(this),
};
/**
* @param {EditorSelection} selection
* @param {string} text
*/
handlePasteText(selection, text) {
let splitAroundUrl;
// todo: add placeholder plugin that prevent any other plugin
// Avoid transforming dynamic placeholder pattern to url.
if (!text.match(/\${.*}/gi)) {
splitAroundUrl = text.split(URL_REGEX);
// Remove 'http(s)://' capturing group from the result (indexes
// 2, 5, 8, ...).
splitAroundUrl = splitAroundUrl.filter((_, index) => (index + 1) % 3);
}
if (
!splitAroundUrl ||
splitAroundUrl.length < 3 ||
closestElement(selection.anchorNode, "pre")
) {
// Let the default paste handle the text.
return false;
}
if (splitAroundUrl.length === 3 && !splitAroundUrl[0] && !splitAroundUrl[2]) {
// Pasted content is a single URL.
this.handlePasteTextUrl(selection, text);
} else {
this.handlePasteTextMultiUrl(selection, splitAroundUrl);
}
return true;
}
/**
* @param {EditorSelection} selection
* @param {string} text
*/
handlePasteTextUrl(selection, text) {
const selectionIsInsideALink = !!closestElement(selection.anchorNode, "a");
const url = /^https?:\/\//i.test(text) ? text : "http://" + text;
if (selectionIsInsideALink) {
this.handlePasteTextUrlInsideLink(text, url);
return;
}
if (this.delegateTo("paste_url_overrides", text, url)) {
return;
}
this.dependencies.link.insertLink(url, text);
}
/**
* @param {string} text
* @param {string} url
*/
handlePasteTextUrlInsideLink(text, url) {
// A url cannot be transformed inside an existing link.
// An image can be embedded inside an existing link, a video cannot.
if (isImageUrl(url)) {
const img = this.document.createElement("IMG");
img.setAttribute("src", url);
this.dependencies.dom.insert(img);
} else {
this.dependencies.dom.insert(text);
}
}
/**
* @param {EditorSelection} selection
* @param {string[]} splitAroundUrl
*/
handlePasteTextMultiUrl(selection, splitAroundUrl) {
const selectionIsInsideALink = !!closestElement(selection.anchorNode, "a");
for (let i = 0; i < splitAroundUrl.length; i++) {
const url = /^https?:\/\//gi.test(splitAroundUrl[i])
? splitAroundUrl[i]
: "http://" + splitAroundUrl[i];
// Even indexes will always be plain text, and odd indexes will always be URL.
// A url cannot be transformed inside an existing link.
if (i % 2 && !selectionIsInsideALink) {
this.dependencies.dom.insert(
this.dependencies.link.createLink(url, splitAroundUrl[i])
);
} else if (splitAroundUrl[i] !== "") {
this.dependencies.clipboard.pasteText(splitAroundUrl[i]);
}
}
}
/**
* @param {EditorSelection} selection
*/
selectFullySelectedLink(selection) {
const link = closestElement(selection.anchorNode, "a");
if (
link?.parentElement?.isContentEditable &&
cleanZWChars(selection.textContent()) === cleanZWChars(link.innerText) &&
!this.getResource("unremovable_node_predicates").some((p) => p(link))
) {
this.dependencies.selection.setSelection({
anchorNode: link.parentElement,
anchorOffset: childNodeIndex(link) + (selection.direction ? 0 : 1),
focusNode: link.parentElement,
focusOffset: childNodeIndex(link) + (selection.direction ? 1 : 0),
});
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,700 @@
import { session } from "@web/session";
import { _t } from "@web/core/l10n/translation";
import { Component, useState, useRef, useEffect, useExternalListener } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { browser } from "@web/core/browser/browser";
import { cleanZWChars, deduceURLfromText } from "./utils";
import { useColorPicker } from "@web/core/color_picker/color_picker";
import { CheckBox } from "@web/core/checkbox/checkbox";
const DEFAULT_CUSTOM_TEXT_COLOR = "#714B67";
const DEFAULT_CUSTOM_FILL_COLOR = "#ffffff";
const isCSSVariable = (color) => color.match(/^o-color-\d$|^\d{3}$/);
const formatColor = (color) => {
if (color.match(/^o-color-\d$/gm)) {
return `var(--hb-cp-${color})`;
}
if (color.match(/^\d{3}$/gm)) {
return `var(--${color})`;
}
return color;
};
export class LinkPopover extends Component {
static template = "html_editor.linkPopover";
static props = {
document: { validate: (p) => p.nodeType === Node.DOCUMENT_NODE },
linkElement: { validate: (el) => el.nodeType === Node.ELEMENT_NODE },
onApply: Function,
onChange: Function,
onDiscard: Function,
onRemove: Function,
onCopy: Function,
onEdit: Function,
getInternalMetaData: Function,
getExternalMetaData: Function,
getAttachmentMetadata: Function,
isImage: Boolean,
showReplaceTitleBanner: Boolean,
type: String,
LinkPopoverState: Object,
recordInfo: Object,
canEdit: { type: Boolean, optional: true },
canRemove: { type: Boolean, optional: true },
canUpload: { type: Boolean, optional: true },
onUpload: { type: Function, optional: true },
allowCustomStyle: { type: Boolean, optional: true },
allowTargetBlank: { type: Boolean, optional: true },
allowStripDomain: { type: Boolean, optional: true },
formatColor: { type: Function, optional: true },
};
static defaultProps = {
canEdit: true,
canRemove: true,
formatColor: formatColor,
};
static components = { CheckBox };
colorsData = [
{ type: "", label: _t("Link"), btnPreview: "link" },
{ type: "primary", label: _t("Button Primary"), btnPreview: "primary" },
{ type: "secondary", label: _t("Button Secondary"), btnPreview: "secondary" },
{ type: "custom", label: _t("Custom"), btnPreview: "custom" },
// Note: by compatibility the dialog should be able to remove old
// colors that were suggested like the BS status colors or the
// alpha -> epsilon classes. This is currently done by removing
// all btn-* classes anyway.
];
buttonSizesData = [
{ size: "sm", label: _t("Small") },
{ size: "", label: _t("Medium") },
{ size: "lg", label: _t("Large") },
];
borderData = [
{ style: "solid", label: "━━━" },
{ style: "dashed", label: "╌╌╌" },
{ style: "dotted", label: "┄┄┄" },
{ style: "double", label: "═══" },
];
buttonShapeData = [
{ shape: "", label: "Default" },
{ shape: "rounded-circle", label: "Default + Rounded" },
{ shape: "outline", label: "Outline" },
{ shape: "outline rounded-circle", label: "Outline + Rounded" },
{ shape: "fill", label: "Fill" },
{ shape: "fill rounded-circle", label: "Fill + Rounded" },
{ shape: "flat", label: "Flat" },
];
setup() {
this.ui = useService("ui");
this.notificationService = useService("notification");
this.uploadService = useService("uploadLocalFiles");
const linkElement = this.props.linkElement;
const textContent = cleanZWChars(linkElement.textContent);
const labelEqualsUrl =
textContent === linkElement.getAttribute("href") ||
textContent + "/" === linkElement.getAttribute("href");
const computedStyle = this.props.document.defaultView.getComputedStyle(linkElement);
const currentRelValues = linkElement.rel.split(" ");
this.state = useState({
editing: this.props.LinkPopoverState.editing,
// `.getAttribute("href")` instead of `.href` to keep relative url
url: linkElement.getAttribute("href") || this.deduceUrl(textContent),
label: labelEqualsUrl ? "" : textContent,
previewIcon: {
/** @type {'fa'|'imgSrc'|'mimetype'} */
type: "fa",
value: "fa-globe",
},
urlTitle: "",
urlDescription: "",
linkPreviewName: "",
imgSrc: "",
type:
this.props.type ||
linkElement.className.match(/btn(-[a-z0-9_-]*)(primary|secondary|custom)/)?.pop() ||
"",
linkTarget: linkElement.target === "_blank" ? "_blank" : "",
directDownload: true,
isDocument: false,
buttonSize: linkElement.className.match(/btn-(sm|lg)/)?.[1] || "",
buttonShape: this.getButtonShape(),
customBorderSize: computedStyle.borderWidth.replace("px", "") || "0",
customBorderStyle: computedStyle.borderStyle || "solid",
isImage: this.props.isImage,
showReplaceTitleBanner: this.props.showReplaceTitleBanner,
showLabel: !linkElement.childElementCount,
stripDomain: true,
showAdvancedOptions: false,
relAttributeOptions: {
nofollow: {
label: "nofollow",
description: _t("Tells search engines not to follow this link"),
isChecked: currentRelValues.includes("nofollow"),
},
noreferrer: {
label: "noreferrer",
description: _t("Removes referrer information sent to the target site"),
isChecked: currentRelValues.includes("noreferrer"),
},
sponsored: {
label: "sponsored",
description: _t("Indicates the link is sponsored or paid content"),
isChecked: currentRelValues.includes("sponsored"),
},
noopener: {
label: "noopener",
description: _t("Prevents the new page from accessing the original window (security)"),
isChecked: currentRelValues.includes("noopener"),
},
}
});
const getTargetedElements = () => [this.props.linkElement];
this.customTextColorState = useState({
selectedColor: computedStyle.color || DEFAULT_CUSTOM_TEXT_COLOR,
defaultTab: "solid",
getTargetedElements,
mode: "color",
});
this.customTextResetPreviewColor = this.customTextColorState.selectedColor;
this.customFillColorState = useState({
selectedColor:
(computedStyle.backgroundImage === "none"
? undefined
: computedStyle.backgroundImage) ||
computedStyle.backgroundColor ||
DEFAULT_CUSTOM_FILL_COLOR,
defaultTab: "solid",
getTargetedElements,
mode: "background-color",
});
this.customFillResetPreviewColor = this.customFillColorState.selectedColor;
this.customBorderColorState = useState({
selectedColor: computedStyle.borderColor || DEFAULT_CUSTOM_TEXT_COLOR,
defaultTab: "solid",
getTargetedElements,
mode: "border-color",
});
this.customBorderResetPreviewColor = this.customBorderColorState.selectedColor;
if (this.props.allowCustomStyle) {
const createCustomColorPicker = (refName, colorStateRef, resetValueRef) =>
useColorPicker(
refName,
{
state: this[colorStateRef],
enabledTabs:
colorStateRef === "customFillColorState"
? ["solid", "custom", "gradient"]
: ["solid", "custom"],
getUsedCustomColors: () => [],
colorPrefix: "",
cssVarColorPrefix: "hb-cp-",
applyColor: (colorValue) => {
this[colorStateRef].selectedColor = colorValue;
this[resetValueRef] = colorValue;
},
applyColorPreview: (colorValue) => {
this[colorStateRef].selectedColor = colorValue;
this.onChange();
},
applyColorResetPreview: () => {
this[colorStateRef].selectedColor = this[resetValueRef];
this.onChange();
},
},
{
env: this.__owl__.childEnv,
}
);
this.customTextColorPicker = createCustomColorPicker(
"customTextColorButton",
"customTextColorState",
"customTextResetPreviewColor"
);
this.customFillColorPicker = createCustomColorPicker(
"customFillColorButton",
"customFillColorState",
"customFillResetPreviewColor"
);
this.customBorderColorPicker = createCustomColorPicker(
"customBorderColorButton",
"customBorderColorState",
"customBorderResetPreviewColor"
);
}
this.updateDocumentState();
this.editingWrapper = useRef("editing-wrapper");
this.inputRef = useRef(this.state.isImage ? "url" : "label");
useEffect(
(el) => {
if (el) {
el.focus();
}
},
() => [this.inputRef.el]
);
if (!this.state.editing) {
this.loadAsyncLinkPreview();
}
const onPointerDown = (ev) => {
if (this.state.isImage) {
return;
}
this.state.url ||= "#";
if (this.editingWrapper?.el && !this.editingWrapper.el.contains(ev.target)) {
this.onClickApply();
}
};
useExternalListener(this.props.document, "pointerdown", onPointerDown);
if (this.props.document !== document) {
// Listen to pointerdown outside the iframe
useExternalListener(document, "pointerdown", onPointerDown);
}
}
toggleAdvancedOptions() {
this.state.showAdvancedOptions = !this.state.showAdvancedOptions;
}
toggleRelAttr(attr) {
const option = this.state.relAttributeOptions[attr];
option.isChecked = !option.isChecked;
}
onChange() {
// Apply changes to update the link preview.
this.props.onChange(
this.state.url,
this.state.label,
this.classes,
this.customStyles,
this.state.linkTarget,
this.state.attachmentId
);
this.updateDocumentState();
}
onClickApply() {
const relOptions = this.state.relAttributeOptions;
const relValue = Object.keys(relOptions)
.filter((key) => relOptions[key].isChecked)
.join(" ");
this.state.editing = false;
this.applyDeducedUrl();
this.props.onApply(
this.state.url,
this.state.label,
this.classes,
this.customStyles,
this.state.linkTarget,
this.state.attachmentId,
relValue,
);
}
applyDeducedUrl() {
if (this.state.label === "") {
this.state.label = this.state.url;
}
const deducedUrl = this.deduceUrl(this.state.url);
this.state.url = deducedUrl
? this.correctLink(deducedUrl)
: this.correctLink(this.state.url);
if (
this.props.allowStripDomain &&
this.state.stripDomain &&
this.isAbsoluteURLInCurrentDomain()
) {
const urlObj = new URL(this.state.url, window.location.origin);
// Not necessarily equal to window.location.origin
// (see isAbsoluteURLInCurrentDomain)
this.state.url = this.state.url.replace(urlObj.origin, "");
}
}
onClickEdit() {
this.state.editing = true;
this.props.onEdit();
this.updateUrlAndLabel();
}
updateUrlAndLabel() {
this.state.url = this.props.linkElement.getAttribute("href");
const textContent = cleanZWChars(this.props.linkElement.textContent);
const labelEqualsUrl =
textContent === this.props.linkElement.getAttribute("href") ||
textContent + "/" === this.props.linkElement.getAttribute("href");
this.state.label = labelEqualsUrl ? "" : textContent;
}
async onClickCopy(ev) {
ev.preventDefault();
await browser.navigator.clipboard.writeText(this.props.linkElement.href || "");
this.notificationService.add(_t("Link copied to clipboard."), {
type: "success",
});
this.props.onCopy();
}
onClickRemove() {
this.props.onRemove();
}
onKeydownEnter(ev) {
const isAutoCompleteDropdownOpen = document.querySelector(".o-autocomplete--dropdown-menu");
if (ev.key === "Enter" && !isAutoCompleteDropdownOpen && this.state.url) {
ev.preventDefault();
this.onClickApply();
}
}
onKeydown(ev) {
if (ev.key === "Escape") {
ev.preventDefault();
ev.stopImmediatePropagation();
this.onClickApply();
}
}
onInput() {
this.onChange();
}
onClickReplaceTitle() {
this.state.label = this.state.urlTitle;
this.onClickApply();
}
onClickDirectDownload(checked) {
this.state.directDownload = checked;
this.state.url = this.state.url.replace("&download=true", "");
if (this.state.directDownload) {
this.state.url += "&download=true";
}
}
onClickNewWindow(checked) {
this.state.linkTarget = checked ? "_blank" : "";
if (!checked) {
this.state.relAttributeOptions.noopener.isChecked = false;
}
}
onClickStripDomain(checked) {
this.state.stripDomain = checked;
}
/**
* @private
*/
async updateDocumentState() {
const url = this.state.url;
const urlObject = URL.parse(url, this.props.document.URL);
if (
url &&
(url.startsWith("/web/content/") ||
(urlObject &&
urlObject.pathname.startsWith("/web/content") &&
urlObject.host === document.location.host))
) {
const { type } = await this.props.getAttachmentMetadata(url);
this.state.isDocument = type !== "url";
this.state.directDownload = url.includes("&download=true");
} else {
this.state.isDocument = false;
this.state.directDownload = true;
}
}
correctLink(url) {
if (
url &&
!url.startsWith("tel:") &&
!url.startsWith("mailto:") &&
!url.includes("://") &&
!url.startsWith("/") &&
!url.startsWith("#") &&
!url.startsWith("${")
) {
url = "https://" + url;
}
if (url && (url.startsWith("http:") || url.startsWith("https:"))) {
url = URL.parse(url) ? url : "";
}
return url;
}
deduceUrl(text) {
text = text.trim();
if (/^(https?:|mailto:|tel:)/.test(text)) {
// Text begins with a known protocol, accept it as valid URL.
return text;
} else {
return deduceURLfromText(text, this.props.linkElement) || "";
}
}
getButtonShape() {
const shapeToRegex = (shape) => {
const parts = shape.trim().split(/\s+/);
const regexParts = parts.map((cls) => {
if (["outline", "fill"].includes(cls)) {
cls = `btn-${cls}`;
}
return `(?=.*\\b${cls}\\b)`;
});
return { regex: new RegExp(regexParts.join("")), nbParts: parts.length };
};
// If multiple shapes match, prefer the one with more specificity.
let shapeMatched = "";
let matchScore = 0;
for (const { shape } of this.buttonShapeData) {
if (!shape) {
continue;
}
const { regex, nbParts } = shapeToRegex(shape);
if (regex.test(this.props.linkElement.className)) {
if (matchScore < nbParts) {
matchScore = nbParts;
shapeMatched = shape;
}
}
}
return shapeMatched;
}
/**
* link preview in the popover
*/
resetPreview() {
this.state.previewIcon = { type: "fa", value: "fa-globe" };
this.state.urlTitle = this.state.url || _t("No URL specified");
this.state.urlDescription = "";
this.state.linkPreviewName = "";
}
async loadAsyncLinkPreview() {
let url;
if (this.state.url === "") {
this.resetPreview();
this.state.previewIcon.value = "fa-question-circle-o";
return;
}
if (this.isLogoutUrl()) {
// The session ends if we fetch this url, so the preview is hardcoded
this.resetPreview();
this.state.urlTitle = _t("Logout");
this.state.previewIcon.value = "fa-sign-out";
return;
}
if (this.isAttachmentUrl()) {
const { name, mimetype } = await this.props.getAttachmentMetadata(this.state.url);
this.resetPreview();
this.state.urlTitle = name;
this.state.previewIcon = { type: "mimetype", value: mimetype };
return;
}
try {
url = new URL(this.state.url, this.props.document.URL); // relative to absolute
} catch {
// Invalid URL, might happen with editor unsuported protocol. eg type
// `geo:37.786971,-122.399677`, become `http://geo:37.786971,-122.399677`
this.notificationService.add(_t("This URL is invalid. Preview couldn't be updated."), {
type: "danger",
});
return;
}
this.resetPreview();
const protocol = url.protocol;
if (!protocol.startsWith("http")) {
const faMap = { "mailto:": "fa-envelope-o", "tel:": "fa-phone" };
const icon = faMap[protocol];
if (icon) {
this.state.previewIcon.value = icon;
}
} else if (
window.location.hostname !== url.hostname &&
!new RegExp(`^https?://${session.db}\\.odoo\\.com(/.*)?$`).test(url.origin)
) {
// Preview pages from current website only. External website will
// most of the time raise a CORS error. To avoid that error, we
// would need to fetch the page through the server (s2s), involving
// enduser fetching problematic pages such as illicit content.
this.state.previewIcon = {
type: "imgSrc",
value: `https://www.google.com/s2/favicons?sz=16&domain=${encodeURIComponent(url)}`,
};
const externalMetadata = await this.props.getExternalMetaData(this.state.url);
this.state.urlTitle = externalMetadata?.og_title || this.state.url;
this.state.urlDescription = externalMetadata?.og_description || "";
this.state.imgSrc = externalMetadata?.og_image || "";
if (
externalMetadata?.og_image &&
this.state.label &&
this.state.urlTitle === this.state.url
) {
this.state.urlTitle = this.state.label;
}
} else {
// Set state based on cached link meta data
// for record missing errors, we push a warning that the url is likely invalid
// for other errors, we log them to not block the ui
const internalMetadata = await this.props
.getInternalMetaData(url.href)
.catch((error) => {
console.warn(`Error fetching internal metadata for ${url.href}:`, error);
return {};
});
if (internalMetadata.favicon) {
this.state.previewIcon = {
type: "imgSrc",
value: internalMetadata.favicon.href,
};
}
if (internalMetadata.error_msg) {
this.notificationService.add(internalMetadata.error_msg, {
type: "warning",
});
} else if (internalMetadata.other_error_msg) {
console.error(
"Internal meta data retrieve error for link preview: " +
internalMetadata.other_error_msg
);
} else {
this.state.linkPreviewName =
internalMetadata.link_preview_name ||
internalMetadata.display_name ||
internalMetadata.name;
this.state.urlDescription = internalMetadata?.description || "";
this.state.urlTitle = this.state.linkPreviewName
? this.state.linkPreviewName
: this.state.url;
}
if (
(internalMetadata.ogTitle || internalMetadata.title) &&
!this.state.linkPreviewName
) {
this.state.urlTitle = internalMetadata.ogTitle
? internalMetadata.ogTitle.getAttribute("content")
: internalMetadata.title.text.trim();
}
}
}
get classes() {
let classes = [...this.props.linkElement.classList]
.filter(
(value) =>
!value.match(/^(btn.*|rounded-circle|flat|(text|bg)-(o-color-\d$|\d{3}$))$/)
)
.join(" ");
let stylePrefix = "";
if (this.state.type === "custom") {
if (this.state.buttonSize) {
classes += ` btn-${this.state.buttonSize}`;
}
if (this.state.buttonShape) {
const buttonShape = this.state.buttonShape.split(" ");
if (["outline", "fill"].includes(buttonShape[0])) {
stylePrefix = `${buttonShape[0]}-`;
}
classes += ` ${buttonShape.slice(stylePrefix ? 1 : 0).join(" ")}`;
}
}
if (this.state.type) {
classes += ` btn btn-${stylePrefix}${this.state.type}`;
}
const textColor = this.customTextColorState.selectedColor;
if (isCSSVariable(textColor)) {
classes += " text-" + textColor;
}
const fillColor = this.customFillColorState.selectedColor;
if (isCSSVariable(fillColor)) {
classes += " bg-" + fillColor;
}
return classes.trim();
}
get customStyles() {
if (!this.props.allowCustomStyle || this.state.type !== "custom") {
return false;
}
let customStyles = "";
const textColor = this.customTextColorState.selectedColor;
if (!isCSSVariable(textColor)) {
customStyles += `color: ${textColor}; `;
}
const fillColor = this.customFillColorState.selectedColor;
if (!isCSSVariable(fillColor)) {
const backgroundProperty = fillColor.includes("gradient")
? "background-image"
: "background-color";
customStyles += `${backgroundProperty}: ${fillColor}; `;
}
const borderColor = this.customBorderColorState.selectedColor;
customStyles += `border-width: ${this.state.customBorderSize}px; `;
customStyles += `border-color: ${formatColor(borderColor)}; `;
customStyles += `border-style: ${this.state.customBorderStyle}; `;
return customStyles;
}
async uploadFile() {
const { upload, getURL } = this.uploadService;
const { resModel, resId } = this.props.recordInfo;
const [attachment] = await upload({ resModel, resId, accessToken: true });
if (!attachment) {
// No file selected or upload failed
return;
}
this.props.onUpload?.(attachment);
this.state.url = getURL(attachment, { download: true, unique: true, accessToken: true });
this.state.label ||= attachment.name;
this.state.attachmentId = attachment.id;
this.onChange();
}
isLogoutUrl() {
return !!this.state.url.match(/\/web\/session\/logout\b/);
}
isAttachmentUrl() {
return !!this.state.url.match(/\/web\/content\/\d+/);
}
/**
* Checks if the given URL is using the domain where the content being
* edited is reachable, i.e. if this URL should be stripped of its domain
* part and converted to a relative URL if put as a link in the content.
*
* @private
* @returns {boolean}
*/
isAbsoluteURLInCurrentDomain() {
// First check if it is a relative URL: if it is, we don't want to check
// further as we will always leave those untouched.
let hasProtocol;
try {
hasProtocol = !!new URL(this.state.url).protocol;
} catch {
hasProtocol = false;
}
if (!hasProtocol) {
return false;
}
const urlObj = new URL(this.state.url, window.location.origin);
// Chosen heuristic to detect someone trying to enter a link using
// its Odoo instance domain. We just suppose it should be a relative
// URL (if unexpected behavior, the user can just not enter its Odoo
// instance domain but its real domain, or opt-out from the domain
// stripping). Mentioning an .odoo.com domain, especially its own
// one, is always a bad practice anyway.
return (
urlObj.origin === window.location.origin ||
new RegExp(`^https?://${session.db}\\.odoo\\.com(/.*)?$`).test(urlObj.origin)
);
}
}

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