Initial commit: Web packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit cd458d4b85
791 changed files with 410049 additions and 0 deletions

View file

@ -0,0 +1,79 @@
/** @odoo-module */
import { Attachment, FileSelector, IMAGE_MIMETYPES } from './file_selector';
export class DocumentAttachment extends Attachment {}
DocumentAttachment.template = 'web_editor.DocumentAttachment';
export class DocumentSelector extends FileSelector {
setup() {
super.setup();
this.uploadText = this.env._t("Upload a document");
this.urlPlaceholder = "https://www.odoo.com/mydocument";
this.addText = this.env._t("Add URL");
this.searchPlaceholder = this.env._t("Search a document");
this.allLoadedText = this.env._t("All documents have been loaded");
}
get attachmentsDomain() {
const domain = super.attachmentsDomain;
domain.push(['mimetype', 'not in', IMAGE_MIMETYPES]);
// The assets should not be part of the documents.
// All assets begin with '/web/assets/', see _get_asset_template_url().
domain.unshift('&', '|', ['url', '=', null], '!', ['url', '=like', '/web/assets/%']);
return domain;
}
async onClickDocument(document) {
this.selectAttachment(document);
await this.props.save();
}
async fetchAttachments(...args) {
const attachments = await super.fetchAttachments(...args);
if (this.selectInitialMedia()) {
for (const attachment of attachments) {
if (`/web/content/${attachment.id}` === this.props.media.getAttribute('href').replace(/[?].*/, '')) {
this.selectAttachment(attachment);
}
}
}
return attachments;
}
/**
* Utility method used by the MediaDialog component.
*/
static async createElements(selectedMedia, { orm }) {
return Promise.all(selectedMedia.map(async attachment => {
const linkEl = document.createElement('a');
let href = `/web/content/${encodeURIComponent(attachment.id)}?unique=${encodeURIComponent(attachment.checksum)}&download=true`;
if (!attachment.public) {
let accessToken = attachment.access_token;
if (!accessToken) {
[accessToken] = await orm.call(
'ir.attachment',
'generate_access_token',
[attachment.id],
);
}
href += `&access_token=${encodeURIComponent(accessToken)}`;
}
linkEl.href = href;
linkEl.title = attachment.name;
linkEl.dataset.mimetype = attachment.mimetype;
return linkEl;
}));
}
}
DocumentSelector.mediaSpecificClasses = ['o_image'];
DocumentSelector.mediaSpecificStyles = [];
DocumentSelector.mediaExtraClasses = [];
DocumentSelector.tagNames = ['A'];
DocumentSelector.attachmentsListTemplate = 'web_editor.DocumentsListTemplate';
DocumentSelector.components = {
...FileSelector.components,
DocumentAttachment,
};

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.DocumentAttachment" owl="1">
<div class="o_existing_attachment_cell o_we_attachment_highlight card col-2 position-relative mb-2 p-2 opacity-trigger-hover cursor-pointer" t-att-class="{ o_we_attachment_selected: props.selected }" t-on-click="props.selectAttachment">
<RemoveButton remove="() => this.remove()"/>
<div t-att-data-url="props.url" role="img" t-att-aria-label="props.name" t-att-title="props.name" t-att-data-mimetype="props.mimetype" class="o_image d-flex align-items-center justify-content-center"/>
<small class="o_file_name d-block text-truncate" t-esc="props.name"/>
</div>
</t>
<t t-name="web_editor.DocumentsListTemplate" owl="1">
<div class="o_we_existing_attachments o_we_documents">
<div t-if="!hasContent" class="o_nocontent_help">
<p class="o_empty_folder_image">No documents found.</p>
<p class="o_empty_folder_subtitle">You can upload documents with the button located in the top left of the screen.</p>
</div>
<div t-else="" class="d-flex flex-wrap gap-2">
<t t-foreach="state.attachments" t-as="attachment" t-key="attachment.id">
<DocumentAttachment url="attachment.url"
name="attachment.name"
mimetype="attachment.mimetype"
id="attachment.id"
onRemoved="(attachmentId) => this.onRemoved(attachmentId)"
selected="this.selectedAttachmentIds.includes(attachment.id)"
selectAttachment="() => this.onClickDocument(attachment)"/>
</t>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,303 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { ConfirmationDialog } from '@web/core/confirmation_dialog/confirmation_dialog';
import { Dialog } from '@web/core/dialog/dialog';
import { KeepLast } from "@web/core/utils/concurrency";
import { useDebounced } from "@web/core/utils/timing";
import { SearchMedia } from './search_media';
import { Component, xml, useState, useRef, onWillStart } from "@odoo/owl";
export const IMAGE_MIMETYPES = ['image/jpg', 'image/jpeg', 'image/jpe', 'image/png', 'image/svg+xml', 'image/gif'];
export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.jpe', '.png', '.svg', '.gif'];
class RemoveButton extends Component {
setup() {
this.removeTitle = this.env._t("This file is attached to the current record.");
if (this.props.model === 'ir.ui.view') {
this.removeTitle = this.env._t("This file is a public view attachment.");
}
}
remove(ev) {
ev.stopPropagation();
this.props.remove();
}
}
RemoveButton.template = xml`<i class="fa fa-trash o_existing_attachment_remove position-absolute top-0 end-0 p-2 bg-white-25 cursor-pointer opacity-0 opacity-100-hover z-index-1 transition-base" t-att-title="removeTitle" role="img" t-att-aria-label="removeTitle" t-on-click="this.remove"/>`;
export class AttachmentError extends Component {
setup() {
this.title = this.env._t("Alert");
}
}
AttachmentError.components = { Dialog };
AttachmentError.template = xml `
<Dialog title="title">
<div class="form-text">
<p>The image could not be deleted because it is used in the
following pages or views:</p>
<ul t-foreach="props.views" t-as="view" t-key="view.id">
<li>
<a t-att-href="'/web#model=ir.ui.view&amp;id=' + window.encodeURIComponent(view.id)">
<t t-esc="view.name"/>
</a>
</li>
</ul>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="() => this.props.close()">
Ok
</button>
</t>
</Dialog>`;
export class Attachment extends Component {
setup() {
this.dialogs = useService('dialog');
this.rpc = useService('rpc');
}
remove() {
this.dialogs.add(ConfirmationDialog, {
body: this.env._t("Are you sure you want to delete this file ?"),
confirm: async () => {
const prevented = await this.rpc('/web_editor/attachment/remove', {
ids: [this.props.id],
});
if (!Object.keys(prevented).length) {
this.props.onRemoved(this.props.id);
} else {
this.dialogs.add(AttachmentError, {
views: prevented[this.props.id],
});
}
},
});
}
}
Attachment.components = {
RemoveButton,
};
export class FileSelectorControlPanel extends Component {
setup() {
this.state = useState({
showUrlInput: false,
urlInput: '',
isValidUrl: false,
isValidFileFormat: false
});
this.fileInput = useRef('file-input');
}
get showSearchServiceSelect() {
return this.props.searchService && this.props.needle;
}
get enableUrlUploadClick() {
return !this.state.showUrlInput || (this.state.urlInput && this.state.isValidUrl && this.state.isValidFileFormat);
}
async onUrlUploadClick() {
if (!this.state.showUrlInput) {
this.state.showUrlInput = true;
} else {
await this.props.uploadUrl(this.state.urlInput);
this.state.urlInput = '';
}
}
onUrlInput(ev) {
const { isValidUrl, isValidFileFormat } = this.props.validateUrl(ev.target.value);
this.state.isValidFileFormat = isValidFileFormat;
this.state.isValidUrl = isValidUrl;
}
onClickUpload() {
this.fileInput.el.click();
}
async onChangeFileInput() {
const inputFiles = this.fileInput.el.files;
if (!inputFiles.length) {
return;
}
await this.props.uploadFiles(inputFiles);
const fileInputEl = this.fileInput.el;
if (fileInputEl) {
fileInputEl.value = "";
}
}
}
FileSelectorControlPanel.template = 'web_editor.FileSelectorControlPanel';
FileSelectorControlPanel.components = {
SearchMedia,
};
export class FileSelector extends Component {
setup() {
this.orm = useService('orm');
this.uploadService = useService('upload');
this.keepLast = new KeepLast();
this.loadMoreButtonRef = useRef('load-more-button');
this.state = useState({
attachments: [],
canLoadMoreAttachments: true,
isFetchingAttachments: false,
needle: '',
});
this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY = 30;
onWillStart(async () => {
this.state.attachments = await this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0);
});
this.debouncedScroll = useDebounced(this.scrollToLoadMoreButton, 500);
}
get canLoadMore() {
return this.state.canLoadMoreAttachments;
}
get hasContent() {
return this.state.attachments.length;
}
get isFetching() {
return this.state.isFetchingAttachments;
}
get selectedAttachmentIds() {
return this.props.selectedMedia[this.props.id].filter(media => media.mediaType === 'attachment').map(({ id }) => id);
}
get attachmentsDomain() {
const domain = [
'&',
['res_model', '=', this.props.resModel],
['res_id', '=', this.props.resId || 0]
];
domain.unshift('|', ['public', '=', true]);
domain.push(['name', 'ilike', this.state.needle]);
return domain;
}
validateUrl(url) {
const path = url.split('?')[0];
const isValidUrl = /^.+\..+$/.test(path); // TODO improve
const isValidFileFormat = true;
return { isValidUrl, isValidFileFormat, path };
}
async fetchAttachments(limit, offset) {
this.state.isFetchingAttachments = true;
let attachments = [];
try {
attachments = await this.orm.call(
'ir.attachment',
'search_read',
[],
{
domain: this.attachmentsDomain,
fields: ['name', 'mimetype', 'description', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'],
order: 'id desc',
// Try to fetch first record of next page just to know whether there is a next page.
limit,
offset,
}
);
attachments.forEach(attachment => attachment.mediaType = 'attachment');
} catch (e) {
// Reading attachments as a portal user is not permitted and will raise
// an access error so we catch the error silently and don't return any
// attachment so he can still use the wizard and upload an attachment
if (e.exceptionName !== 'odoo.exceptions.AccessError') {
throw e;
}
}
this.state.canLoadMoreAttachments = attachments.length >= this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
this.state.isFetchingAttachments = false;
return attachments;
}
async handleLoadMore() {
await this.loadMore();
this.debouncedScroll();
}
async loadMore() {
return this.keepLast.add(this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, this.state.attachments.length)).then((newAttachments) => {
// This is never reached if another search or loadMore occurred.
this.state.attachments.push(...newAttachments);
});
}
async handleSearch(needle) {
await this.search(needle);
this.debouncedScroll();
}
async search(needle) {
// Prepare in case loadMore results are obtained instead.
this.state.attachments = [];
// Fetch attachments relies on the state's needle.
this.state.needle = needle;
return this.keepLast.add(this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0)).then((attachments) => {
// This is never reached if a new search occurred.
this.state.attachments = attachments;
});
}
async uploadFiles(files) {
await this.uploadService.uploadFiles(files, { resModel: this.props.resModel, resId: this.props.resId }, attachment => this.onUploaded(attachment));
}
async uploadUrl(url) {
await this.uploadService.uploadUrl(url, { resModel: this.props.resModel, resId: this.props.resId }, attachment => this.onUploaded(attachment));
}
async onUploaded(attachment) {
this.state.attachments = [attachment, ...this.state.attachments.filter(attach => attach.id !== attachment.id)];
this.selectAttachment(attachment);
if (!this.props.multiSelect) {
await this.props.save();
}
if (this.props.onAttachmentChange) {
this.props.onAttachmentChange(attachment);
}
}
onRemoved(attachmentId) {
this.state.attachments = this.state.attachments.filter(attachment => attachment.id !== attachmentId);
}
selectAttachment(attachment) {
this.props.selectMedia({ ...attachment, mediaType: 'attachment' });
}
selectInitialMedia() {
return this.props.media
&& this.constructor.tagNames.includes(this.props.media.tagName)
&& !this.selectedAttachmentIds.length;
}
/**
* This is used (debounced) to be called after loading an attachment.
* This way, the user can always see the "load more" button.
*/
scrollToLoadMoreButton() {
if (this.state.needle || this.state.attachments.length > this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY) {
this.loadMoreButtonRef.el.scrollIntoView({ block: 'end', inline: 'nearest', behavior: 'smooth' });
}
}
}
FileSelector.template = 'web_editor.FileSelector';
FileSelector.components = {
FileSelectorControlPanel,
};

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.FileSelectorControlPanel" owl="1">
<div class="d-flex flex-wrap gap-2 mt-4 mb-2 align-items-end">
<SearchMedia searchPlaceholder="props.searchPlaceholder" needle="props.needle" search="props.search"/>
<div class="d-flex gap-3 justify-content-start align-items-center">
<div t-if="props.showOptimizedOption" class="flex-shrink-0 form-check form-switch align-items-center" t-on-change="props.changeShowOptimized">
<input class="o_we_show_optimized form-check-input" type="checkbox" t-att-checked="props.showOptimized" id="o_we_show_optimized_switch"/>
<label class="form-check-label" for="o_we_show_optimized_switch">
Show optimized images
</label>
</div>
<select t-if="showSearchServiceSelect" class="o_input o_we_search_select form-select" t-on-change="ev => props.changeSearchService(ev.target.value)">
<option t-att-selected="props.searchService === 'all'" value="all">All</option>
<option t-att-selected="props.searchService === 'database'" value="database">My Images</option>
<option t-if="props.useMediaLibrary" t-att-selected="props.searchService === 'media-library'" value="media-library">Illustrations</option>
</select>
</div>
<div class="col justify-content-end flex-nowrap input-group has-validation">
<input type="text" class="form-control o_input o_we_url_input o_we_transition_ease flex-grow-0" t-att-class="{ o_we_horizontal_collapse: !state.showUrlInput, 'w-auto': state.showUrlInput }" name="url" t-att-placeholder="props.urlPlaceholder" t-model="state.urlInput" t-on-input="onUrlInput" t-if="state.showUrlInput"/>
<button type="button" class="btn o_upload_media_url_button text-nowrap" t-att-class="{ 'btn-primary': state.urlInput, 'btn-secondary': !state.urlInput}" t-on-click="onUrlUploadClick" t-att-disabled="!enableUrlUploadClick">
<t t-esc="props.addText"/>
</button>
<div class="d-flex align-items-center">
<span t-if="state.urlInput and state.isValidUrl and state.isValidFileFormat" class="o_we_url_success text-success mx-2 fa fa-lg fa-check" title="The URL seems valid."/>
<span t-if="state.urlInput and !state.isValidUrl" class="o_we_url_error text-danger mx-2 fa fa-lg fa-times" title="The URL does not seem to work."/>
<span t-if="props.urlWarningTitle and state.urlInput and state.isValidUrl and !state.isValidFileFormat" class="o_we_url_warning text-warning mx-2 fa fa-lg fa-warning" t-att-title="props.urlWarningTitle"/>
</div>
</div>
<input type="file" class="d-none o_file_input" t-on-change="onChangeFileInput" t-ref="file-input" t-att-accept="props.accept" t-att-multiple="props.multiSelect and 'multiple'"/>
<div class="col-auto btn-group">
<button type="button" class="btn btn-primary o_upload_media_button" t-on-click="onClickUpload">
<t t-esc="props.uploadText"/>
</button>
</div>
</div>
</t>
<t t-name="web_editor.FileSelector" owl="1">
<div>
<FileSelectorControlPanel uploadText="uploadText"
accept="fileMimetypes"
urlPlaceholder="urlPlaceholder"
addText="addText"
searchPlaceholder="searchPlaceholder"
urlWarningTitle="urlWarningTitle"
uploadUrl="(url) => this.uploadUrl(url)"
uploadFiles="(files) => this.uploadFiles(files)"
showOptimizedOption="showOptimizedOption"
showOptimized="state.showOptimized"
changeShowOptimized="showOptimized => this.state.showOptimized = !this.state.showOptimized"
changeSearchService="service => this.state.searchService = service"
searchService="state.searchService"
needle="state.needle"
search="(needle) => this.handleSearch(needle)"
useMediaLibrary="props.useMediaLibrary"
validateUrl="validateUrl"
multiSelect="props.multiSelect"/>
<t t-call="{{ constructor.attachmentsListTemplate }}"/>
<div name="load_more_attachments" class="mt-4 text-center mx-auto o_we_load_more" t-ref="load-more-button">
<button t-if="canLoadMore" class="btn btn-odoo o_load_more" type="button" t-on-click="handleLoadMore">Load more...</button>
<div t-if="hasContent and !canLoadMore" class="mt-4 o_load_done_msg">
<span><i t-esc="allLoadedText"/></span>
</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,80 @@
/** @odoo-module */
import fonts from 'wysiwyg.fonts';
import { SearchMedia } from './search_media';
import { Component, useState } from "@odoo/owl";
export class IconSelector extends Component {
setup() {
this.state = useState({
fonts: this.props.fonts,
needle: '',
});
this.searchPlaceholder = this.env._t("Search a pictogram");
}
get selectedMediaIds() {
return this.props.selectedMedia[this.props.id].map(({ id }) => id);
}
search(needle) {
this.state.needle = needle;
if (!this.state.needle) {
this.state.fonts = this.props.fonts;
} else {
this.state.fonts = this.props.fonts.map(font => {
const icons = font.icons.filter(icon => icon.alias.indexOf(this.state.needle) >= 0);
return {...font, icons};
});
}
}
async onClickIcon(font, icon) {
this.props.selectMedia({
...icon,
fontBase: font.base,
// To check if the icon has changed, we only need to compare
// an alias of the icon with the class from the old media (some
// icons can have multiple classes e.g. "fa-gears" ~ "fa-cogs")
initialIconChanged: this.props.media
&& !icon.names.some(name => this.props.media.classList.contains(name)),
});
await this.props.save();
}
/**
* Utility methods, used by the MediaDialog component.
*/
static createElements(selectedMedia) {
return selectedMedia.map(icon => {
const iconEl = document.createElement('span');
iconEl.classList.add(icon.fontBase, icon.names[0]);
return iconEl;
});
}
static initFonts() {
fonts.computeFonts();
const allFonts = fonts.fontIcons.map(({cssData, base}) => {
const uniqueIcons = Array.from(new Map(cssData.map(icon => {
const alias = icon.names.join(',');
const id = `${base}_${alias}`;
return [id, { ...icon, alias, id }];
})).values());
return { base, icons: uniqueIcons };
});
return allFonts;
}
}
IconSelector.mediaSpecificClasses = ['fa'];
IconSelector.mediaSpecificStyles = ['color', 'background-color'];
IconSelector.mediaExtraClasses = [
'rounded-circle', 'rounded', 'img-thumbnail', 'shadow',
/^text-\S+$/, /^bg-\S+$/, /^fa-\S+$/,
];
IconSelector.tagNames = ['SPAN', 'I'];
IconSelector.template = 'web_editor.IconSelector';
IconSelector.components = {
SearchMedia,
};

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.IconSelector" owl="1">
<div>
<div class="d-flex gap-2 align-items-center py-4">
<SearchMedia searchPlaceholder="searchPlaceholder"
search.bind="this.search"
needle="state.needle"/>
</div>
<div class="font-icons-icons">
<t t-foreach="state.fonts" t-as="font" t-key="font.base">
<div t-if="!font.icons.length" class="o_nocontent_help">
<p class="o_empty_folder_image">No pictograms found.</p>
<p class="o_empty_folder_subtitle">Try searching with other keywords.</p>
</div>
<span t-foreach="font.icons" t-as="icon" t-key="icon.id"
t-att-title="icon.names[0]"
t-att-aria-label="icon.names[0]" role="img"
class="font-icons-icon m-2 fs-2 p-3 cursor-pointer text-center"
t-att-class="{ o_we_attachment_selected: this.selectedMediaIds.includes(icon.id) }"
t-attf-class="{{ font.base }} {{ icon.names[0] }}"
t-on-click="() => this.onClickIcon(font, icon)"/>
</t>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,370 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { getCSSVariableValue, DEFAULT_PALETTE } from 'web_editor.utils';
import { Attachment, FileSelector, IMAGE_MIMETYPES, IMAGE_EXTENSIONS } from './file_selector';
import { KeepLast } from "@web/core/utils/concurrency";
import { useRef, useState, useEffect } from "@odoo/owl";
export class AutoResizeImage extends Attachment {
setup() {
super.setup();
this.image = useRef('auto-resize-image');
this.container = useRef('auto-resize-image-container');
this.state = useState({
loaded: false,
});
useEffect(() => {
this.image.el.addEventListener('load', () => this.onImageLoaded());
return this.image.el.removeEventListener('load', () => this.onImageLoaded());
}, () => []);
}
async onImageLoaded() {
if (!this.image.el) {
// Do not fail if already removed.
return;
}
if (this.props.onLoaded) {
await this.props.onLoaded(this.image.el);
if (!this.image.el) {
// If replaced by colored version, aspect ratio will be
// computed on it instead.
return;
}
}
const aspectRatio = this.image.el.offsetWidth / this.image.el.offsetHeight;
const width = aspectRatio * this.props.minRowHeight;
this.container.el.style.flexGrow = width;
this.container.el.style.flexBasis = `${width}px`;
this.state.loaded = true;
}
}
AutoResizeImage.template = 'web_editor.AutoResizeImage';
export class ImageSelector extends FileSelector {
setup() {
super.setup();
this.rpc = useService('rpc');
this.keepLastLibraryMedia = new KeepLast();
this.state.libraryMedia = [];
this.state.libraryResults = null;
this.state.isFetchingLibrary = false;
this.state.searchService = 'all';
this.state.showOptimized = false;
this.NUMBER_OF_MEDIA_TO_DISPLAY = 10;
this.uploadText = this.env._t("Upload an image");
this.urlPlaceholder = "https://www.odoo.com/logo.png";
this.addText = this.env._t("Add URL");
this.searchPlaceholder = this.env._t("Search an image");
this.urlWarningTitle = this.env._t("Uploaded image's format is not supported. Try with: " + IMAGE_EXTENSIONS.join(', '));
this.allLoadedText = this.env._t("All images have been loaded");
this.showOptimizedOption = this.env.debug;
this.MIN_ROW_HEIGHT = 128;
this.fileMimetypes = IMAGE_MIMETYPES.join(',');
}
get canLoadMore() {
// The user can load more library media only when the filter is set.
if (this.state.searchService === 'media-library') {
return this.state.libraryResults && this.state.libraryMedia.length < this.state.libraryResults;
}
return super.canLoadMore;
}
get hasContent() {
if (this.state.searchService === 'all') {
return super.hasContent || !!this.state.libraryMedia.length;
} else if (this.state.searchService === 'media-library') {
return !!this.state.libraryMedia.length;
}
return super.hasContent;
}
get isFetching() {
return super.isFetching || this.state.isFetchingLibrary;
}
get selectedMediaIds() {
return this.props.selectedMedia[this.props.id].filter(media => media.mediaType === 'libraryMedia').map(({ id }) => id);
}
get attachmentsDomain() {
const domain = super.attachmentsDomain;
domain.push(['mimetype', 'in', IMAGE_MIMETYPES]);
if (!this.props.useMediaLibrary) {
domain.push('|', ['url', '=', false], '!', ['url', '=ilike', '/web_editor/shape/%']);
}
domain.push('!', ['name', '=like', '%.crop']);
domain.push('|', ['type', '=', 'binary'], '!', ['url', '=like', '/%/static/%']);
// Optimized images (meaning they are related to an `original_id`) can
// only be shown in debug mode as the toggler to make those images
// appear is hidden when not in debug mode.
// There is thus no point to fetch those optimized images outside debug
// mode. Worst, it leads to bugs: it might fetch only optimized images
// when clicking on "load more" which will look like it's bugged as no
// images will appear on screen (they all will be hidden).
if (!this.env.debug) {
const subDomain = [false];
// Particular exception: if the edited image is an optimized
// image, we need to fetch it too so it's displayed as the
// selected image when opening the media dialog.
// We might get a few more optimized image than necessary if the
// original image has multiple optimized images but it's not a
// big deal.
const originalId = this.props.media && this.props.media.dataset.originalId;
if (originalId) {
subDomain.push(originalId);
}
domain.push(['original_id', 'in', subDomain]);
}
return domain;
}
async uploadFiles(files) {
await this.uploadService.uploadFiles(files, { resModel: this.props.resModel, resId: this.props.resId, isImage: true }, (attachment) => this.onUploaded(attachment));
}
validateUrl(...args) {
const { isValidUrl, path } = super.validateUrl(...args);
const isValidFileFormat = IMAGE_EXTENSIONS.some(format => path.endsWith(format));
return { isValidFileFormat, isValidUrl };
}
isInitialMedia(attachment) {
if (this.props.media.dataset.originalSrc) {
return this.props.media.dataset.originalSrc === attachment.image_src;
}
return this.props.media.getAttribute('src') === attachment.image_src;
}
async fetchAttachments(limit, offset) {
const attachments = await super.fetchAttachments(limit, offset);
// Color-substitution for dynamic SVG attachment
const primaryColors = {};
for (let color = 1; color <= 5; color++) {
primaryColors[color] = getCSSVariableValue('o-color-' + color);
}
return attachments.map(attachment => {
if (attachment.image_src.startsWith('/')) {
const newURL = new URL(attachment.image_src, window.location.origin);
// Set the main colors of dynamic SVGs to o-color-1~5
if (attachment.image_src.startsWith('/web_editor/shape/')) {
newURL.searchParams.forEach((value, key) => {
const match = key.match(/^c([1-5])$/);
if (match) {
newURL.searchParams.set(key, primaryColors[match[1]]);
}
});
} else {
// Set height so that db images load faster
newURL.searchParams.set('height', 2 * this.MIN_ROW_HEIGHT);
}
attachment.thumbnail_src = newURL.pathname + newURL.search;
}
if (this.selectInitialMedia() && this.isInitialMedia(attachment)) {
this.selectAttachment(attachment);
}
return attachment;
});
}
async fetchLibraryMedia(offset) {
if (!this.state.needle) {
return { media: [], results: null };
}
this.state.isFetchingLibrary = true;
try {
const response = await this.rpc(
'/web_editor/media_library_search',
{
'query': this.state.needle,
'offset': offset,
},
{
silent: true,
}
);
this.state.isFetchingLibrary = false;
const media = (response.media || []).slice(0, this.NUMBER_OF_MEDIA_TO_DISPLAY);
media.forEach(record => record.mediaType = 'libraryMedia');
return { media, results: response.results };
} catch {
// Either API endpoint doesn't exist or is misconfigured.
console.error(`Couldn't reach API endpoint.`);
this.state.isFetchingLibrary = false;
return { media: [], results: null };
}
}
async loadMore(...args) {
await super.loadMore(...args);
if (!this.props.useMediaLibrary
// The user can load more library media only when the filter is set.
|| this.state.searchService !== 'media-library'
) {
return;
}
return this.keepLastLibraryMedia.add(this.fetchLibraryMedia(this.state.libraryMedia.length)).then(({ media }) => {
// This is never reached if another search or loadMore occurred.
this.state.libraryMedia.push(...media);
});
}
async search(...args) {
await super.search(...args);
if (!this.props.useMediaLibrary) {
return;
}
if (!this.state.needle) {
this.state.searchService = 'all';
}
this.state.libraryMedia = [];
this.state.libraryResults = 0;
return this.keepLastLibraryMedia.add(this.fetchLibraryMedia(0)).then(({ media, results }) => {
// This is never reached if a new search occurred.
this.state.libraryMedia = media;
this.state.libraryResults = results;
});
}
async onClickAttachment(attachment) {
this.selectAttachment(attachment);
if (!this.props.multiSelect) {
await this.props.save();
}
}
async onClickMedia(media) {
this.props.selectMedia({ ...media, mediaType: 'libraryMedia' });
if (!this.props.multiSelect) {
await this.props.save();
}
}
/**
* Utility method used by the MediaDialog component.
*/
static async createElements(selectedMedia, { orm, rpc }) {
// Create all media-library attachments.
const toSave = Object.fromEntries(selectedMedia.filter(media => media.mediaType === 'libraryMedia').map(media => [
media.id, {
query: media.query || '',
is_dynamic_svg: !!media.isDynamicSVG,
dynamic_colors: media.dynamicColors,
}
]));
let savedMedia = [];
if (Object.keys(toSave).length !== 0) {
savedMedia = await rpc('/web_editor/save_library_media', { media: toSave });
}
const selected = selectedMedia.filter(media => media.mediaType === 'attachment').concat(savedMedia).map(attachment => {
// Color-customize dynamic SVGs with the theme colors
if (attachment.image_src && attachment.image_src.startsWith('/web_editor/shape/')) {
const colorCustomizedURL = new URL(attachment.image_src, window.location.origin);
colorCustomizedURL.searchParams.forEach((value, key) => {
const match = key.match(/^c([1-5])$/);
if (match) {
colorCustomizedURL.searchParams.set(key, getCSSVariableValue(`o-color-${match[1]}`));
}
});
attachment.image_src = colorCustomizedURL.pathname + colorCustomizedURL.search;
}
return attachment;
});
return Promise.all(selected.map(async (attachment) => {
const imageEl = document.createElement('img');
let src = attachment.image_src;
if (!attachment.public && !attachment.url) {
let accessToken = attachment.access_token;
if (!accessToken) {
[accessToken] = await orm.call(
'ir.attachment',
'generate_access_token',
[attachment.id],
);
}
src += `?access_token=${encodeURIComponent(accessToken)}`;
}
imageEl.src = src;
imageEl.alt = attachment.description || '';
return imageEl;
}));
}
async onImageLoaded(imgEl, attachment) {
this.debouncedScroll();
if (attachment.mediaType === 'libraryMedia' && !imgEl.src.startsWith('blob')) {
// This call applies the theme's color palette to the
// loaded illustration. Upon replacement of the image,
// `onImageLoad` is called again, but the replacement image
// has an URL that starts with 'blob'. The condition above
// uses this to avoid an infinite loop.
await this.onLibraryImageLoaded(imgEl, attachment);
}
}
/**
* This converts the colors of an svg coming from the media library to
* the palette's ones, and make them dynamic.
*
* @param {HTMLElement} imgEl
* @param {Object} media
* @returns
*/
async onLibraryImageLoaded(imgEl, media) {
const mediaUrl = imgEl.src;
try {
const response = await fetch(mediaUrl);
const contentType = response.headers.get("content-type");
if (contentType && contentType.startsWith("image/svg+xml")) {
let svg = await response.text();
const dynamicColors = {};
const combinedColorsRegex = new RegExp(Object.values(DEFAULT_PALETTE).join('|'), 'gi');
svg = svg.replace(combinedColorsRegex, match => {
const colorId = Object.keys(DEFAULT_PALETTE).find(key => DEFAULT_PALETTE[key] === match.toUpperCase());
const colorKey = 'c' + colorId
dynamicColors[colorKey] = getCSSVariableValue('o-color-' + colorId);
return dynamicColors[colorKey];
});
const fileName = mediaUrl.split('/').pop();
const file = new File([svg], fileName, {
type: "image/svg+xml",
});
imgEl.src = URL.createObjectURL(file);
if (Object.keys(dynamicColors).length) {
media.isDynamicSVG = true;
media.dynamicColors = dynamicColors;
}
}
} catch (_e) {
console.error('CORS is misconfigured on the API server, image will be treated as non-dynamic.');
}
}
}
ImageSelector.mediaSpecificClasses = ['img', 'img-fluid', 'o_we_custom_image'];
ImageSelector.mediaSpecificStyles = [];
ImageSelector.mediaExtraClasses = [
'rounded-circle', 'rounded', 'img-thumbnail', 'shadow',
'w-25', 'w-50', 'w-75', 'w-100',
];
ImageSelector.tagNames = ['IMG'];
ImageSelector.attachmentsListTemplate = 'web_editor.ImagesListTemplate';
ImageSelector.components = {
...FileSelector.components,
AutoResizeImage,
};

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.AutoResizeImage" owl="1">
<div t-ref="auto-resize-image-container" class="o_existing_attachment_cell o_we_image align-items-center justify-content-center me-1 mb-1 opacity-trigger-hover opacity-0 cursor-pointer" t-att-class="{ o_we_attachment_optimized: props.isOptimized, 'o_loaded position-relative opacity-100': state.loaded, o_we_attachment_selected: props.selected, 'position-fixed': !state.loaded }" t-on-click="props.onImageClick">
<RemoveButton t-if="props.isRemovable" model="props.model" remove="() => this.remove()"/>
<div class="o_we_media_dialog_img_wrapper">
<img t-ref="auto-resize-image" class="o_we_attachment_highlight img img-fluid w-100" t-att-src="props.src" t-att-alt="props.altDescription" t-att-title="props.title" loading="lazy"/>
<a t-if="props.author" class="o_we_media_author position-absolute start-0 bottom-0 end-0 text-truncate text-center text-primary fs-6 bg-white-50" t-att-href="props.authorLink" target="_blank" t-esc="props.author"/>
</div>
<span t-if="props.isOptimized" class="badge position-absolute bottom-0 end-0 m-1 text-bg-success">Optimized</span>
</div>
</t>
<t t-name="web_editor.ImagesListTemplate" owl="1">
<div class="o_we_existing_attachments o_we_images d-flex flex-wrap my-0">
<t t-if="!hasContent and !isFetching">
<div t-if="state.needle" class="o_nocontent_help">
<p class="o_empty_folder_image">No images found.</p>
<p class="o_empty_folder_subtitle">You can upload images with the button located in the top left of the screen.</p>
</div>
<div t-else="" class="o_we_search_prompt">
<h2>Get the perfect image by searching in our library of copyright free photos and illustrations.</h2>
</div>
</t>
<t t-else="">
<t t-if="['all', 'database'].includes(state.searchService)">
<t t-foreach="state.attachments" t-as="attachment" t-key="attachment.id">
<AutoResizeImage t-if="!attachment.original_id or state.showOptimized"
id="attachment.id"
isOptimized="!!attachment.original_id"
isRemovable="true"
onRemoved="(attachmentId) => this.onRemoved(attachmentId)"
selected="this.selectedAttachmentIds.includes(attachment.id)"
src="attachment.thumbnail_src or attachment.image_src"
name="attachment.name"
title="attachment.name"
altDescription="attachment.altDescription"
model="attachment.res_model"
minRowHeight="MIN_ROW_HEIGHT"
onImageClick="() => this.onClickAttachment(attachment)"
onLoaded="(imgEl) => this.onImageLoaded(imgEl, attachment)"/>
</t>
</t>
<t id="o_we_media_library_images" t-if="['all', 'media-library'].includes(state.searchService)">
<t t-foreach="state.libraryMedia" t-as="media" t-key="media.id">
<AutoResizeImage author="media.author"
src="media.thumbnail_url"
authorLink="media.author_link"
title="media.tooltip"
altDescription="media.tooltip"
minRowHeight="MIN_ROW_HEIGHT"
selected="this.selectedMediaIds.includes(media.id)"
onImageClick="() => this.onClickMedia(media)"
onLoaded="(imgEl) => this.onImageLoaded(imgEl, media)"/>
</t>
</t>
<!-- 20 placeholders is just enough for a 5K screen, change this if ImageWidget.MIN_ROW_HEIGHT changes -->
<t t-foreach="[...Array(20).keys()]" t-as="i" t-key="i">
<div class="o_we_attachment_placeholder"/>
</t>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,272 @@
/** @odoo-module **/
import {_lt} from "@web/core/l10n/translation";
import { useService } from '@web/core/utils/hooks';
import { Mutex } from "@web/core/utils/concurrency";
import { useWowlService } from '@web/legacy/utils';
import { Dialog } from '@web/core/dialog/dialog';
import { Notebook } from '@web/core/notebook/notebook';
import { ImageSelector } from './image_selector';
import { DocumentSelector } from './document_selector';
import { IconSelector } from './icon_selector';
import { VideoSelector } from './video_selector';
import { Component, useState, onRendered, xml } from "@odoo/owl";
export const TABS = {
IMAGES: {
id: 'IMAGES',
title: _lt("Images"),
Component: ImageSelector,
},
DOCUMENTS: {
id: 'DOCUMENTS',
title: _lt("Documents"),
Component: DocumentSelector,
},
ICONS: {
id: 'ICONS',
title: _lt("Icons"),
Component: IconSelector,
},
VIDEOS: {
id: 'VIDEOS',
title: _lt("Videos"),
Component: VideoSelector,
},
};
export class MediaDialog extends Component {
setup() {
this.size = 'xl';
this.contentClass = 'o_select_media_dialog';
this.title = this.env._t("Select a media");
this.rpc = useService('rpc');
this.orm = useService('orm');
this.notificationService = useService('notification');
this.mutex = new Mutex();
this.tabs = [];
this.selectedMedia = useState({});
this.initialIconClasses = [];
this.addTabs();
this.errorMessages = {};
this.state = useState({
activeTab: this.initialActiveTab,
});
}
get initialActiveTab() {
if (this.props.activeTab) {
return this.props.activeTab;
}
if (this.props.media) {
const correspondingTab = Object.keys(TABS).find(id => TABS[id].Component.tagNames.includes(this.props.media.tagName));
if (correspondingTab) {
return correspondingTab;
}
}
return this.tabs[0].id;
}
addTab(tab, additionalProps = {}) {
this.selectedMedia[tab.id] = [];
this.tabs.push({
...tab,
props: {
...tab.props,
...additionalProps,
id: tab.id,
resModel: this.props.resModel,
resId: this.props.resId,
media: this.props.media,
multiImages: this.props.multiImages,
selectedMedia: this.selectedMedia,
selectMedia: (...args) => this.selectMedia(...args, tab.id, additionalProps.multiSelect),
save: this.save.bind(this),
onAttachmentChange: this.props.onAttachmentChange,
errorMessages: (errorMessage) => this.errorMessages[tab.id] = errorMessage,
},
});
}
addTabs() {
const onlyImages = this.props.onlyImages || this.props.multiImages || (this.props.media && this.props.media.parentElement && (this.props.media.parentElement.dataset.oeField === 'image' || this.props.media.parentElement.dataset.oeType === 'image'));
const noDocuments = onlyImages || this.props.noDocuments;
const noIcons = onlyImages || this.props.noIcons;
const noVideos = onlyImages || this.props.noVideos;
if (!this.props.noImages) {
this.addTab(TABS.IMAGES, {
useMediaLibrary: this.props.useMediaLibrary,
multiSelect: this.props.multiImages,
});
}
if (!noDocuments) {
this.addTab(TABS.DOCUMENTS);
}
if (!noIcons) {
const fonts = TABS.ICONS.Component.initFonts();
this.addTab(TABS.ICONS, {
fonts,
});
if (this.props.media && TABS.ICONS.Component.tagNames.includes(this.props.media.tagName)) {
const classes = this.props.media.className.split(/\s+/);
const mediaFont = fonts.find(font => classes.includes(font.base));
if (mediaFont) {
const selectedIcon = mediaFont.icons.find(icon => icon.names.some(name => classes.includes(name)));
if (selectedIcon) {
this.initialIconClasses.push(...selectedIcon.names);
this.selectMedia(selectedIcon, TABS.ICONS.id);
}
}
}
}
if (!noVideos) {
this.addTab(TABS.VIDEOS, {
vimeoPreviewIds: this.props.vimeoPreviewIds,
isForBgVideo: this.props.isForBgVideo,
});
}
}
selectMedia(media, tabId, multiSelect) {
if (multiSelect) {
const isMediaSelected = this.selectedMedia[tabId].map(({ id }) => id).includes(media.id);
if (!isMediaSelected) {
this.selectedMedia[tabId].push(media);
} else {
this.selectedMedia[tabId] = this.selectedMedia[tabId].filter(m => m.id !== media.id);
}
} else {
this.selectedMedia[tabId] = [media];
}
}
async save() {
if (this.errorMessages[this.state.activeTab]) {
this.notificationService.add(this.errorMessages[this.state.activeTab], {
type: 'danger',
});
return;
}
const selectedMedia = this.selectedMedia[this.state.activeTab];
// TODO In master: clean the save method so it performs the specific
// adaptation before saving from the active media selector and find a
// way to simply close the dialog if the media element remains the same.
const saveSelectedMedia = selectedMedia.length
&& (this.state.activeTab !== TABS.ICONS.id || selectedMedia[0].initialIconChanged || !this.props.media);
if (saveSelectedMedia) {
// Calling a mutex to make sure RPC calls inside `createElements`
// are properly awaited (e.g. avoid creating multiple attachments
// when clicking multiple times on the same media). As
// `createElements` is static, the mutex has to be set on the media
// dialog itself to be destroyed with its instance.
const elements = await this.mutex.exec(async() =>
await TABS[this.state.activeTab].Component.createElements(selectedMedia, { rpc: this.rpc, orm: this.orm })
);
elements.forEach(element => {
if (this.props.media) {
element.classList.add(...this.props.media.classList);
const style = this.props.media.getAttribute('style');
if (style) {
element.setAttribute('style', style);
}
if (this.state.activeTab === TABS.IMAGES.id) {
if (this.props.media.dataset.shape) {
element.dataset.shape = this.props.media.dataset.shape;
}
if (this.props.media.dataset.shapeColors) {
element.dataset.shapeColors = this.props.media.dataset.shapeColors;
}
} else if ([TABS.VIDEOS.id, TABS.DOCUMENTS.id].includes(this.state.activeTab)) {
const parentEl = this.props.media.parentElement;
if (
parentEl &&
parentEl.tagName === "A" &&
parentEl.children.length === 1 &&
this.props.media.tagName === "IMG"
) {
// If an image is wrapped in an <a> tag, we remove the link when replacing it with a video or document
parentEl.replaceWith(parentEl.firstElementChild);
}
}
}
for (const otherTab of Object.keys(TABS).filter(key => key !== this.state.activeTab)) {
for (const property of TABS[otherTab].Component.mediaSpecificStyles) {
element.style.removeProperty(property);
}
element.classList.remove(...TABS[otherTab].Component.mediaSpecificClasses);
const extraClassesToRemove = [];
for (const name of TABS[otherTab].Component.mediaExtraClasses) {
if (typeof(name) === 'string') {
extraClassesToRemove.push(name);
} else { // Regex
for (const className of element.classList) {
if (className.match(name)) {
extraClassesToRemove.push(className);
}
}
}
}
// Remove classes that do not also exist in the target type.
element.classList.remove(...extraClassesToRemove.filter(candidateName => {
for (const name of TABS[this.state.activeTab].Component.mediaExtraClasses) {
if (typeof(name) === 'string') {
if (candidateName === name) {
return false;
}
} else { // Regex
for (const className of element.classList) {
if (className.match(candidateName)) {
return false;
}
}
}
}
return true;
}));
}
element.classList.remove(...this.initialIconClasses);
element.classList.remove('o_modified_image_to_save');
element.classList.remove('oe_edited_link');
element.classList.add(...TABS[this.state.activeTab].Component.mediaSpecificClasses);
});
if (this.props.multiImages) {
await this.props.save(elements);
} else {
await this.props.save(elements[0]);
}
}
this.props.close();
}
onTabChange(tab) {
this.state.activeTab = tab;
}
}
MediaDialog.template = 'web_editor.MediaDialog';
MediaDialog.defaultProps = {
useMediaLibrary: true,
};
MediaDialog.components = {
...Object.keys(TABS).map(key => TABS[key].Component),
Dialog,
Notebook,
};
export class MediaDialogWrapper extends Component {
setup() {
this.dialogs = useWowlService('dialog');
onRendered(() => {
this.dialogs.add(MediaDialog, this.props);
});
}
}
MediaDialogWrapper.template = xml``;

View file

@ -0,0 +1,52 @@
.modal:not(.o_legacy_dialog) .o_select_media_dialog {
$min-row-height: 128;
.o_we_existing_attachments {
min-height: $min-row-height + px;
.o_we_attachment_placeholder {
flex-grow: $min-row-height;
flex-basis: $min-row-height + px;
}
.o_existing_attachment_cell.o_we_image {
transition: opacity 0.5s ease 0.5s;
.o_we_media_dialog_img_wrapper {
@extend %o-preview-alpha-background;
}
}
.o_existing_attachment_remove {
border-radius: 0 0 0 $o-we-item-border-radius;
&:hover {
color: $o-we-color-danger;
}
}
}
.o_we_attachment_selected {
@include o-we-active-wrapper($top: 5px, $left: 5px);
}
.o_we_load_more {
scroll-margin: $modal-inner-padding;
}
.font-icons-icons > span {
width: 50px;
}
.o_video_dialog_form textarea {
min-height: 95px;
}
.o_video_preview {
@include o-we-preview-box();
.media_iframe_video {
width: 100%;
}
}
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.MediaDialog" owl="1">
<Dialog contentClass="contentClass"
size="size"
title="title">
<Notebook pages="tabs" onPageUpdate.bind="onTabChange" defaultPage="state.activeTab"/>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="() => this.save()">Add</button>
<button class="btn btn-secondary" t-on-click="() => this.props.close()">Discard</button>
</t>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,31 @@
/** @odoo-module **/
import { useDebounced } from '@web/core/utils/timing';
import { useAutofocus } from '@web/core/utils/hooks';
import { Component, xml, useEffect, useState } from "@odoo/owl";
export class SearchMedia extends Component {
setup() {
useAutofocus();
this.debouncedSearch = useDebounced(this.props.search, 1000);
this.state = useState({
input: this.props.needle || '',
});
useEffect((input) => {
// Do not trigger a search on the initial render.
if (this.hasRendered) {
this.debouncedSearch(input);
} else {
this.hasRendered = true;
}
}, () => [this.state.input]);
}
}
SearchMedia.template = xml`
<div class="position-relative mw-lg-25 flex-grow-1 me-auto">
<input type="text" class="o_we_search o_input form-control" t-att-placeholder="props.searchPlaceholder.trim()" t-model="state.input" t-ref="autofocus"/>
<i class="oi oi-search input-group-text position-absolute end-0 top-50 me-n3 px-2 py-1 translate-middle bg-transparent border-0" title="Search" role="img" aria-label="Search"/>
</div>`;

View file

@ -0,0 +1,213 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { throttle } from '@web/core/utils/timing';
import { qweb } from 'web.core';
import { Component, useState, useRef, onMounted, onWillStart } from "@odoo/owl";
class VideoOption extends Component {}
VideoOption.template = 'web_editor.VideoOption';
export class VideoSelector extends Component {
setup() {
this.rpc = useService('rpc');
this.http = useService('http');
this.PLATFORMS = {
youtube: 'youtube',
dailymotion: 'dailymotion',
vimeo: 'vimeo',
youku: 'youku',
};
this.OPTIONS = {
autoplay: {
label: this.env._t("Autoplay"),
description: this.env._t("Videos are muted when autoplay is enabled"),
platforms: [this.PLATFORMS.youtube, this.PLATFORMS.dailymotion, this.PLATFORMS.vimeo],
urlParameter: 'autoplay=1',
},
loop: {
label: this.env._t("Loop"),
platforms: [this.PLATFORMS.youtube, this.PLATFORMS.vimeo],
urlParameter: 'loop=1',
},
hide_controls: {
label: this.env._t("Hide player controls"),
platforms: [this.PLATFORMS.youtube, this.PLATFORMS.dailymotion, this.PLATFORMS.vimeo],
urlParameter: 'controls=0',
},
hide_fullscreen: {
label: this.env._t("Hide fullscreen button"),
platforms: [this.PLATFORMS.youtube],
urlParameter: 'fs=0',
isHidden: () => this.state.options.filter(option => option.id === 'hide_controls')[0].value,
},
hide_dm_logo: {
label: this.env._t("Hide Dailymotion logo"),
platforms: [this.PLATFORMS.dailymotion],
urlParameter: 'ui-logo=0',
},
hide_dm_share: {
label: this.env._t("Hide sharing button"),
platforms: [this.PLATFORMS.dailymotion],
urlParameter: 'sharing-enable=0',
},
};
this.state = useState({
options: [],
src: '',
urlInput: '',
platform: null,
vimeoPreviews: [],
errorMessage: '',
});
this.urlInputRef = useRef('url-input');
onWillStart(async () => {
if (this.props.media) {
const src = this.props.media.dataset.oeExpression || this.props.media.dataset.src || (this.props.media.tagName === 'IFRAME' && this.props.media.getAttribute('src')) || '';
if (src) {
this.state.urlInput = src;
await this.updateVideo();
this.state.options = this.state.options.map((option) => {
const { urlParameter } = this.OPTIONS[option.id];
return { ...option, value: src.indexOf(urlParameter) >= 0 };
});
}
}
});
onMounted(async () => {
await Promise.all(this.props.vimeoPreviewIds.map(async (videoId) => {
try {
const { thumbnail_url: thumbnailSrc } = await this.http.get(`https://vimeo.com/api/oembed.json?url=http%3A//vimeo.com/${encodeURIComponent(videoId)}`);
this.state.vimeoPreviews.push({
id: videoId,
thumbnailSrc,
src: `https://player.vimeo.com/video/${encodeURIComponent(videoId)}`
});
} catch (err) {
console.warn(`Could not get video #${videoId} from vimeo: ${err}`);
}
}));
});
this.onChangeUrl = throttle((ev) => this.updateVideo(ev.target.value), 500);
}
get shownOptions() {
if (this.props.isForBgVideo) {
return [];
}
return this.state.options.filter(option => !this.OPTIONS[option.id].isHidden || !this.OPTIONS[option.id].isHidden());
}
async onChangeOption(optionId) {
this.state.options = this.state.options.map(option => {
if (option.id === optionId) {
return { ...option, value: !option.value };
}
return option;
});
await this.updateVideo();
}
async onClickSuggestion(src) {
this.state.urlInput = src;
await this.updateVideo();
}
async updateVideo() {
if (!this.state.urlInput) {
this.state.src = '';
this.state.urlInput = '';
this.state.options = [];
this.state.platform = null;
this.state.errorMessage = '';
return;
}
// Detect if we have an embed code rather than an URL
const embedMatch = this.state.urlInput.match(/(src|href)=["']?([^"']+)?/);
if (embedMatch && embedMatch[2].length > 0 && embedMatch[2].indexOf('instagram')) {
embedMatch[1] = embedMatch[2]; // Instagram embed code is different
}
const url = embedMatch ? embedMatch[1] : this.state.urlInput;
const options = {};
if (this.props.isForBgVideo) {
Object.keys(this.OPTIONS).forEach(key => {
options[key] = true;
});
} else {
for (const option of this.shownOptions) {
options[option.id] = option.value;
}
}
const { embed_url: src, platform } = await this._getVideoURLData(url, options);
if (!src) {
this.state.errorMessage = this.env._t("The provided url is not valid");
} else if (!platform) {
this.state.errorMessage =
this.env._t("The provided url does not reference any supported video");
} else {
this.state.errorMessage = '';
}
this.props.errorMessages(this.state.errorMessage);
const newOptions = [];
if (platform && platform !== this.state.platform) {
Object.keys(this.OPTIONS).forEach(key => {
if (this.OPTIONS[key].platforms.includes(platform)) {
const { label, description } = this.OPTIONS[key];
newOptions.push({ id: key, label, description });
}
});
}
this.state.src = src;
this.props.selectMedia({ id: src, src });
if (platform !== this.state.platform) {
this.state.platform = platform;
this.state.options = newOptions;
}
}
/**
* Keep rpc call in distinct method make it patchable by test.
*/
async _getVideoURLData(url, options) {
return await this.rpc('/web_editor/video_url/data', {
video_url: url,
...options,
});
}
/**
* Utility method, called by the MediaDialog component.
*/
static createElements(selectedMedia) {
return selectedMedia.map(video => {
const template = document.createElement('template');
template.innerHTML = qweb.render('web_editor.videoWrapper', { src: video.src });
return template.content.firstChild;
});
}
}
VideoSelector.mediaSpecificClasses = ['media_iframe_video'];
VideoSelector.mediaSpecificStyles = [];
VideoSelector.mediaExtraClasses = [];
VideoSelector.tagNames = ['IFRAME', 'DIV'];
VideoSelector.template = 'web_editor.VideoSelector';
VideoSelector.components = {
VideoOption,
};
VideoSelector.defaultProps = {
vimeoPreviewIds: [],
isForBgVideo: false,
};

View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<div t-name="web_editor.videoWrapper" t-att-data-oe-expression="src">
<div class="css_editable_mode_display"/>
<div class="media_iframe_video_size" contenteditable="false"/>
<iframe t-att-src="src" frameborder="0" contenteditable="false" allowfullscreen="allowfullscreen"/>
</div>
<t t-name="web_editor.VideoOption" owl="1">
<div class="mb-1">
<label class="o_switch" t-on-change="props.onChangeOption">
<input type="checkbox" t-att-checked="props.value"/><span/>
<span t-esc="props.label"/>
<span t-if="props.description" class="small text-muted ms-auto" t-esc="props.description"/>
</label>
</div>
</t>
<t t-name="web_editor.VideoSelector" owl="1">
<div class="row">
<div class="col mt-4 o_video_dialog_form">
<div class="mb-2">
<label class="col-form-label" for="o_video_text">
<b>Video code </b>(URL or Embed)
</label>
<div class="text-start">
<small class="text-muted">Accepts <b><i>Youtube</i></b>, <b><i>Vimeo</i></b>, <b><i>Dailymotion</i></b> and <b><i>Youku</i></b> videos</small>
</div>
<textarea t-model="state.urlInput" class="form-control" id="o_video_text" placeholder="Copy-paste your URL or embed code here" t-on-input="onChangeUrl" t-att-class="{ 'is-valid': state.urlInput and !this.state.errorMessage, 'is-invalid': state.urlInput and this.state.errorMessage }"/>
</div>
<div t-if="shownOptions.length" class="o_video_dialog_options mt-4">
<VideoOption t-foreach="shownOptions" t-as="option" t-key="option.id"
value="option.value"
onChangeOption="() => this.onChangeOption(option.id)"
label="option.label"
description="option.description"/>
</div>
<t t-if="state.vimeoPreviews.length">
<span class="fw-bold">Suggestions</span>
<div id="video-suggestion" class="mt-4 d-flex flex-wrap mh-75 overflow-auto">
<t t-foreach="state.vimeoPreviews" t-as="vimeoPreview" t-key="vimeoPreview.id">
<div class="o_sample_video w-25 mh-100 cursor-pointer" t-on-click="() => this.onClickSuggestion(vimeoPreview.src)">
<img class="mw-100 mh-100 p-1" t-att-src="vimeoPreview.thumbnailSrc"/>
</div>
</t>
</div>
</t>
</div>
<div class="col-md-6">
<div class="o_video_preview position-relative border-0 p-3">
<div t-if="this.state.src and !this.state.errorMessage" class="o_video_dialog_preview_text mb-2">Preview</div>
<div class="media_iframe_video">
<div class="media_iframe_video_size"/>
<iframe t-if="this.state.src and !this.state.errorMessage" height="720" width="1280" class="o_video_dialog_iframe mw-100 mh-100 overflow-hidden shadow" allowfullscreen="allowfullscreen" frameborder="0" t-att-src="this.state.src"/>
<div t-if="this.state.errorMessage" class="alert alert-warning o_video_dialog_iframe mw-100 mh-100 mb-2 mt-2" t-esc="this.state.errorMessage"/>
</div>
</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,23 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { Component, useState } from "@odoo/owl";
export class ProgressBar extends Component {
get progress() {
return Math.round(this.props.progress);
}
}
ProgressBar.template = 'web_editor.ProgressBar';
export class UploadProgressToast extends Component {
setup() {
this.uploadService = useService('upload');
this.state = useState(this.uploadService.progressToast);
}
}
UploadProgressToast.template = 'web_editor.UploadProgressToast';
UploadProgressToast.components = {
ProgressBar
};

View file

@ -0,0 +1,9 @@
.o_upload_progress_toast {
font-size: 16px;
.o_we_progressbar:last-child {
hr {
display: none;
}
}
}

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.ProgressBar" owl="1">
<small class="text-info d-flex align-items-center me-2">
<span t-if="!props.hasError and !props.uploaded"><i class="fa fa-circle-o-notch fa-spin me-2"/></span>
<span class="fst-italic fw-bold text-truncate flex-grow-1 me-2" t-esc="props.name"/>
<span class="fw-bold text-nowrap" t-esc="props.size"/>
</small>
<small t-if="props.uploaded or props.hasError" class="d-flex align-items-center mt-1">
<span t-if="props.uploaded" class="text-success"><i class="fa fa-check my-1 me-1"/> File has been uploaded</span>
<span t-else="" class="text-danger"><i class="fa fa-times float-start my-1 me-1"/> <span class="o_we_error_text" t-esc="props.errorMessage ? props.errorMessage : 'File could not be saved'"/></span>
</small>
<div t-else="" class="progress">
<div class="progress-bar bg-info progress-bar-striped progress-bar-animated" role="progressbar" t-attf-style="width: {{this.progress}}%;"><span t-esc="this.progress + '%'"/></div>
</div>
<hr/>
</t>
<t t-name="web_editor.UploadProgressToast" owl="1">
<div class="o_notification_manager o_upload_progress_toast">
<div t-if="state.isVisible" class="o_notification position-relative show fade mb-2 border border-info bg-white" role="alert" aria-live="assertive" aria-atomic="true">
<button type="button" class="btn btn-close o_notification_close p-2" aria-label="Close" t-on-click="props.close"/>
<div class="o_notification_body ps-2 pe-4 py-2">
<div class="me-auto o_notification_content">
<div t-foreach="state.files" t-as="file" t-key="file" class="o_we_progressbar">
<ProgressBar progress="file_value.progress"
errorMessage="file_value.errorMessage"
hasError="file_value.hasError"
name="file_value.name"
uploaded="file_value.uploaded"
size="file_value.size"
id="file_value.id"/>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,163 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
import { UploadProgressToast } from './upload_progress_toast';
import { getDataURLFromFile } from 'web.utils';
import { _t } from "@web/core/l10n/translation";
import { checkFileSize } from "@web/core/utils/files";
import { humanNumber } from "@web/core/utils/numbers";
import { sprintf } from "@web/core/utils/strings";
import { reactive } from "@odoo/owl";
export const AUTOCLOSE_DELAY = 3000;
export const uploadService = {
dependencies: ['rpc'],
start(env, { rpc }) {
let fileId = 0;
const progressToast = reactive({
files: {},
isVisible: false,
});
registry.category('main_components').add('UploadProgressToast', {
Component: UploadProgressToast,
props: {
close: () => progressToast.isVisible = false,
}
});
const addFile = (file) => {
progressToast.files[file.id] = file;
progressToast.isVisible = true;
return progressToast.files[file.id];
};
const deleteFile = (fileId) => {
delete progressToast.files[fileId];
if (!Object.keys(progressToast.files).length) {
progressToast.isVisible = false;
}
};
return {
get progressToast() {
return progressToast;
},
get fileId() {
return fileId;
},
addFile,
deleteFile,
incrementId() {
fileId++;
},
uploadUrl: async (url, { resModel, resId }, onUploaded) => {
const attachment = await rpc('/web_editor/attachment/add_url', {
url,
'res_model': resModel,
'res_id': resId,
});
await onUploaded(attachment);
},
/**
* This takes an array of files (from an input HTMLElement), and
* uploads them while managing the UploadProgressToast.
*
* @param {Array<File>} files
* @param {Object} options
* @param {Function} onUploaded
*/
uploadFiles: async (files, {resModel, resId, isImage}, onUploaded) => {
// Upload the smallest file first to block the user the least possible.
const sortedFiles = Array.from(files).sort((a, b) => a.size - b.size);
for (const file of sortedFiles) {
let fileSize = file.size;
if (!checkFileSize(fileSize, env.services.notification)) {
// FIXME
// Note that the notification service is not added as a
// dependency of this service, in order to avoid introducing
// a breaking change in a stable version.
// If the notification service is not available, the
// checkFileSize function will not display any notification
// but will still return the correct value.
return null;
}
if (!fileSize) {
fileSize = null;
} else {
fileSize = humanNumber(fileSize) + "B";
}
const id = ++fileId;
file.progressToastId = id;
// This reactive object, built based on the files array,
// is given as a prop to the UploadProgressToast.
addFile({
id,
name: file.name,
size: fileSize,
progress: 0,
hasError: false,
uploaded: false,
errorMessage: '',
});
}
// Upload one file at a time: no need to parallel as upload is
// limited by bandwidth.
for (const sortedFile of sortedFiles) {
const file = progressToast.files[sortedFile.progressToastId];
let dataURL;
try {
dataURL = await getDataURLFromFile(sortedFile);
} catch {
deleteFile(file.id);
env.services.notification.add(
sprintf(
_t('Could not load the file "%s".'),
sortedFile.name
),
{ type: 'danger' }
);
continue
}
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', ev => {
const rpcComplete = ev.loaded / ev.total * 100;
file.progress = rpcComplete;
});
xhr.upload.addEventListener('load', function () {
// Don't show yet success as backend code only starts now
file.progress = 100;
});
const attachment = await rpc('/web_editor/attachment/add_data', {
'name': file.name,
'data': dataURL.split(',')[1],
'res_id': resId,
'res_model': resModel,
'is_image': !!isImage,
'width': 0,
'quality': 0,
}, {xhr});
if (attachment.error) {
file.hasError = true;
file.errorMessage = attachment.error;
} else {
file.uploaded = true;
await onUploaded(attachment);
}
setTimeout(() => deleteFile(file.id), AUTOCLOSE_DELAY);
} catch (error) {
file.hasError = true;
setTimeout(() => deleteFile(file.id), AUTOCLOSE_DELAY);
throw error;
}
}
}
};
},
};
registry.category('services').add('upload', uploadService);

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,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15">
<g fill="none" class="snippet_disabled">
<path fill="#E16F2B" d="M7.5 0c1.36 0 2.616.335 3.765 1.006a7.466 7.466 0 0 1 2.73 2.73A7.337 7.337 0 0 1 15 7.5c0 1.36-.335 2.616-1.006 3.765a7.466 7.466 0 0 1-2.73 2.73A7.337 7.337 0 0 1 7.5 15a7.337 7.337 0 0 1-3.765-1.006 7.466 7.466 0 0 1-2.73-2.73A7.337 7.337 0 0 1 0 7.5c0-1.36.335-2.616 1.006-3.765a7.466 7.466 0 0 1 2.73-2.73A7.337 7.337 0 0 1 7.5 0z" class="path"/>
<path fill="#E17D41" d="M7.5 1c1.18 0 2.267.29 3.263.872a6.47 6.47 0 0 1 2.365 2.365C13.71 5.233 14 6.321 14 7.5a6.35 6.35 0 0 1-.872 3.263 6.47 6.47 0 0 1-2.365 2.365A6.358 6.358 0 0 1 7.5 14a6.35 6.35 0 0 1-3.263-.872 6.47 6.47 0 0 1-2.365-2.365A6.358 6.358 0 0 1 1 7.5c0-1.18.29-2.267.872-3.263a6.47 6.47 0 0 1 2.365-2.365A6.358 6.358 0 0 1 7.5 1z" class="path"/>
<path fill="#FFF" d="M8.51 10c.09 0 .167.03.23.093a.31.31 0 0 1 .093.23v1.855a.31.31 0 0 1-.093.23.313.313 0 0 1-.23.092h-2a.34.34 0 0 1-.24-.098.3.3 0 0 1-.103-.224v-1.856a.3.3 0 0 1 .104-.224.34.34 0 0 1 .24-.098zm.136-7.5c.097 0 .18.026.25.078A.19.19 0 0 1 9 2.754l-.188 6.064c-.006.065-.043.122-.109.171a.4.4 0 0 1-.245.073H6.531a.423.423 0 0 1-.25-.073c-.07-.049-.104-.106-.104-.17L6 2.753a.19.19 0 0 1 .104-.176.405.405 0 0 1 .25-.078z" class="shape"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="11" viewBox="0 0 14 11">
<g fill="none" fill-rule="evenodd" class="symbols">
<g fill="#D9D9D9" class="shape" transform="translate(-176 -6)">
<g class="group" transform="translate(167)">
<g class="bg_shape" transform="translate(9 6)">
<path d="M12 0a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h10zm0 2l-1.59 1.68a7 7 0 0 1-4.207 2.134l-.155.02A4.967 4.967 0 0 0 2.224 8.55L2 9h10V2z" class="o_graphic"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 557 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<g class="o_overlay_back_front">
<rect x="4" y="4" width="9" height="9" class="o_graphic" fill="#EEE"/>
<rect x="7" y="7" width="9" height="9" class="o_subdle" fill="#AAA"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 293 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<g class="o_overlay_back_front">
<rect x="4" y="4" width="9" height="9" class="o_subdle" fill="#AAA"/>
<rect x="7" y="7" width="9" height="9" class="o_graphic" fill="#EEE"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 275 B

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<g fill="none" fill-rule="evenodd" class="o_overlay_move_drag">
<g fill="#FFF" class="group" transform="translate(3.5 6)">
<polygon points="0 0 0 3 3 3 3 0" class="o_graphic"/>
<polygon points="5 0 5 3 8 3 8 0" class="o_graphic"/>
<polygon points="10 0 10 3 13 3 13 0" class="o_graphic"/>
<polygon points="0 5 0 8 3 8 3 5" class="o_graphic"/>
<polygon points="5 5 5 8 8 8 8 5" class="o_graphic"/>
<polygon points="10 5 10 8 13 8 13 5" class="o_graphic"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 604 B

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="82" height="60" viewBox="0 0 82 60">
<defs>
<rect id="path-1" width="44" height="1" x="0" y="5.5"/>
<filter id="filter-2" width="102.3%" height="300%" x="-1.1%" y="-50%" filterUnits="objectBoundingBox">
<feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
<feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
<feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.292012675 0"/>
</filter>
</defs>
<g fill="none" fill-rule="evenodd" class="snippets_thumbs">
<g class="s_hr">
<rect width="82" height="60" class="bg"/>
<g class="group" transform="translate(19 24)">
<g class="rectangle">
<use fill="#000" filter="url(#filter-2)" xlink:href="#path-1"/>
<use fill="#FFF" fill-opacity=".78" xlink:href="#path-1"/>
</g>
<path fill="#FFF" stroke="#FFF" d="M25.925 10.5L22 13.64l-3.925-3.14h7.85zm0-8h-7.85L22-.64l3.925 3.14z" class="combined_shape"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,230 @@
/** @odoo-module **/
import { ancestors } from '@web_editor/js/common/wysiwyg_utils';
export class QWebPlugin {
constructor(options = {}) {
this._options = options;
if (this._options.editor) {
this._editable = this._options.editor.editable;
this._document = this._options.editor.document;
} else {
this._editable = this._options.editable;
this._document = this._options.document || window.document;
}
this._editable = this._options.editable || (this._options.editor && this._options.editor.editable);
this._document = this._options.document || (this._options.editor && this._options.editor.document) || window.document;
this._tGroupCount = 0;
this._hideBranchingSelection = this._hideBranchingSelection.bind(this);
this._makeBranchingSelection();
this._clickListeners = [];
}
destroy() {
this._selectElWrapper.remove();
for (const listener of this._clickListeners) {
document.removeEventListener('click', listener);
}
}
cleanForSave(editable) {
for (const node of editable.querySelectorAll('[data-oe-t-group], [data-oe-t-inline], [data-oe-t-selectable], [data-oe-t-group-active]')) {
node.removeAttribute('data-oe-t-group-active');
node.removeAttribute('data-oe-t-group');
node.removeAttribute('data-oe-t-inline');
node.removeAttribute('data-oe-t-selectable');
}
}
sanitizeElement(subRoot) {
if (subRoot.nodeType !== Node.ELEMENT_NODE) {
return;
}
if (this._options.editor) {
this._options.editor.observerUnactive('qweb-plugin-sanitize');
}
this._fixInlines(subRoot);
const demoElements = subRoot.querySelectorAll('[t-esc], [t-raw], [t-out]');
for (const element of demoElements) {
element.setAttribute('contenteditable', 'false');
}
this._groupQwebBranching(subRoot);
if (this._options.editor) {
this._options.editor.observerActive('qweb-plugin-sanitize');
}
}
_groupQwebBranching(subRoot) {
const tNodes = subRoot.querySelectorAll('[t-if], [t-elif], [t-else]');
const groupsEncounter = new Set();
for (const node of tNodes) {
const parentTNode = [...node.parentElement.children];
const index = parentTNode.indexOf(node);
const prevNode = parentTNode[index - 1];
let groupId;
if (
prevNode &&
node.previousElementSibling === prevNode &&
!node.hasAttribute('t-if')
) {
// Make the first t-if selectable, if prevNode is not a t-if,
// it's already data-oe-t-selectable.
prevNode.setAttribute('data-oe-t-selectable', 'true');
groupId = parseInt(prevNode.getAttribute('data-oe-t-group'));
node.setAttribute('data-oe-t-selectable', 'true');
} else {
groupId = this._tGroupCount++;
}
groupsEncounter.add(groupId);
node.setAttribute('data-oe-t-group', groupId);
const clickListener = e => {
e.stopImmediatePropagation();
this._showBranchingSelection(node);
};
this._clickListeners.push(clickListener);
node.addEventListener('click', clickListener);
}
for (const groupId of groupsEncounter) {
const isOneElementActive = subRoot.querySelector(
`[data-oe-t-group='${groupId}'][data-oe-t-group-active]`,
);
// If there is no element in groupId activated, activate the first
// one.
if (!isOneElementActive) {
subRoot
.querySelector(`[data-oe-t-group='${groupId}']`)
.setAttribute('data-oe-t-group-active', 'true');
}
}
}
_fixInlines(subRoot) {
const checkAllInline = el => {
return [...el.children].every(child => {
if (child.tagName === 'T') {
return checkAllInline(child);
} else {
return (
child.nodeType !== Node.ELEMENT_NODE ||
window.getComputedStyle(child).display === 'inline'
);
}
});
};
const tElements = subRoot.querySelectorAll('t');
// Wait for the content to be on the dom to check checkAllInline
// otherwise the getComputedStyle will be wrong.
// todo: remove the setTimeout when the editor will provide a signal
// that the editable is on the dom.
setTimeout(() => {
if (this._options.editor) {
this._options.editor.observerUnactive('qweb-plugin-checkAllInline');
}
for (const tElement of tElements) {
if (checkAllInline(tElement)) {
tElement.setAttribute('data-oe-t-inline', 'true');
}
}
if (this._options.editor) {
this._options.editor.observerActive('qweb-plugin-checkAllInline');
}
});
}
_makeBranchingSelection() {
const document = this._options.document || window.document;
this._selectElWrapper = document.createElement('div');
this._selectElWrapper.classList.add('oe-qweb-select');
this._selectElWrapper.innerHTML = '';
document.body.append(this._selectElWrapper);
this._hideBranchingSelection();
}
_showBranchingSelection(target) {
this._hideBranchingSelection();
const branchingHierarchyElements = [target, ...ancestors(target, this._editable)]
.filter(element => element.getAttribute('data-oe-t-group-active') === 'true')
.filter(element => {
const itemGroupId = element.getAttribute('data-oe-t-group');
const groupItemsNodes = element.parentElement.querySelectorAll(
`[data-oe-t-group='${itemGroupId}']`,
);
return groupItemsNodes.length > 1;
});
if (!branchingHierarchyElements.length) return;
const groupsActive = branchingHierarchyElements.map(node =>
node.getAttribute('data-oe-t-group'),
);
for (const branchingElement of branchingHierarchyElements) {
this._selectElWrapper.prepend(this._renderBranchingSelection(branchingElement));
}
const closeSelectHandler = event => {
const path = [event.target, ...ancestors(event.target)];
const shouldClose = !path.find(
element =>
element === this._selectElWrapper ||
groupsActive.includes(element.getAttribute('data-oe-t-group')),
);
if (shouldClose) {
this._hideBranchingSelection();
document.removeEventListener('mousedown', closeSelectHandler);
}
};
document.addEventListener('mousedown', closeSelectHandler);
this._selectElWrapper.style.display = 'flex';
this._updateBranchingSelectionPosition(
branchingHierarchyElements[branchingHierarchyElements.length - 1],
);
}
_updateBranchingSelectionPosition(target) {
window.addEventListener('mousewheel', this._hideBranchingSelection);
const box = target.getBoundingClientRect();
const selBox = this._selectElWrapper.getBoundingClientRect();
this._selectElWrapper.style.left = `${window.scrollX + box.left}px`;
this._selectElWrapper.style.top = `${window.scrollY + box.top - selBox.height}px`;
}
_renderBranchingSelection(target) {
const selectEl = document.createElement('select');
const groupId = parseInt(target.getAttribute('data-oe-t-group'));
const groupElements = target.parentElement.querySelectorAll(
`[data-oe-t-group='${groupId}']`,
);
for (const element of groupElements) {
const optionElement = document.createElement('option');
if (element.hasAttribute('t-if')) {
optionElement.innerText = 'if';
} else if (element.hasAttribute('t-elif')) {
optionElement.innerText = 'elif';
} else if (element.hasAttribute('t-else')) {
optionElement.innerText = 'else';
}
if (element.hasAttribute('data-oe-t-group-active')) {
optionElement.selected = true;
}
selectEl.appendChild(optionElement);
}
selectEl.onchange = () => {
let activeElement;
for (let i = 0; i < groupElements.length; i++) {
if (i === selectEl.selectedIndex) {
activeElement = groupElements[i];
groupElements[i].setAttribute('data-oe-t-group-active', 'true');
} else {
groupElements[i].removeAttribute('data-oe-t-group-active');
}
}
this._showBranchingSelection(activeElement);
};
return selectEl;
}
_hideBranchingSelection() {
this._selectElWrapper.style.display = 'none';
this._selectElWrapper.innerHTML = ``;
window.removeEventListener('mousewheel', this._hideBranchingSelection);
}
}

View file

@ -0,0 +1,6 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
const commandCategoryRegistry = registry.category("command_categories");
commandCategoryRegistry.add("shortcut_conflict", {}, { sequence: 5 });

View file

@ -0,0 +1,700 @@
/** @odoo-module alias=web_editor.field.html */
'use strict';
import ajax from 'web.ajax';
import basic_fields from 'web.basic_fields';
import core from 'web.core';
import wysiwygLoader from 'web_editor.loader';
import field_registry from 'web.field_registry';
import {QWebPlugin} from '@web_editor/js/backend/QWebPlugin';
import {
getAdjacentPreviousSiblings,
getAdjacentNextSiblings,
setSelection,
rightPos,
getRangePosition
} from '../editor/odoo-editor/src/utils/utils';
// must wait for web/ to add the default html widget, otherwise it would override the web_editor one
import 'web._field_registry';
import "@web/views/fields/html/html_field"; // make sure the html field file has first been executed.
var _lt = core._lt;
var _t = core._t;
var TranslatableFieldMixin = basic_fields.TranslatableFieldMixin;
var DynamicPlaceholderFieldMixin = basic_fields.DynamicPlaceholderFieldMixin;
var QWeb = core.qweb;
/**
* FieldHtml Widget
* Intended to display HTML content. This widget uses the wysiwyg editor
* improved by odoo.
*
* nodeOptions:
* - style-inline => convert class to inline style (no re-edition) => for sending by email
* - no-attachment
* - cssEdit
* - cssReadonly
* - snippets
* - wrapper
* - resizable
* - codeview
*/
var FieldHtml = basic_fields.DebouncedField.extend(DynamicPlaceholderFieldMixin).extend(TranslatableFieldMixin, {
description: _lt("Html"),
className: 'oe_form_field oe_form_field_html',
supportedFieldTypes: ['html'],
isQuickEditable: true,
quickEditExclusion: [
'[href]',
],
custom_events: {
wysiwyg_focus: '_onWysiwygFocus',
wysiwyg_blur: '_onWysiwygBlur',
wysiwyg_change: '_onChange',
wysiwyg_attachment: '_onAttachmentChange',
},
/**
* Position the Model Selector Popover with respect to the
* wysiwyg current range.
*
* @override
* @param {ModelFieldSelectorPopover} modelSelector
*/
positionModelSelector: async function (modelSelector) {
// Let the default positioning do its thing.
await this._super.apply(this, arguments);
let topPosition = parseInt(modelSelector.el.style.top.replace('px', ''));
let leftPosition = parseInt(modelSelector.el.style.left.replace('px', ''));
// Bring back the top position to the top of the editable.
topPosition -= this.el.offsetHeight;
// Offsets to the top and left position to match the editable selection.
const position = getRangePosition(modelSelector.$el, this.wysiwyg.options.document);
topPosition += position.top;
// Offset the popover to ensure the arrow is pointing at
// the precise range location.
leftPosition += position.left - 29;
// Restrict the PopOver to visible area,
// and ensure the popover is not too close to the edges of the window.
topPosition = Math.min(topPosition, window.window.innerHeight - modelSelector.el.offsetHeight - 15);
leftPosition = Math.min(leftPosition, window.window.innerWidth - modelSelector.el.offsetWidth - 10);
// Apply the position back to the element.
modelSelector.el.style.top = topPosition + 'px';
modelSelector.el.style.left = leftPosition + 'px';
},
/**
* Open a Model Field Selector which can select fields
* to create a dynamic placeholder <t-out> Element in the field HTML
* with or without a default text value.
*
* @override
* @public
* @param {String} baseModel
* @param {Array} chain
*
*/
openDynamicPlaceholder: async function (baseModel, chain = []) {
let modelSelector;
const onFieldChanged = (ev) => {
this.wysiwyg.odooEditor.editable.focus();
if (ev.data.chain.length) {
let dynamicPlaceholder = "object." + ev.data.chain.join('.');
const defaultValue = ev.data.defaultValue;
dynamicPlaceholder += defaultValue && defaultValue !== '' ? ` or '''${defaultValue}'''` : '';
const t = document.createElement('T');
t.setAttribute('t-out', dynamicPlaceholder);
this.wysiwyg.odooEditor.execCommand('insert', t);
setSelection(...rightPos(t));
this.wysiwyg.odooEditor.editable.focus();
}
modelSelector.destroy();
};
const onFieldCancel = () => {
this.wysiwyg.odooEditor.editable.focus();
modelSelector.destroy();
};
modelSelector = await this._openNewModelSelector(
baseModel, chain, onFieldChanged, onFieldCancel
);
},
/**
* @override
*/
willStart: async function () {
this.isRendered = false;
this._onUpdateIframeId = 'onLoad_' + _.uniqueId('FieldHtml');
await this._super();
if (this.nodeOptions.cssReadonly) {
this.cssReadonly = await ajax.loadAsset(this.nodeOptions.cssReadonly);
}
if (this.nodeOptions.cssEdit || this.nodeOptions['style-inline']) {
this.cssEdit = await ajax.loadAsset(this.nodeOptions.cssEdit || 'web_editor.assets_edit_html_field');
}
},
/**
* @override
*/
destroy: function () {
delete window.top[this._onUpdateIframeId];
if (this.$iframe) {
this.$iframe.remove();
}
if (this._qwebPlugin) {
this._qwebPlugin.destroy();
}
this._super();
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
activate: function (options) {
if (this.wysiwyg) {
this.wysiwyg.focus();
return true;
}
},
/**
* Wysiwyg doesn't notify for changes done in code mode. We override
* commitChanges to manually switch back to normal mode before committing
* changes, so that the widget is aware of the changes done in code mode.
*
* @override
*/
commitChanges: async function () {
if (this.mode == "readonly" || !this.isRendered) {
return this._super();
}
var _super = this._super.bind(this);
// Do not wait for the resolution of the cleanForSave promise to update
// the internal value in case this happens during an urgentSave as the
// beforeunload event does not play well with asynchronicity. It is
// better to have a partially cleared value than to lose changes. When
// this function is called outside of an urgentSave context, the full
// cleaning is still awaited below and `_super` will reupdate the value.
const fullClean = this.wysiwyg.cleanForSave();
this._setValue(this._getValue());
this._isDirty = this.wysiwyg.isDirty();
await fullClean;
await this.wysiwyg.saveModifiedImages(this.$content);
// Update the value to the fully cleaned version.
this._setValue(this._getValue());
_super();
},
/**
* @override
*/
isSet: function () {
var value = this.value && this.value.split('&nbsp;').join('').replace(/\s/g, ''); // Removing spaces & html spaces
return value && value !== "<p></p>" && value !== "<p><br></p>" && value.match(/\S/);
},
/**
* @override
*/
getFocusableElement: function () {
return this.wysiwyg && this.wysiwyg.$editable || $();
},
/**
* Do not re-render this field if it was the origin of the onchange call.
*
* @override
*/
reset: function (record, event) {
this._reset(record, event);
var value = this.value;
if (this.nodeOptions.wrapper) {
value = this._wrap(value);
}
value = this._textToHtml(value);
if (!event || event.target !== this) {
if (this.mode === 'edit' && this.wysiwyg) {
this.wysiwyg.setValue(value);
} else if (this.cssReadonly) {
return Promise.resolve();
} else {
this.$content.html(value);
}
}
return Promise.resolve();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_getValue: function () {
let value;
if (!this._$codeview || this._$codeview.hasClass('d-none')) {
value = this.wysiwyg.getValue();
} else {
value = this._$codeview.val();
}
if (this.nodeOptions.wrapper) {
return this._unWrap(value);
}
return value;
},
/**
* Create the wysiwyg instance with the target (this.$target)
* then add the editable content (this.$content).
*
* @private
* @returns {$.Promise}
*/
_createWysiwygInstance: async function () {
const Wysiwyg = await wysiwygLoader.getWysiwygClass();
this.wysiwyg = new Wysiwyg(this, this._getWysiwygOptions());
return this.wysiwyg.appendTo(this.$el).then(() => {
this.$content = this.wysiwyg.$editable;
this._onLoadWysiwyg();
this.isRendered = true;
});
},
/**
* Get wysiwyg options to create wysiwyg instance.
*
* @private
* @returns {Object}
*/
_getWysiwygOptions: function () {
const wysiwygOptions = {
recordInfo: {
context: this.record.getContext(this.recordParams),
res_model: this.model,
res_id: this.res_id,
},
placeholder: this.attrs && this.attrs.placeholder,
collaborationChannel: !!this.nodeOptions.collaborative && {
collaborationModelName: this.model,
collaborationFieldName: this.name,
collaborationResId: parseInt(this.res_id),
},
noAttachment: this.nodeOptions['no-attachment'],
inIframe: !!this.nodeOptions.cssEdit,
iframeCssAssets: this.nodeOptions.cssEdit,
snippets: this.nodeOptions.snippets,
value: this.value,
mediaModalParams: {
noVideos: 'noVideos' in this.nodeOptions ? this.nodeOptions.noVideos : true,
useMediaLibrary: true,
},
linkForceNewWindow: true,
tabsize: 0,
height: this.nodeOptions.height,
minHeight: this.nodeOptions.minHeight,
maxHeight: this.nodeOptions.maxHeight,
resizable: 'resizable' in this.nodeOptions ? this.nodeOptions.resizable : false,
editorPlugins: [QWebPlugin],
};
if ('allowCommandImage' in this.nodeOptions) {
// Set the option only if it is explicitly set in the view so a
// default can be set elsewhere otherwise.
wysiwygOptions.allowCommandImage = Boolean(this.nodeOptions.allowCommandImage);
}
if (this.field.sanitize_tags || (this.field.sanitize_tags === undefined && this.field.sanitize)) {
wysiwygOptions.allowCommandVideo = false; // Tag-sanitized fields remove videos.
} else if ('allowCommandVideo' in this.nodeOptions) {
// Set the option only if it is explicitly set in the view so a
// default can be set elsewhere otherwise.
wysiwygOptions.allowCommandVideo = Boolean(this.nodeOptions.allowCommandVideo);
}
return Object.assign({}, this.nodeOptions, wysiwygOptions);
},
/**
* Toggle the code view and update the UI.
*
* @param {JQuery} $codeview
*/
_toggleCodeView: function ($codeview) {
this.wysiwyg.odooEditor.observerUnactive();
$codeview.toggleClass('d-none');
this.$content.toggleClass('d-none');
if ($codeview.hasClass('d-none')) {
this.wysiwyg.odooEditor.observerActive();
this.wysiwyg.setValue($codeview.val());
this.wysiwyg.odooEditor.sanitize();
this.wysiwyg.odooEditor.historyStep(true);
} else {
$codeview.val(this.$content.html());
this.wysiwyg.odooEditor.observerActive();
}
},
/**
* trigger_up 'field_changed' add record into the "ir.attachment" field found in the view.
* This method is called when an image is uploaded via the media dialog.
*
* For e.g. when sending email, this allows people to add attachments with the content
* editor interface and that they appear in the attachment list.
* The new documents being attached to the email, they will not be erased by the CRON
* when closing the wizard.
*
* @private
* @param {Object} event the event containing attachment data
*/
_onAttachmentChange: function (event) {
// This only needs to happen for the composer for now
if (!this.fieldNameAttachment || this.model !== 'mail.compose.message') {
return;
}
const attachments = event.data;
this.trigger_up('field_changed', {
dataPointID: this.dataPointID,
changes: _.object([this.fieldNameAttachment], [{
operation: 'ADD_M2M',
ids: attachments
}])
});
},
/**
* @override
*/
_renderEdit: function () {
if (this.nodeOptions.notEditable) {
return this._renderReadonly();
}
var fieldNameAttachment = _.chain(this.recordData)
.pairs()
.find(function (value) {
return _.isObject(value[1]) && value[1].model === "ir.attachment";
})
.first()
.value();
if (fieldNameAttachment) {
this.fieldNameAttachment = fieldNameAttachment;
}
if (this.nodeOptions.cssEdit) {
// must be async because the target must be append in the DOM
this._createWysiwygInstance();
} else {
return this._createWysiwygInstance();
}
},
/**
* @override
*/
_renderReadonly: function () {
var self = this;
var value = this._textToHtml(this.value);
if (this.nodeOptions.wrapper) {
value = this._wrap(value);
}
this.$el.empty();
var resolver;
var def = new Promise(function (resolve) {
resolver = resolve;
});
const externalLinkSelector = `a:not([href^="${location.origin}"]):not([href^="/"])`;
if (this.nodeOptions.cssReadonly) {
this.$iframe = $('<iframe class="o_readonly d-none"/>');
this.$iframe.appendTo(this.$el);
var avoidDoubleLoad = 0; // this bug only appears on some computers with some chrome version.
// inject content in iframe
this.$iframe.data('loadDef', def); // for unit test
window.top[this._onUpdateIframeId] = function (_avoidDoubleLoad) {
if (_avoidDoubleLoad !== avoidDoubleLoad) {
console.warn('Wysiwyg iframe double load detected');
return;
}
self.$content = $('#iframe_target', self.$iframe[0].contentWindow.document.body);
resolver();
self.trigger_up('iframe_updated', { $iframe: self.$iframe });
};
this.$iframe.on('load', function onLoad() {
var _avoidDoubleLoad = ++avoidDoubleLoad;
ajax.loadAsset(self.nodeOptions.cssReadonly).then(function (asset) {
if (_avoidDoubleLoad !== avoidDoubleLoad) {
console.warn('Wysiwyg immediate iframe double load detected');
return;
}
var cwindow = self.$iframe[0].contentWindow;
try {
cwindow.document;
} catch (_e) {
return;
}
cwindow.document
.open("text/html", "replace")
.write(
'<!DOCTYPE html><html>' +
'<head>' +
'<meta charset="utf-8"/>' +
'<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n' +
'<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>\n' +
_.map(asset.cssLibs, function (cssLib) {
return '<link type="text/css" rel="stylesheet" href="' + cssLib + '"/>';
}).join('\n') + '\n' +
_.map(asset.cssContents, function (cssContent) {
return '<style type="text/css">' + cssContent + '</style>';
}).join('\n') + '\n' +
'</head>\n' +
'<body class="o_in_iframe o_readonly" style="overflow: hidden;">\n' +
'<div id="iframe_target">' + value + '</div>\n' +
'<script type="text/javascript">' +
'if (window.top.' + self._onUpdateIframeId + ') {' +
'window.top.' + self._onUpdateIframeId + '(' + _avoidDoubleLoad + ')' +
'}' +
'</script>\n' +
'</body>' +
'</html>');
var height = cwindow.document.body.scrollHeight;
self.$iframe.css('height', Math.max(30, Math.min(height, 500)) + 'px');
$(cwindow).on('click', function (ev) {
if (!ev.target.closest("[href]")) {
self._onClick(ev);
}
});
// Ensure all external links are opened in a new tab.
for (const externalLink of cwindow.document.body.querySelectorAll(externalLinkSelector)) {
externalLink.setAttribute('target', '_blank');
externalLink.setAttribute('rel', 'noreferrer');
}
});
});
} else {
this.$content = $('<div class="o_readonly"/>').html(value);
this.$content.appendTo(this.$el);
this._qwebPlugin = new QWebPlugin();
this._qwebPlugin.sanitizeElement(this.$content[0]);
// Ensure all external links are opened in a new tab.
for (const externalLink of this.$content.find(externalLinkSelector)) {
externalLink.setAttribute('target', '_blank');
externalLink.setAttribute('rel', 'noreferrer');
}
resolver();
}
def.then(function () {
if (!self.hasReadonlyModifier) {
self.$content.on('click', 'ul.o_checklist > li', self._onReadonlyClickChecklist.bind(self));
self.$content.on('click', '.o_stars .fa-star, .o_stars .fa-star-o', self._onReadonlyClickStar.bind(self));
}
if (self.$iframe) {
// Iframe is hidden until fully loaded to avoid glitches.
self.$iframe.removeClass('d-none');
}
});
},
/**
* @private
* @param {string} text
* @returns {string} the text converted to html
*/
_textToHtml: function (text) {
var value = text || "";
try {
$(text)[0].innerHTML; // crashes if text isn't html
} catch (_e) {
if (value.match(/^\s*$/)) {
value = '<p><br/></p>';
} else {
value = "<p>" + value.split(/<br\/?>/).join("<br/></p><p>") + "</p>";
value = value
.replace(/<p><\/p>/g, '')
.replace('<p><p>', '<p>')
.replace('<p><p ', '<p ')
.replace('</p></p>', '</p>');
}
}
return value;
},
/**
* Move HTML contents out of their wrapper.
*
* @private
* @param {string} html content
* @returns {string} html content
*/
_unWrap: function (html) {
var $wrapper = $(html).find('#wrapper');
return $wrapper.length ? $wrapper.html() : html;
},
/**
* Wrap HTML in order to create a custom display.
*
* The wrapper (this.nodeOptions.wrapper) must be a static
* XML template with content id="wrapper".
*
* @private
* @param {string} html content
* @returns {string} html content
*/
_wrap: function (html) {
return $(QWeb.render(this.nodeOptions.wrapper))
.find('#wrapper').html(html)
.end().prop('outerHTML');
},
//--------------------------------------------------------------------------
// Handler
//--------------------------------------------------------------------------
/**
* Method called when wysiwyg triggers a change.
*
* @private
* @param {OdooEvent} ev
*/
_onChange: function (ev) {
this._doDebouncedAction.apply(this, arguments);
},
/**
* Allows Enter keypress in a textarea (source mode)
*
* @private
* @param {OdooEvent} ev
*/
_onKeydown: function (ev) {
if (ev.which === $.ui.keyCode.ENTER) {
ev.stopPropagation();
return;
}
this._super.apply(this, arguments);
},
/**
* Method called when wysiwyg triggers a change.
*
* @private
* @param {OdooEvent} ev
*/
_onReadonlyClickChecklist: function (ev) {
const self = this;
if (ev.offsetX > 0) {
return;
}
ev.stopPropagation();
ev.preventDefault();
const checked = $(ev.target).hasClass('o_checked');
let checklistId = $(ev.target).attr('id');
checklistId = checklistId && checklistId.replace('checkId-', '');
checklistId = parseInt(checklistId || '0');
this._rpc({
route: '/web_editor/checklist',
params: {
res_model: this.model,
res_id: this.res_id,
filename: this.name,
checklistId: checklistId,
checked: !checked,
},
}).then(function (value) {
self._setValue(value);
});
},
/**
* Check stars on click event in readonly.
*
* @private
* @param {OdooEvent} ev
*/
_onReadonlyClickStar: function (ev) {
ev.stopPropagation();
ev.preventDefault();
const node = ev.target;
const previousStars = getAdjacentPreviousSiblings(node, sib => (
sib.nodeType === Node.ELEMENT_NODE && sib.className.includes('fa-star')
));
const nextStars = getAdjacentNextSiblings(node, sib => (
sib.nodeType === Node.ELEMENT_NODE && sib.classList.contains('fa-star')
));
const shouldToggleOff = node.classList.contains('fa-star') && !nextStars.length;
const rating = shouldToggleOff ? 0 : previousStars.length + 1;
let starsId = $(node).parent().attr('id');
starsId = starsId && starsId.replace('checkId-', '');
starsId = parseInt(starsId || '0');
this._rpc({
route: '/web_editor/stars',
params: {
res_model: this.model,
res_id: this.res_id,
filename: this.name,
starsId,
rating,
},
}).then(value => this._setValue(value));
},
/**
* Method called when the wysiwyg instance is loaded.
*
* @private
*/
_onLoadWysiwyg: function () {
var $button = this._renderTranslateButton();
var $container;
if (this.nodeOptions.cssEdit && this.wysiwyg) {
$container = this.wysiwyg.$iframeBody.find('.email_designer_top_actions');
} else {
$container = this.$el;
$button.css({
'font-size': '15px',
position: 'absolute',
top: '5px',
[_t.database.parameters.direction === 'rtl' ? 'left' : 'right']: odoo.debug && this.nodeOptions.codeview ? '40px' : '5px',
});
}
$container.append($button);
if (odoo.debug && this.nodeOptions.codeview) {
const $codeviewButtonToolbar = $(`
<div id="codeview-btn-group" class="btn-group">
<button class="o_codeview_btn btn btn-primary">
<i class="fa fa-code"></i>
</button>
</div>
`);
this.$floatingCodeViewButton = $codeviewButtonToolbar.clone();
this._$codeview = $('<textarea class="o_codeview d-none"/>');
this.wysiwyg.$editable.after(this._$codeview);
this._$codeview.after(this.$floatingCodeViewButton);
this.wysiwyg.toolbar.$el.append($codeviewButtonToolbar);
$codeviewButtonToolbar.click(() => this._toggleCodeView(this._$codeview));
this.$floatingCodeViewButton.click(() => this._toggleCodeView(this._$codeview));
}
},
/**
* @private
* @param {OdooEvent} ev
*/
_onWysiwygBlur: function (ev) {
ev.stopPropagation();
this._doAction();
if (ev.data.key === 'TAB') {
this.trigger_up('navigation_move', {
direction: ev.data.shiftKey ? 'left' : 'right',
});
}
},
/**
* @private
* @param {OdooEvent} ev
*/
_onWysiwygFocus: function (ev) {},
});
field_registry.add('html', FieldHtml);
export default FieldHtml;

View file

@ -0,0 +1,757 @@
/** @odoo-module **/
import legacyEnv from 'web.commonEnv';
import { ComponentAdapter } from 'web.OwlCompatibility';
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { getWysiwygClass } from 'web_editor.loader';
import { QWebPlugin } from '@web_editor/js/backend/QWebPlugin';
import { TranslationButton } from "@web/views/fields/translation_button";
import { useDynamicPlaceholder } from "@web/views/fields/dynamicplaceholder_hook";
import { QWeb } from 'web.core';
import ajax from 'web.ajax';
import {
useBus,
useService,
} from "@web/core/utils/hooks";
import {
getAdjacentPreviousSiblings,
getAdjacentNextSiblings,
getRangePosition
} from '@web_editor/js/editor/odoo-editor/src/utils/utils';
import { toInline } from 'web_editor.convertInline';
import {
markup,
Component,
useRef,
useSubEnv,
useState,
onWillStart,
onMounted,
onWillUpdateProps,
useEffect,
onWillUnmount,
} from "@odoo/owl";
export class HtmlFieldWysiwygAdapterComponent extends ComponentAdapter {
setup() {
super.setup();
useSubEnv(legacyEnv);
let started = false;
onMounted(() => {
if (!started) {
this.props.startWysiwyg(this.widget);
started = true;
}
});
}
updateWidget(newProps) {
const lastValue = String(this.props.widgetArgs[0].value || '');
const lastRecordInfo = this.props.widgetArgs[0].recordInfo;
const lastCollaborationChannel = this.props.widgetArgs[0].collaborationChannel;
const newValue = String(newProps.widgetArgs[0].value || '');
const newRecordInfo = newProps.widgetArgs[0].recordInfo;
const newCollaborationChannel = newProps.widgetArgs[0].collaborationChannel;
if (
(
stripHistoryIds(newValue) !== stripHistoryIds(newProps.editingValue) &&
stripHistoryIds(lastValue) !== stripHistoryIds(newValue)
) ||
!_.isEqual(lastRecordInfo, newRecordInfo) ||
!_.isEqual(lastCollaborationChannel, newCollaborationChannel))
{
this.widget.resetEditor(newValue, newProps.widgetArgs[0]);
this.env.onWysiwygReset && this.env.onWysiwygReset();
}
}
renderWidget() {}
}
export class HtmlField extends Component {
setup() {
this.containsComplexHTML = this.computeContainsComplexHTML();
this.sandboxedPreview = this.props.sandboxedPreview || this.containsComplexHTML;
this.readonlyElementRef = useRef("readonlyElement");
this.codeViewRef = useRef("codeView");
this.iframeRef = useRef("iframe");
this.codeViewButtonRef = useRef("codeViewButton");
if (this.props.dynamicPlaceholder) {
this.dynamicPlaceholder = useDynamicPlaceholder();
}
this.rpc = useService("rpc");
this.onIframeUpdated = this.env.onIframeUpdated || (() => {});
this.state = useState({
showCodeView: false,
iframeVisible: false,
});
useBus(this.env.bus, "RELATIONAL_MODEL:WILL_SAVE_URGENTLY", () => this.commitChanges({ urgent: true }));
useBus(this.env.bus, "RELATIONAL_MODEL:NEED_LOCAL_CHANGES", ({ detail }) => detail.proms.push(this.commitChanges({ shouldInline: true })));
this._onUpdateIframeId = 'onLoad_' + _.uniqueId('FieldHtml');
onWillStart(async () => {
this.Wysiwyg = await this._getWysiwygClass();
if (this.props.cssReadonlyAssetId) {
this.cssReadonlyAsset = await ajax.loadAsset(this.props.cssReadonlyAssetId);
}
if (this.props.cssEditAssetId || this.props.isInlineStyle) {
this.cssEditAsset = await ajax.loadAsset(this.props.cssEditAssetId || 'web_editor.assets_edit_html_field');
}
});
this._lastRecordInfo = {
res_model: this.props.record.resModel,
res_id: this.props.record.resId,
};
onWillUpdateProps((newProps) => {
if (!newProps.readonly && !this.sandboxedPreview && this.state.iframeVisible) {
this.state.iframeVisible = false;
}
const newRecordInfo = {
res_model: newProps.record.resModel,
res_id: newProps.record.resId,
};
if (!_.isEqual(this._lastRecordInfo, newRecordInfo)) {
this.currentEditingValue = undefined;
}
this._lastRecordInfo = newRecordInfo;
});
useEffect(() => {
(async () => {
if (this._qwebPlugin) {
this._qwebPlugin.destroy();
}
if (this.props.readonly || (!this.state.showCodeView && this.sandboxedPreview)) {
if (this.showIframe) {
await this._setupReadonlyIframe();
} else if (this.readonlyElementRef.el) {
this._qwebPlugin = new QWebPlugin();
this._qwebPlugin.sanitizeElement(this.readonlyElementRef.el);
// Ensure all external links are opened in a new tab.
retargetLinks(this.readonlyElementRef.el);
const hasReadonlyModifiers = Boolean(this.props.record.isReadonly(this.props.fieldName));
if (!hasReadonlyModifiers) {
const $el = $(this.readonlyElementRef.el);
$el.off('.checklistBinding');
$el.on('click.checklistBinding', 'ul.o_checklist > li', this._onReadonlyClickChecklist.bind(this));
$el.on('click.checklistBinding', '.o_stars .fa-star, .o_stars .fa-star-o', this._onReadonlyClickStar.bind(this));
}
}
} else {
const codeViewEl = this._getCodeViewEl();
if (codeViewEl) {
codeViewEl.value = this.props.value;
}
}
})();
});
onWillUnmount(() => {
if (!this.props.readonly && this._isDirty()) {
// If we still have uncommited changes, commit them with the
// urgent flag to avoid losing them. Urgent flag is used to be
// able to save the changes before the component is destroyed
// by the owl component manager.
this.commitChanges({ urgent: true });
}
if (this._qwebPlugin) {
this._qwebPlugin.destroy();
}
if (this.resizerHandleObserver) {
this.resizerHandleObserver.disconnect();
}
});
}
/**
* 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.
*/
computeContainsComplexHTML() {
const domParser = new DOMParser();
const parsedOriginal = domParser.parseFromString(this.props.value || '', 'text/html');
return !!parsedOriginal.head.innerHTML.trim();
}
get markupValue () {
return markup(this.props.value);
}
get showIframe () {
return (this.sandboxedPreview && !this.state.showCodeView) || (this.props.readonly && this.props.cssReadonlyAssetId);
}
get wysiwygOptions() {
let dynamicPlaceholderOptions = {};
if (this.props.dynamicPlaceholder) {
dynamicPlaceholderOptions = {
// Add the powerbox option to open the Dynamic Placeholder
// generator.
powerboxCommands: [
{
category: this.env._t('Marketing Tools'),
name: this.env._t('Dynamic Placeholder'),
priority: 10,
description: this.env._t('Insert personalized content'),
fontawesome: 'fa-magic',
callback: () => {
this.wysiwygRangePosition = getRangePosition(document.createElement('x'), this.wysiwyg.options.document || document);
const baseModel = this.props.record.data.mailing_model_real || this.props.record.data.model;
if (baseModel) {
// The method openDynamicPlaceholder need to be triggered
// after the focus from powerBox prevalidate.
setTimeout(async () => {
await this.dynamicPlaceholder.open(
this.wysiwyg.$editable[0],
baseModel,
{
validateCallback: this.onDynamicPlaceholderValidate.bind(this),
closeCallback: this.onDynamicPlaceholderClose.bind(this),
positionCallback: this.positionDynamicPlaceholder.bind(this),
}
);
});
}
},
}
],
powerboxFilters: [this._filterPowerBoxCommands.bind(this)],
}
}
return {
value: this.props.value,
autostart: false,
onAttachmentChange: this._onAttachmentChange.bind(this),
onWysiwygBlur: this._onWysiwygBlur.bind(this),
...this.props.wysiwygOptions,
...dynamicPlaceholderOptions,
recordInfo: {
res_model: this.props.record.resModel,
res_id: this.props.record.resId,
},
collaborationChannel: this.props.isCollaborative && {
collaborationModelName: this.props.record.resModel,
collaborationFieldName: this.props.fieldName,
collaborationResId: parseInt(this.props.record.resId),
},
fieldId: this.props.id,
};
}
/**
* Prevent usage of the dynamic placeholder command inside widgets
* containing background images ( cover & masonry ).
*
* We cannot use dynamic placeholder in block containing background images
* because the email processing will flatten the text into the background
* image and this case the dynamic placeholder cannot be dynamic anymore.
*
* @param {Array} commands commands available in this wysiwyg
* @returns {Array} commands which can be used after the filter was applied
*/
_filterPowerBoxCommands(commands) {
let selectionIsInForbidenSnippet = false;
if (this.wysiwyg && this.wysiwyg.odooEditor) {
const selection = this.wysiwyg.odooEditor.document.getSelection();
selectionIsInForbidenSnippet = this.wysiwyg.closestElement(
selection.anchorNode,
'div[data-snippet="s_cover"], div[data-snippet="s_masonry_block"]'
);
}
return selectionIsInForbidenSnippet ? commands.filter((o) => o.title !== "Dynamic Placeholder") : commands;
}
get translationButtonWrapperStyle() {
return `
font-size: 15px;
position: absolute;
right: ${this.props.codeview ? '40px' : '5px'};
top: 5px;
`;
}
getEditingValue () {
const codeViewEl = this._getCodeViewEl();
if (codeViewEl) {
return codeViewEl.value;
} else {
if (this.wysiwyg) {
return this.wysiwyg.getValue();
} else {
return null;
}
}
}
async updateValue() {
const value = this.getEditingValue();
const lastValue = (this.props.value || "").toString();
if (
value !== null &&
!(!lastValue && stripHistoryIds(value) === "<p><br></p>") &&
stripHistoryIds(value) !== stripHistoryIds(lastValue)
) {
this.props.setDirty(false);
this.currentEditingValue = value;
await this.props.update(value);
}
}
async startWysiwyg(wysiwyg) {
this.wysiwyg = wysiwyg;
await this.wysiwyg.startEdition();
if (this.props.codeview) {
const $codeviewButtonToolbar = $(`
<div id="codeview-btn-group" class="btn-group">
<button class="o_codeview_btn btn btn-primary">
<i class="fa fa-code"></i>
</button>
</div>
`);
this.wysiwyg.toolbar.$el.append($codeviewButtonToolbar);
$codeviewButtonToolbar.click(this.toggleCodeView.bind(this));
}
this.wysiwyg.odooEditor.addEventListener("historyStep", () =>
this.props.setDirty(this._isDirty())
);
this.isRendered = true;
}
/**
* Toggle the code view and update the UI.
*/
toggleCodeView() {
this.state.showCodeView = !this.state.showCodeView;
if (this.wysiwyg) {
this.wysiwyg.odooEditor.observerUnactive('toggleCodeView');
if (this.state.showCodeView) {
this.wysiwyg.odooEditor.toolbarHide();
const value = this.wysiwyg.getValue();
this.props.update(value);
} else {
this.wysiwyg.odooEditor.observerActive('toggleCodeView');
}
}
if (!this.state.showCodeView) {
const $codeview = $(this.codeViewRef.el);
const value = $codeview.val();
this.props.update(value);
}
}
onDynamicPlaceholderValidate(chain, defaultValue) {
if (chain) {
let dynamicPlaceholder = "object." + chain.join('.');
dynamicPlaceholder += defaultValue && defaultValue !== '' ? ` or '''${defaultValue}'''` : '';
const t = document.createElement('T');
t.setAttribute('t-out', dynamicPlaceholder);
this.wysiwyg.odooEditor.execCommand('insert', t);
// Ensure the dynamic placeholder <t> element is sanitized.
this.wysiwyg.odooEditor.sanitize(t);
}
}
onDynamicPlaceholderClose() {
this.wysiwyg.focus();
}
/**
* @param {HTMLElement} popover
* @param {Object} position
*/
positionDynamicPlaceholder(popover, position) {
// make sure the popover won't be out(below) of the page
const enoughSpaceBelow = window.innerHeight - popover.clientHeight - this.wysiwygRangePosition.top;
let topPosition = (enoughSpaceBelow > 0) ? this.wysiwygRangePosition.top : this.wysiwygRangePosition.top + enoughSpaceBelow;
// Offset the popover to ensure the arrow is pointing at
// the precise range location.
let leftPosition = this.wysiwygRangePosition.left - 14;
// make sure the popover won't be out(right) of the page
const enoughSpaceRight = window.innerWidth - popover.clientWidth - leftPosition;
leftPosition = (enoughSpaceRight > 0) ? leftPosition : leftPosition + enoughSpaceRight;
// Apply the position back to the element.
popover.style.top = topPosition + 'px';
popover.style.left = leftPosition + 'px';
}
async commitChanges({ urgent, shouldInline } = {}) {
if (this._isDirty() || urgent || (shouldInline && this.props.isInlineStyle)) {
let saveModifiedImagesPromise, toInlinePromise;
if (this.wysiwyg && this.wysiwyg.odooEditor) {
this.wysiwyg.odooEditor.observerUnactive('commitChanges');
saveModifiedImagesPromise = this.wysiwyg.saveModifiedImages();
if (this.props.isInlineStyle) {
// Avoid listening to changes made during the _toInline process.
toInlinePromise = this._toInline();
}
if (urgent && owl.status(this) !== 'destroyed') {
await this.updateValue();
}
await saveModifiedImagesPromise;
const codeViewEl = this._getCodeViewEl();
if (codeViewEl) {
codeViewEl.value = this.wysiwyg.getValue();
}
if (this.props.isInlineStyle) {
await toInlinePromise;
}
this.wysiwyg.odooEditor.observerActive('commitChanges');
}
if (owl.status(this) !== 'destroyed') {
await this.updateValue();
}
}
}
_isDirty() {
const strippedPropValue = stripHistoryIds(String(this.props.value));
const strippedEditingValue = stripHistoryIds(this.getEditingValue());
const domParser = new DOMParser();
const parsedPropValue = domParser.parseFromString(strippedPropValue || '<p><br></p>', 'text/html').body;
const parsedEditingValue = domParser.parseFromString(strippedEditingValue, 'text/html').body;
return !this.props.readonly && parsedPropValue.innerHTML !== parsedEditingValue.innerHTML;
}
_getCodeViewEl() {
return this.state.showCodeView && this.codeViewRef.el;
}
async _setupReadonlyIframe() {
const iframeTarget = this.sandboxedPreview
? this.iframeRef.el.contentDocument.documentElement
: this.iframeRef.el.contentDocument.querySelector('#iframe_target');
if (this.iframePromise && iframeTarget) {
if (iframeTarget.innerHTML !== this.props.value) {
iframeTarget.innerHTML = this.props.value;
retargetLinks(iframeTarget);
}
return this.iframePromise;
}
this.iframePromise = new Promise((resolve) => {
let value = this.props.value;
if (this.props.wrapper) {
value = this._wrap(value);
}
// this bug only appears on some computers with some chrome version.
let avoidDoubleLoad = 0;
// inject content in iframe
window.top[this._onUpdateIframeId] = (_avoidDoubleLoad) => {
if (_avoidDoubleLoad !== avoidDoubleLoad) {
console.warn('Wysiwyg iframe double load detected');
return;
}
resolve();
this.state.iframeVisible = true;
this.onIframeUpdated();
};
this.iframeRef.el.addEventListener('load', async () => {
const _avoidDoubleLoad = ++avoidDoubleLoad;
if (_avoidDoubleLoad !== avoidDoubleLoad) {
console.warn('Wysiwyg immediate iframe double load detected');
return;
}
const cwindow = this.iframeRef.el.contentWindow;
try {
cwindow.document;
} catch (_e) {
return;
}
if (!this.sandboxedPreview) {
cwindow.document
.open("text/html", "replace")
.write(
'<!DOCTYPE html><html>' +
'<head>' +
'<meta charset="utf-8"/>' +
'<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n' +
'<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>\n' +
'</head>\n' +
'<body class="o_in_iframe o_readonly" style="overflow: hidden;">\n' +
'<div id="iframe_target"></div>\n' +
'</body>' +
'</html>');
}
if (this.props.cssReadonlyAssetId) {
const asset = await ajax.loadAsset(this.props.cssReadonlyAssetId);
for (const cssLib of asset.cssLibs) {
const link = cwindow.document.createElement('link');
link.setAttribute('type', 'text/css');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('href', cssLib);
cwindow.document.head.append(link);
}
for (const cssContent of asset.cssContents) {
const style = cwindow.document.createElement('style');
style.setAttribute('type', 'text/css');
const textNode = cwindow.document.createTextNode(cssContent);
style.append(textNode);
cwindow.document.head.append(style);
}
}
if (!this.sandboxedPreview) {
const iframeTarget = cwindow.document.querySelector('#iframe_target');
iframeTarget.innerHTML = value;
const script = cwindow.document.createElement('script');
script.setAttribute('type', 'text/javascript');
const scriptTextNode = document.createTextNode(
`if (window.top.${this._onUpdateIframeId}) {` +
`window.top.${this._onUpdateIframeId}(${_avoidDoubleLoad})` +
`}`
);
script.append(scriptTextNode);
cwindow.document.body.append(script);
} else {
cwindow.document.documentElement.innerHTML = value;
}
const height = cwindow.document.body.scrollHeight;
this.iframeRef.el.style.height = Math.max(30, Math.min(height, 500)) + 'px';
retargetLinks(cwindow.document.body);
if (this.sandboxedPreview) {
this.state.iframeVisible = true;
this.onIframeUpdated();
resolve();
}
});
// Force the iframe to call the `load` event. Without this line, the
// event 'load' might never trigger.
this.iframeRef.el.after(this.iframeRef.el);
});
return this.iframePromise;
}
/**
* Wrap HTML in order to create a custom display.
*
* The wrapper (this.props.wrapper) must be a static
* XML template with content id="wrapper".
*
* @private
* @param {string} html content
* @returns {string} html content
*/
_wrap(html) {
return $(QWeb.render(this.props.wrapper))
.find('#wrapper').html(html)
.end().prop('outerHTML');
}
/**
* Move HTML contents out of their wrapper.
*
* @private
* @param {string} html content
* @returns {string} html content
*/
_unWrap(html) {
const $wrapper = $(html).find('#wrapper');
return $wrapper.length ? $wrapper.html() : html;
}
/**
* Converts CSS dependencies to CSS-independent HTML.
* - CSS display for attachment link -> real image
* - Font icons -> images
* - CSS styles -> inline styles
*
* @private
*/
async _toInline() {
const $editable = this.wysiwyg.getEditable();
this.wysiwyg.odooEditor.sanitize(this.wysiwyg.odooEditor.editable);
const html = this.wysiwyg.getValue();
const $odooEditor = $editable.closest('.odoo-editor-editable');
// Save correct nodes references.
// Remove temporarily the class so that css editing will not be converted.
$odooEditor.removeClass('odoo-editor-editable');
$editable.html(html);
await toInline($editable, undefined, this.wysiwyg.$iframe);
$odooEditor.addClass('odoo-editor-editable');
this.wysiwyg.setValue($editable.html());
this.wysiwyg.odooEditor.sanitize(this.wysiwyg.odooEditor.editable);
}
async _getWysiwygClass() {
return getWysiwygClass();
}
_onAttachmentChange(attachment) {
// This only needs to happen for the composer for now
if (!(this.props.record.fieldNames.includes('attachment_ids') && this.props.record.resModel === 'mail.compose.message')) {
return;
}
this.props.record.update(_.object(['attachment_ids'], [{
operation: 'ADD_M2M',
ids: attachment
}]));
}
_onWysiwygBlur() {
// Avoid save on blur if the html field is in inline mode.
if (this.props.isInlineStyle) {
this.updateValue();
} else {
this.commitChanges();
}
}
async _onReadonlyClickChecklist(ev) {
if (ev.offsetX > 0) {
return;
}
ev.stopPropagation();
ev.preventDefault();
const checked = $(ev.target).hasClass('o_checked');
let checklistId = $(ev.target).attr('id');
checklistId = checklistId && checklistId.replace('checkId-', '');
checklistId = parseInt(checklistId || '0');
const value = await this.rpc('/web_editor/checklist', {
res_model: this.props.record.resModel,
res_id: this.props.record.resId,
filename: this.props.fieldName,
checklistId: checklistId,
checked: !checked,
});
if (value) {
this.props.update(value);
}
}
async _onReadonlyClickStar(ev) {
ev.stopPropagation();
ev.preventDefault();
const node = ev.target;
const previousStars = getAdjacentPreviousSiblings(node, sib => (
sib.nodeType === Node.ELEMENT_NODE && sib.className.includes('fa-star')
));
const nextStars = getAdjacentNextSiblings(node, sib => (
sib.nodeType === Node.ELEMENT_NODE && sib.classList.contains('fa-star')
));
const shouldToggleOff = node.classList.contains('fa-star') && !nextStars.length;
const rating = shouldToggleOff ? 0 : previousStars.length + 1;
let starsId = $(node).parent().attr('id');
starsId = starsId && starsId.replace('checkId-', '');
starsId = parseInt(starsId || '0');
const value = await this.rpc('/web_editor/stars', {
res_model: this.props.record.resModel,
res_id: this.props.record.resId,
filename: this.props.fieldName,
starsId,
rating,
});
if (value) {
this.props.update(value);
}
}
}
HtmlField.template = "web_editor.HtmlField";
HtmlField.components = {
TranslationButton,
HtmlFieldWysiwygAdapterComponent,
};
HtmlField.defaultProps = {
dynamicPlaceholder: false,
setDirty: () => {},
};
HtmlField.props = {
...standardFieldProps,
isTranslatable: { type: Boolean, optional: true },
placeholder: { type: String, optional: true },
fieldName: { type: String, optional: true },
codeview: { type: Boolean, optional: true },
isCollaborative: { type: Boolean, optional: true },
dynamicPlaceholder: { type: Boolean, optional: true, default: false },
cssReadonlyAssetId: { type: String, optional: true },
cssEditAssetId: { type: String, optional: true },
isInlineStyle: { type: Boolean, optional: true },
sandboxedPreview: {type: Boolean, optional: true},
wrapper: { type: String, optional: true },
wysiwygOptions: { type: Object },
};
HtmlField.displayName = _lt("Html");
HtmlField.supportedTypes = ["html"];
HtmlField.extractProps = ({ attrs, field }) => {
const wysiwygOptions = {
placeholder: attrs.placeholder,
noAttachment: attrs.options['no-attachment'],
inIframe: Boolean(attrs.options.cssEdit),
iframeCssAssets: attrs.options.cssEdit,
iframeHtmlClass: attrs.iframeHtmlClass,
snippets: attrs.options.snippets,
mediaModalParams: {
noVideos: 'noVideos' in attrs.options ? attrs.options.noVideos : true,
useMediaLibrary: true,
},
linkForceNewWindow: true,
tabsize: 0,
height: attrs.options.height,
minHeight: attrs.options.minHeight,
maxHeight: attrs.options.maxHeight,
resizable: 'resizable' in attrs.options ? attrs.options.resizable : false,
editorPlugins: [QWebPlugin],
};
if ('collaborative' in attrs.options) {
wysiwygOptions.collaborative = attrs.options.collaborative;
}
if ('style-inline' in attrs.options) {
wysiwygOptions.inlineStyle = Boolean(attrs.options['style-inline']);
}
if ('allowCommandImage' in attrs.options) {
// Set the option only if it is explicitly set in the view so a default
// can be set elsewhere otherwise.
wysiwygOptions.allowCommandImage = Boolean(attrs.options.allowCommandImage);
}
if (field.sanitize_tags || (field.sanitize_tags === undefined && field.sanitize)) {
wysiwygOptions.allowCommandVideo = false; // Tag-sanitized fields remove videos.
} else if ('allowCommandVideo' in attrs.options) {
// Set the option only if it is explicitly set in the view so a default
// can be set elsewhere otherwise.
wysiwygOptions.allowCommandVideo = Boolean(attrs.options.allowCommandVideo);
}
return {
isTranslatable: field.translate,
fieldName: field.name,
codeview: Boolean(odoo.debug && attrs.options.codeview),
sandboxedPreview: Boolean(attrs.options.sandboxedPreview),
placeholder: attrs.placeholder,
isCollaborative: attrs.options.collaborative,
cssReadonlyAssetId: attrs.options.cssReadonly,
dynamicPlaceholder: attrs.options.dynamic_placeholder,
cssEditAssetId: attrs.options.cssEdit,
isInlineStyle: attrs.options['style-inline'],
wrapper: attrs.options.wrapper,
wysiwygOptions,
};
};
registry.category("fields").add("html", HtmlField, { force: true });
export function stripHistoryIds(value) {
return value && value.replace(/\sdata-last-history-steps="[^"]*?"/, '') || value;
}
// Ensure all links are opened in a new tab.
const retargetLinks = (container) => {
for (const link of container.querySelectorAll('a')) {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noreferrer');
}
}

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.HtmlField" owl="1">
<t t-if="props.readonly || props.notEditable || (sandboxedPreview and !state.showCodeView)">
<t t-if="this.showIframe">
<iframe t-ref="iframe" t-att-class="{'d-none': !this.state.iframeVisible, 'o_readonly': true}"
t-att-sandbox="sandboxedPreview ? 'allow-same-origin allow-popups allow-popups-to-escape-sandbox' : false"></iframe>
</t>
<t t-else="">
<div t-ref="readonlyElement" class="o_readonly" t-out="markupValue" />
</t>
</t>
<t t-else="">
<t t-if="state.showCodeView">
<textarea t-ref="codeView" class="o_codeview" t-att-value="markupValue"/>
</t>
<t t-else="">
<HtmlFieldWysiwygAdapterComponent Component="this.Wysiwyg"
startWysiwyg.bind="this.startWysiwyg"
editingValue="this.currentEditingValue"
widgetArgs="[this.wysiwygOptions]" />
</t>
<t t-if="props.isTranslatable">
<span t-attf-style="font-size: 15px; position: absolute; right: {{this.props.codeview ? '40px' : '5px'}}; top: 5px;">
<TranslationButton
fieldName="props.name"
record="props.record"
/>
</span>
</t>
</t>
<t t-if="state.showCodeView || (sandboxedPreview and !props.readonly and !props.notEditable)">
<div t-ref="codeViewButton" id="codeview-btn-group" class="btn-group" t-on-click="() => this.toggleCodeView()">
<button class="o_codeview_btn btn btn-primary">
<i class="fa fa-code" />
</button>
</div>
</t>
</t>
</templates>

View file

@ -0,0 +1,13 @@
/** @odoo-module **/
import ListRenderer from "web.ListRenderer";
ListRenderer.include({
_onWindowClicked: function (event) {
// ignore clicks in the web_editor toolbar
if ($(event.target).closest(".oe-toolbar").length) {
return;
}
return this._super.apply(this, arguments);
},
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
/** @odoo-module **/
// Redefine the getRangeAt function in order to avoid an error appearing
// sometimes when an input element is focused on Firefox.
// The error happens because the range returned by getRangeAt is "restricted".
// Ex: Range { commonAncestorContainer: Restricted, startContainer: Restricted,
// startOffset: 0, endContainer: Restricted, endOffset: 0, collapsed: true }
// The solution consists in detecting when the range is restricted and then
// redefining it manually based on the current selection.
const originalGetRangeAt = Selection.prototype.getRangeAt;
Selection.prototype.getRangeAt = function () {
let range = originalGetRangeAt.apply(this, arguments);
// Check if the range is restricted
if (range.startContainer && !Object.getPrototypeOf(range.startContainer)) {
// Define the range manually based on the selection
range = document.createRange();
range.setStart(this.anchorNode, 0);
range.setEnd(this.focusNode, 0);
}
return range;
};

View file

@ -0,0 +1,348 @@
/** @odoo-module **/
'use strict';
import {qweb} from 'web.core';
import {descendants, preserveCursor} from "@web_editor/js/editor/odoo-editor/src/utils/utils";
const rowSize = 50; // 50px.
// Maximum number of rows that can be added when dragging a grid item.
export const additionalRowLimit = 10;
const defaultGridPadding = 10; // 10px (see `--grid-item-padding-(x|y)` CSS variables).
/**
* Returns the grid properties: rowGap, rowSize, columnGap and columnSize.
*
* @private
* @param {Element} rowEl the grid element
* @returns {Object}
*/
export function _getGridProperties(rowEl) {
const style = window.getComputedStyle(rowEl);
const rowGap = parseFloat(style.rowGap);
const columnGap = parseFloat(style.columnGap);
const columnSize = (rowEl.clientWidth - 11 * columnGap) / 12;
return {rowGap: rowGap, rowSize: rowSize, columnGap: columnGap, columnSize: columnSize};
}
/**
* Sets the z-index property of the element to the maximum z-index present in
* the grid increased by one (so it is in front of all the other elements).
*
* @private
* @param {Element} element the element of which we want to set the z-index
* @param {Element} rowEl the parent grid element of the element
*/
export function _setElementToMaxZindex(element, rowEl) {
const childrenEls = [...rowEl.children].filter(el => el !== element);
element.style.zIndex = Math.max(...childrenEls.map(el => el.style.zIndex)) + 1;
}
/**
* Creates the background grid appearing everytime a change occurs in a grid.
*
* @private
* @param {Element} rowEl
* @param {Number} gridHeight
*/
export function _addBackgroundGrid(rowEl, gridHeight) {
const gridProp = _getGridProperties(rowEl);
const rowCount = Math.max(rowEl.dataset.rowCount, gridHeight);
const backgroundGrid = qweb.render('web_editor.background_grid', {
rowCount: rowCount + 1, rowGap: gridProp.rowGap, rowSize: gridProp.rowSize,
columnGap: gridProp.columnGap, columnSize: gridProp.columnSize,
});
rowEl.insertAdjacentHTML("afterbegin", backgroundGrid);
return rowEl.firstElementChild;
}
/**
* Updates the number of rows in the grid to the end of the lowest column
* present in it.
*
* @private
* @param {Element} rowEl
*/
export function _resizeGrid(rowEl) {
const columnEls = [...rowEl.children].filter(c => c.classList.contains('o_grid_item'));
rowEl.dataset.rowCount = Math.max(...columnEls.map(el => el.style.gridRowEnd)) - 1;
}
/**
* Removes the properties and elements added to make the drag work.
*
* @private
* @param {Element} rowEl
* @param {Element} column
*/
export function _gridCleanUp(rowEl, columnEl) {
columnEl.style.removeProperty('position');
columnEl.style.removeProperty('top');
columnEl.style.removeProperty('left');
columnEl.style.removeProperty('height');
columnEl.style.removeProperty('width');
rowEl.style.removeProperty('position');
}
/**
* Toggles the row (= child element of containerEl) in grid mode.
*
* @private
* @param {Element} containerEl element with the class "container"
*/
export function _toggleGridMode(containerEl) {
let rowEl = containerEl.querySelector(':scope > .row');
const outOfRowEls = [...containerEl.children].filter(el => !el.classList.contains('row'));
// Avoid an unwanted rollback that prevents from deleting the text.
const avoidRollback = (el) => {
for (const node of descendants(el)) {
node.ouid = undefined;
}
};
// Keep the text selection.
const restoreCursor = !rowEl || outOfRowEls.length > 0 ?
preserveCursor(containerEl.ownerDocument) : () => {};
// For the snippets having elements outside of the row (and therefore not in
// a column), create a column and put these elements in it so they can also
// be placed in the grid.
if (rowEl && outOfRowEls.length > 0) {
const columnEl = document.createElement('div');
columnEl.classList.add('col-lg-12');
for (let i = outOfRowEls.length - 1; i >= 0; i--) {
columnEl.prepend(outOfRowEls[i]);
}
avoidRollback(columnEl);
rowEl.prepend(columnEl);
}
// If the number of columns is "None", create a column with the content.
if (!rowEl) {
rowEl = document.createElement('div');
rowEl.classList.add('row');
const columnEl = document.createElement('div');
columnEl.classList.add('col-lg-12');
const containerChildren = containerEl.children;
// Looping backwards because elements are removed, so the indexes are
// not lost.
for (let i = containerChildren.length - 1; i >= 0; i--) {
columnEl.prepend(containerChildren[i]);
}
avoidRollback(columnEl);
rowEl.appendChild(columnEl);
containerEl.appendChild(rowEl);
}
restoreCursor();
// Converting the columns to grid and getting back the number of rows.
const columnEls = rowEl.children;
const columnSize = (rowEl.clientWidth) / 12;
rowEl.style.position = 'relative';
const rowCount = _placeColumns(columnEls, rowSize, 0, columnSize, 0) - 1;
rowEl.style.removeProperty('position');
rowEl.dataset.rowCount = rowCount;
// Removing the classes that break the grid.
const classesToRemove = [...rowEl.classList].filter(c => {
return /^align-items/.test(c);
});
rowEl.classList.remove(...classesToRemove);
rowEl.classList.add('o_grid_mode');
}
/**
* Places each column in the grid based on their position and returns the
* lowest row end.
*
* @private
* @param {HTMLCollection} columnEls
* The children of the row element we are toggling in grid mode.
* @param {Number} rowSize
* @param {Number} rowGap
* @param {Number} columnSize
* @param {Number} columnGap
* @returns {Number}
*/
function _placeColumns(columnEls, rowSize, rowGap, columnSize, columnGap) {
let maxRowEnd = 0;
const columnSpans = [];
let zIndex = 1;
const imageColumns = []; // array of boolean telling if it is a column with only an image.
// Checking if all the columns have a background color to take that into
// account when computing their size and padding (to make them look good).
const allBackgroundColor = [...columnEls].every(columnEl => columnEl.classList.contains('o_cc'));
for (const columnEl of columnEls) {
// Finding out if the images are alone in their column.
let isImageColumn = _checkIfImageColumn(columnEl);
const imageEl = columnEl.querySelector('img');
// Placing the column.
const style = window.getComputedStyle(columnEl);
// Horizontal placement.
const borderLeft = parseFloat(style.borderLeft);
const columnLeft = isImageColumn && !borderLeft ? imageEl.offsetLeft : columnEl.offsetLeft;
// Getting the width of the column.
const paddingLeft = parseFloat(style.paddingLeft);
let width = isImageColumn ? parseFloat(imageEl.scrollWidth)
: parseFloat(columnEl.scrollWidth) - (allBackgroundColor ? 0 : 2 * paddingLeft);
const borderX = borderLeft + parseFloat(style.borderRight);
width += borderX + (allBackgroundColor || isImageColumn ? 0 : 2 * defaultGridPadding);
let columnSpan = Math.round((width + columnGap) / (columnSize + columnGap));
if (columnSpan < 1) {
columnSpan = 1;
}
const columnStart = Math.round(columnLeft / (columnSize + columnGap)) + 1;
const columnEnd = columnStart + columnSpan;
// Vertical placement.
const borderTop = parseFloat(style.borderTop);
const columnTop = isImageColumn && !borderTop ? imageEl.offsetTop : columnEl.offsetTop;
// Getting the top and bottom paddings and computing the row offset.
const paddingTop = parseFloat(style.paddingTop);
const paddingBottom = parseFloat(style.paddingBottom);
const rowOffsetTop = Math.floor((paddingTop + rowGap) / (rowSize + rowGap));
// Getting the height of the column.
let height = isImageColumn ? parseFloat(imageEl.scrollHeight)
: parseFloat(columnEl.scrollHeight) - (allBackgroundColor ? 0 : paddingTop + paddingBottom);
const borderY = borderTop + parseFloat(style.borderBottom);
height += borderY + (allBackgroundColor || isImageColumn ? 0 : 2 * defaultGridPadding);
const rowSpan = Math.ceil((height + rowGap) / (rowSize + rowGap));
const rowStart = Math.round(columnTop / (rowSize + rowGap)) + 1 + (allBackgroundColor || isImageColumn ? 0 : rowOffsetTop);
const rowEnd = rowStart + rowSpan;
columnEl.style.gridArea = `${rowStart} / ${columnStart} / ${rowEnd} / ${columnEnd}`;
columnEl.classList.add('o_grid_item');
// Adding the grid classes.
columnEl.classList.add('g-col-lg-' + columnSpan, 'g-height-' + rowSpan);
// Setting the initial z-index.
columnEl.style.zIndex = zIndex++;
// Reload the images.
_reloadLazyImages(columnEl);
maxRowEnd = Math.max(rowEnd, maxRowEnd);
columnSpans.push(columnSpan);
imageColumns.push(isImageColumn);
}
// If all the columns have a background color, set their padding to the
// original padding of the first column.
if (allBackgroundColor) {
const style = window.getComputedStyle(columnEls[0]);
const paddingY = style.paddingTop;
const paddingX = style.paddingLeft;
const rowEl = columnEls[0].parentNode;
rowEl.style.setProperty('--grid-item-padding-y', paddingY);
rowEl.style.setProperty('--grid-item-padding-x', paddingX);
}
for (const [i, columnEl] of [...columnEls].entries()) {
// Removing padding and offset classes.
const regex = /^(pt|pb|col-|offset-)/;
const toRemove = [...columnEl.classList].filter(c => {
return regex.test(c);
});
columnEl.classList.remove(...toRemove);
columnEl.classList.add('col-lg-' + columnSpans[i]);
// If the column only has an image, convert it.
if (imageColumns[i]) {
_convertImageColumn(columnEl);
}
}
return maxRowEnd;
}
/**
* Removes and sets back the 'src' attribute of the images inside a column.
* (To avoid the disappearing image problem in Chrome).
*
* @private
* @param {Element} columnEl
*/
export function _reloadLazyImages(columnEl) {
const imageEls = columnEl.querySelectorAll('img');
for (const imageEl of imageEls) {
const src = imageEl.getAttribute("src");
imageEl.src = '';
imageEl.src = src;
}
}
/**
* Computes the column and row spans of the column thanks to its width and
* height and returns them. Also adds the grid classes to the column.
*
* @private
* @param {Element} rowEl
* @param {Element} columnEl
* @param {Number} columnWidth the width in pixels of the column.
* @param {Number} columnHeight the height in pixels of the column.
* @returns {Object}
*/
export function _convertColumnToGrid(rowEl, columnEl, columnWidth, columnHeight) {
// First, checking if the column only contains an image and if it is the
// case, converting it.
if (_checkIfImageColumn(columnEl)) {
_convertImageColumn(columnEl);
}
// Taking the grid padding into account.
const paddingX = parseFloat(rowEl.style.getPropertyValue("--grid-item-padding-x")) || defaultGridPadding;
const paddingY = parseFloat(rowEl.style.getPropertyValue("--grid-item-padding-y")) || defaultGridPadding;
columnWidth += 2 * paddingX;
columnHeight += 2 * paddingY;
// Computing the column and row spans.
const gridProp = _getGridProperties(rowEl);
const columnColCount = Math.round((columnWidth + gridProp.columnGap) / (gridProp.columnSize + gridProp.columnGap));
const columnRowCount = Math.ceil((columnHeight + gridProp.rowGap) / (gridProp.rowSize + gridProp.rowGap));
// Removing the padding and offset classes.
const regex = /^(pt|pb|col-|offset-)/;
const toRemove = [...columnEl.classList].filter(c => regex.test(c));
columnEl.classList.remove(...toRemove);
// Adding the grid classes.
columnEl.classList.add('g-col-lg-' + columnColCount, 'g-height-' + columnRowCount, 'col-lg-' + columnColCount);
columnEl.classList.add('o_grid_item');
return {columnColCount: columnColCount, columnRowCount: columnRowCount};
}
/**
* Checks whether the column only contains an image or not. An image is
* considered alone if the column only contains empty textnodes and line breaks
* in addition to the image. Note that "image" also refers to an image link
* (i.e. `a > img`).
*
* @private
* @param {Element} columnEl
* @returns {Boolean}
*/
export function _checkIfImageColumn(columnEl) {
let isImageColumn = false;
const imageEls = columnEl.querySelectorAll(":scope > img, :scope > a > img");
const columnChildrenEls = [...columnEl.children].filter(el => el.nodeName !== 'BR');
if (imageEls.length === 1 && columnChildrenEls.length === 1) {
// If there is only one image and if this image is the only "real"
// child of the column, we need to check if there is text in it.
const textNodeEls = [...columnEl.childNodes].filter(el => el.nodeType === Node.TEXT_NODE);
const areTextNodesEmpty = [...textNodeEls].every(textNodeEl => textNodeEl.nodeValue.trim() === '');
isImageColumn = areTextNodesEmpty;
}
return isImageColumn;
}
/**
* Removes the line breaks and textnodes of the column, adds the grid class and
* sets the image width to default so it can be displayed as expected.
*
* @private
* @param {Element} columnEl a column containing only an image.
*/
function _convertImageColumn(columnEl) {
columnEl.querySelectorAll('br').forEach(el => el.remove());
const textNodeEls = [...columnEl.childNodes].filter(el => el.nodeType === Node.TEXT_NODE);
textNodeEls.forEach(el => el.remove());
const imageEl = columnEl.querySelector('img');
columnEl.classList.add('o_grid_item_image');
imageEl.style.removeProperty('width');
}

View file

@ -0,0 +1,489 @@
odoo.define('web_editor.utils', function (require) {
'use strict';
const {ColorpickerWidget} = require('web.Colorpicker');
let editableWindow = window;
const _setEditableWindow = (ew) => editableWindow = ew;
const COLOR_PALETTE_COMPATIBILITY_COLOR_NAMES = ['primary', 'secondary', 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'success', 'info', 'warning', 'danger'];
/**
* These constants are colors that can be edited by the user when using
* web_editor in a website context. We keep track of them so that color
* palettes and their preview elements can always have the right colors
* displayed even if website has redefined the colors during an editing
* session.
*
* @type {string[]}
*/
const EDITOR_COLOR_CSS_VARIABLES = [...COLOR_PALETTE_COMPATIBILITY_COLOR_NAMES];
// o-cc and o-colors
for (let i = 1; i <= 5; i++) {
EDITOR_COLOR_CSS_VARIABLES.push(`o-color-${i}`);
EDITOR_COLOR_CSS_VARIABLES.push(`o-cc${i}-bg`);
EDITOR_COLOR_CSS_VARIABLES.push(`o-cc${i}-headings`);
EDITOR_COLOR_CSS_VARIABLES.push(`o-cc${i}-text`);
EDITOR_COLOR_CSS_VARIABLES.push(`o-cc${i}-btn-primary`);
EDITOR_COLOR_CSS_VARIABLES.push(`o-cc${i}-btn-primary-text`);
EDITOR_COLOR_CSS_VARIABLES.push(`o-cc${i}-btn-secondary`);
EDITOR_COLOR_CSS_VARIABLES.push(`o-cc${i}-btn-secondary-text`);
EDITOR_COLOR_CSS_VARIABLES.push(`o-cc${i}-btn-primary-border`);
EDITOR_COLOR_CSS_VARIABLES.push(`o-cc${i}-btn-secondary-border`);
}
// Grays
for (let i = 100; i <= 900; i += 100) {
EDITOR_COLOR_CSS_VARIABLES.push(`${i}`);
}
/**
* window.getComputedStyle cannot work properly with CSS shortcuts (like
* 'border-width' which is a shortcut for the top + right + bottom + left border
* widths. If an option wants to customize such a shortcut, it should be listed
* here with the non-shortcuts property it stands for, in order.
*
* @type {Object<string[]>}
*/
const CSS_SHORTHANDS = {
'border-width': ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'],
'border-radius': ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'],
'border-color': ['border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color'],
'border-style': ['border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style'],
'padding': ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
};
/**
* Key-value mapping to list converters from an unit A to an unit B.
* - The key is a string in the format '$1-$2' where $1 is the CSS symbol of
* unit A and $2 is the CSS symbol of unit B.
* - The value is a function that converts the received value (expressed in
* unit A) to another value expressed in unit B. Two other parameters is
* received: the css property on which the unit applies and the jQuery element
* on which that css property may change.
*/
const CSS_UNITS_CONVERSION = {
's-ms': () => 1000,
'ms-s': () => 0.001,
'rem-px': () => _computePxByRem(),
'px-rem': () => _computePxByRem(true),
'%-px': () => -1, // Not implemented but should simply be ignored for now
'px-%': () => -1, // Not implemented but should simply be ignored for now
};
/**
* Colors of the default palette, used for substitution in shapes/illustrations.
* key: number of the color in the palette (ie, o-color-<1-5>)
* value: color hex code
*/
const DEFAULT_PALETTE = {
'1': '#3AADAA',
'2': '#7C6576',
'3': '#F6F6F6',
'4': '#FFFFFF',
'5': '#383E45',
};
/**
* Set of all the data attributes relative to the background images.
*/
const BACKGROUND_IMAGE_ATTRIBUTES = new Set([
"originalId", "originalSrc", "mimetype", "resizeWidth", "glFilter", "quality", "bgSrc",
"filterOptions",
]);
/**
* Computes the number of "px" needed to make a "rem" unit. Subsequent calls
* returns the cached computed value.
*
* @param {boolean} [toRem=false]
* @returns {float} - number of px by rem if 'toRem' is false
* - the inverse otherwise
*/
function _computePxByRem(toRem) {
if (_computePxByRem.PX_BY_REM === undefined) {
const htmlStyle = editableWindow.getComputedStyle(editableWindow.document.documentElement);
_computePxByRem.PX_BY_REM = parseFloat(htmlStyle['font-size']);
}
return toRem ? (1 / _computePxByRem.PX_BY_REM) : _computePxByRem.PX_BY_REM;
}
/**
* Converts the given (value + unit) string to a numeric value expressed in
* the other given css unit.
*
* e.g. fct('400ms', 's') -> 0.4
*
* @param {string} value
* @param {string} unitTo
* @param {string} [cssProp] - the css property on which the unit applies
* @param {jQuery} [$target] - the jQuery element on which that css property
* may change
* @returns {number}
*/
function _convertValueToUnit(value, unitTo, cssProp, $target) {
const m = _getNumericAndUnit(value);
if (!m) {
return NaN;
}
const numValue = parseFloat(m[0]);
const valueUnit = m[1];
return _convertNumericToUnit(numValue, valueUnit, unitTo, cssProp, $target);
}
/**
* Converts the given numeric value expressed in the given css unit into
* the corresponding numeric value expressed in the other given css unit.
*
* e.g. fct(400, 'ms', 's') -> 0.4
*
* @param {number} value
* @param {string} unitFrom
* @param {string} unitTo
* @param {string} [cssProp] - the css property on which the unit applies
* @param {jQuery} [$target] - the jQuery element on which that css property
* may change
* @returns {number}
*/
function _convertNumericToUnit(value, unitFrom, unitTo, cssProp, $target) {
if (Math.abs(value) < Number.EPSILON || unitFrom === unitTo) {
return value;
}
const converter = CSS_UNITS_CONVERSION[`${unitFrom}-${unitTo}`];
if (converter === undefined) {
throw new Error(`Cannot convert '${unitFrom}' units into '${unitTo}' units !`);
}
return value * converter(cssProp, $target);
}
/**
* Returns the numeric value and unit of a css value.
*
* e.g. fct('400ms') -> [400, 'ms']
*
* @param {string} value
* @returns {Array|null}
*/
function _getNumericAndUnit(value) {
const m = value.trim().match(/^(-?[0-9.]+(?:e[+|-]?[0-9]+)?)\s*([^\s]*)$/);
if (!m) {
return null;
}
return [m[1].trim(), m[2].trim()];
}
/**
* Checks if two css values are equal.
*
* @param {string} value1
* @param {string} value2
* @param {string} [cssProp] - the css property on which the unit applies
* @param {jQuery} [$target] - the jQuery element on which that css property
* may change
* @returns {boolean}
*/
function _areCssValuesEqual(value1, value2, cssProp, $target) {
// String comparison first
if (value1 === value2) {
return true;
}
// In case the values are a size, they might be made of two parts.
if (cssProp && cssProp.endsWith('-size')) {
// Avoid re-splitting each part during their individual comparison.
const pseudoPartProp = cssProp + '-part';
const re = /-?[0-9.]+(?:e[+|-]?[0-9]+)?\s*[A-Za-z%-]+|auto/g;
const parts1 = value1.match(re);
const parts2 = value2.match(re);
for (const index of [0, 1]) {
const part1 = parts1 && parts1.length > index ? parts1[index] : 'auto';
const part2 = parts2 && parts2.length > index ? parts2[index] : 'auto';
if (!_areCssValuesEqual(part1, part2, pseudoPartProp, $target)) {
return false;
}
}
return true;
}
// It could be a CSS variable, in that case the actual value has to be
// retrieved before comparing.
if (value1.startsWith('var(--')) {
value1 = _getCSSVariableValue(value1.substring(6, value1.length - 1));
}
if (value2.startsWith('var(--')) {
value2 = _getCSSVariableValue(value2.substring(6, value2.length - 1));
}
if (value1 === value2) {
return true;
}
// They may be colors, normalize then re-compare the resulting string
const color1 = ColorpickerWidget.normalizeCSSColor(value1);
const color2 = ColorpickerWidget.normalizeCSSColor(value2);
if (color1 === color2) {
return true;
}
// They may be gradients
const value1IsGradient = _isColorGradient(value1);
const value2IsGradient = _isColorGradient(value2);
if (value1IsGradient !== value2IsGradient) {
return false;
}
if (value1IsGradient) {
// Kinda hacky and probably inneficient but probably the easiest way:
// applied the value as background-image of two fakes elements and
// compare their computed value.
const temp1El = document.createElement('div');
temp1El.style.backgroundImage = value1;
document.body.appendChild(temp1El);
value1 = getComputedStyle(temp1El).backgroundImage;
document.body.removeChild(temp1El);
const temp2El = document.createElement('div');
temp2El.style.backgroundImage = value2;
document.body.appendChild(temp2El);
value2 = getComputedStyle(temp2El).backgroundImage;
document.body.removeChild(temp2El);
return value1 === value2;
}
// In case the values are meant as box-shadow, this is difficult to compare.
// In this case we use the kinda hacky and probably inneficient but probably
// easiest way: applying the value as box-shadow of two fakes elements and
// compare their computed value.
if (cssProp === 'box-shadow') {
const temp1El = document.createElement('div');
temp1El.style.boxShadow = value1;
document.body.appendChild(temp1El);
value1 = getComputedStyle(temp1El).boxShadow;
document.body.removeChild(temp1El);
const temp2El = document.createElement('div');
temp2El.style.boxShadow = value2;
document.body.appendChild(temp2El);
value2 = getComputedStyle(temp2El).boxShadow;
document.body.removeChild(temp2El);
return value1 === value2;
}
// Convert the second value in the unit of the first one and compare
// floating values
const data = _getNumericAndUnit(value1);
if (!data) {
return false;
}
const numValue1 = data[0];
const numValue2 = _convertValueToUnit(value2, data[1], cssProp, $target);
return (Math.abs(numValue1 - numValue2) < Number.EPSILON);
}
/**
* @param {string|number} name
* @returns {boolean}
*/
function _isColorCombinationName(name) {
const number = parseInt(name);
return (!isNaN(number) && number % 100 !== 0);
}
/**
* @param {string[]} colorNames
* @param {string} [prefix='bg-']
* @returns {string[]}
*/
function _computeColorClasses(colorNames, prefix = 'bg-') {
let hasCCClasses = false;
const isBgPrefix = (prefix === 'bg-');
const classes = colorNames.map(c => {
if (isBgPrefix && _isColorCombinationName(c)) {
hasCCClasses = true;
return `o_cc${c}`;
}
return (prefix + c);
});
if (hasCCClasses) {
classes.push('o_cc');
}
return classes;
}
/**
* @param {string} key
* @param {CSSStyleDeclaration} [htmlStyle] if not provided, it is computed
* @returns {string}
*/
function _getCSSVariableValue(key, htmlStyle) {
if (htmlStyle === undefined) {
htmlStyle = editableWindow.getComputedStyle(editableWindow.document.documentElement);
}
// Get trimmed value from the HTML element
let value = htmlStyle.getPropertyValue(`--${key}`).trim();
// If it is a color value, it needs to be normalized
value = ColorpickerWidget.normalizeCSSColor(value);
// Normally scss-string values are "printed" single-quoted. That way no
// magic conversation is needed when customizing a variable: either save it
// quoted for strings or non quoted for colors, numbers, etc. However,
// Chrome has the annoying behavior of changing the single-quotes to
// double-quotes when reading them through getPropertyValue...
return value.replace(/"/g, "'");
}
/**
* Normalize a color in case it is a variable name so it can be used outside of
* css.
*
* @param {string} color the color to normalize into a css value
* @returns {string} the normalized color
*/
function _normalizeColor(color) {
if (ColorpickerWidget.isCSSColor(color)) {
return color;
}
return _getCSSVariableValue(color);
}
/**
* Parse an element's background-image's url.
*
* @param {string} string a css value in the form 'url("...")'
* @returns {string|false} the src of the image or false if not parsable
*/
function _getBgImageURL(el) {
const parts = _backgroundImageCssToParts($(el).css('background-image'));
const string = parts.url || '';
const match = string.match(/^url\((['"])(.*?)\1\)$/);
if (!match) {
return '';
}
const matchedURL = match[2];
// Make URL relative if possible
const fullURL = new URL(matchedURL, window.location.origin);
if (fullURL.origin === window.location.origin) {
return fullURL.href.slice(fullURL.origin.length);
}
return matchedURL;
}
/**
* Extracts url and gradient parts from the background-image CSS property.
*
* @param {string} CSS 'background-image' property value
* @returns {Object} contains the separated 'url' and 'gradient' parts
*/
function _backgroundImageCssToParts(css) {
const parts = {};
css = css || '';
if (css.startsWith('url(')) {
const urlEnd = css.indexOf(')') + 1;
parts.url = css.substring(0, urlEnd).trim();
const commaPos = css.indexOf(',', urlEnd);
css = commaPos > 0 ? css.substring(commaPos + 1) : '';
}
if (_isColorGradient(css)) {
parts.gradient = css.trim();
}
return parts;
}
/**
* Combines url and gradient parts into a background-image CSS property value
*
* @param {Object} contains the separated 'url' and 'gradient' parts
* @returns {string} CSS 'background-image' property value
*/
function _backgroundImagePartsToCss(parts) {
let css = parts.url || '';
if (parts.gradient) {
css += (css ? ', ' : '') + parts.gradient;
}
return css || 'none';
}
/**
* @param {string} [value]
* @returns {boolean}
*/
function _isColorGradient(value) {
// FIXME duplicated in odoo-editor/utils.js
return value && value.includes('-gradient(');
}
/**
* Generates a string ID.
*
* @private
* @returns {string}
*/
function _generateHTMLId() {
return `o${Math.random().toString(36).substring(2, 15)}`;
}
/**
* Returns the class of the element that matches the specified prefix.
*
* @private
* @param {Element} el element from which to recover the color class
* @param {string[]} colorNames
* @param {string} prefix prefix of the color class to recover
* @returns {string} color class matching the prefix or an empty string
*/
function _getColorClass(el, colorNames, prefix) {
const prefixedColorNames = _computeColorClasses(colorNames, prefix);
return el.classList.value.split(' ').filter(cl => prefixedColorNames.includes(cl)).join(' ');
}
/**
* Add one or more new attributes related to background images in the
* BACKGROUND_IMAGE_ATTRIBUTES set.
*
* @param {...string} newAttributes The new attributes to add in the
* BACKGROUND_IMAGE_ATTRIBUTES set.
*/
function _addBackgroundImageAttributes(...newAttributes) {
BACKGROUND_IMAGE_ATTRIBUTES.add(...newAttributes);
}
/**
* Check if an attribute is in the BACKGROUND_IMAGE_ATTRIBUTES set.
*
* @param {string} attribute The attribute that has to be checked.
*/
function _isBackgroundImageAttribute(attribute) {
return BACKGROUND_IMAGE_ATTRIBUTES.has(attribute);
}
/**
* Checks if an element supposedly marked with the o_editable_media class should
* in fact be editable (checks if its environment looks like a non editable
* environment whose media should be editable).
*
* TODO: the name of this function is voluntarily bad to reflect the fact that
* this system should be improved. The combination of o_not_editable,
* o_editable, getContentEditableAreas, getReadOnlyAreas and other concepts
* related to what should be editable or not should be reviewed.
*
* @returns {boolean}
*/
function _shouldEditableMediaBeEditable(mediaEl) {
// Some sections of the DOM are contenteditable="false" (for
// example with the help of the o_not_editable class) but have
// inner media that should be editable (the fact the container
// is not is to prevent adding text in between those medias).
// This case is complex and the solution to support it is not
// perfect: we mark those media with a class and check that the
// first non editable ancestor is in fact in an editable parent.
const parentEl = mediaEl.parentElement;
const nonEditableAncestorRootEl = parentEl && parentEl.closest('[contenteditable="false"]');
return nonEditableAncestorRootEl
&& nonEditableAncestorRootEl.parentElement
&& nonEditableAncestorRootEl.parentElement.isContentEditable;
}
return {
COLOR_PALETTE_COMPATIBILITY_COLOR_NAMES: COLOR_PALETTE_COMPATIBILITY_COLOR_NAMES,
CSS_SHORTHANDS: CSS_SHORTHANDS,
CSS_UNITS_CONVERSION: CSS_UNITS_CONVERSION,
DEFAULT_PALETTE: DEFAULT_PALETTE,
EDITOR_COLOR_CSS_VARIABLES: EDITOR_COLOR_CSS_VARIABLES,
computePxByRem: _computePxByRem,
convertValueToUnit: _convertValueToUnit,
convertNumericToUnit: _convertNumericToUnit,
getNumericAndUnit: _getNumericAndUnit,
areCssValuesEqual: _areCssValuesEqual,
isColorCombinationName: _isColorCombinationName,
isColorGradient: _isColorGradient,
computeColorClasses: _computeColorClasses,
getCSSVariableValue: _getCSSVariableValue,
normalizeColor: _normalizeColor,
getBgImageURL: _getBgImageURL,
backgroundImageCssToParts: _backgroundImageCssToParts,
backgroundImagePartsToCss: _backgroundImagePartsToCss,
generateHTMLId: _generateHTMLId,
getColorClass: _getColorClass,
setEditableWindow: _setEditableWindow,
addBackgroundImageAttributes: _addBackgroundImageAttributes,
isBackgroundImageAttribute: _isBackgroundImageAttribute,
shouldEditableMediaBeEditable: _shouldEditableMediaBeEditable,
};
});

View file

@ -0,0 +1,17 @@
/** @odoo-module **/
export function isImg(node) {
return (node && (node.nodeName === "IMG" || (node.className && node.className.match(/(^|\s)(media_iframe_video|o_image|fa)(\s|$)/i))));
}
/**
* Returns a list of all the ancestors nodes of the provided node.
*
* @param {Node} node
* @param {Node} [stopElement] include to prevent bubbling up further than the stopElement.
* @returns {HTMLElement[]}
*/
export function ancestors(node, stopElement) {
if (!node || !node.parentElement || node === stopElement) return [];
return [node.parentElement, ...ancestors(node.parentElement, stopElement)];
}

View file

@ -0,0 +1,16 @@
odoo.define('web_editor.custom_colors', function (require) {
'use strict';
// These colors are already normalized as per normalizeCSSColor in web.Colorpicker
return [
['#000000', '#424242', '#636363', '#9C9C94', '#CEC6CE', '#EFEFEF', '#F7F7F7', '#FFFFFF'],
['#FF0000', '#FF9C00', '#FFFF00', '#00FF00', '#00FFFF', '#0000FF', '#9C00FF', '#FF00FF'],
['#F7C6CE', '#FFE7CE', '#FFEFC6', '#D6EFD6', '#CEDEE7', '#CEE7F7', '#D6D6E7', '#E7D6DE'],
['#E79C9C', '#FFC69C', '#FFE79C', '#B5D6A5', '#A5C6CE', '#9CC6EF', '#B5A5D6', '#D6A5BD'],
['#E76363', '#F7AD6B', '#FFD663', '#94BD7B', '#73A5AD', '#6BADDE', '#8C7BC6', '#C67BA5'],
['#CE0000', '#E79439', '#EFC631', '#6BA54A', '#4A7B8C', '#3984C6', '#634AA5', '#A54A7B'],
['#9C0000', '#B56308', '#BD9400', '#397B21', '#104A5A', '#085294', '#311873', '#731842'],
['#630000', '#7B3900', '#846300', '#295218', '#083139', '#003163', '#21104A', '#4A1031']
];
});

View file

@ -0,0 +1,542 @@
odoo.define('web_editor.image_processing', function (require) {
'use strict';
const {getAffineApproximation, getProjective} = require('@web_editor/js/editor/perspective_utils');
// Fields returned by cropperjs 'getData' method, also need to be passed when
// initializing the cropper to reuse the previous crop.
const cropperDataFields = ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY'];
const modifierFields = [
'filter',
'quality',
'mimetype',
'glFilter',
'originalId',
'originalSrc',
'resizeWidth',
'aspectRatio',
"bgSrc",
];
const isGif = (mimetype) => mimetype === 'image/gif';
// webgl color filters
const _applyAll = (result, filter, filters) => {
filters.forEach(f => {
if (f[0] === 'blend') {
const cv = f[1];
const ctx = result.getContext('2d');
ctx.globalCompositeOperation = f[2];
ctx.globalAlpha = f[3];
ctx.drawImage(cv, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0;
} else {
filter.addFilter(...f);
}
});
};
let applyAll;
const glFilters = {
blur: filter => filter.addFilter('blur', 10),
'1977': (filter, cv) => {
const ctx = cv.getContext('2d');
ctx.fillStyle = 'rgb(243, 106, 188)';
ctx.fillRect(0, 0, cv.width, cv.height);
applyAll(filter, [
['blend', cv, 'screen', .3],
['brightness', .1],
['contrast', .1],
['saturation', .3],
]);
},
aden: (filter, cv) => {
const ctx = cv.getContext('2d');
ctx.fillStyle = 'rgb(66, 10, 14)';
ctx.fillRect(0, 0, cv.width, cv.height);
applyAll(filter, [
['blend', cv, 'darken', .2],
['brightness', .2],
['contrast', -.1],
['saturation', -.15],
['hue', 20],
]);
},
brannan: (filter, cv) => {
const ctx = cv.getContext('2d');
ctx.fillStyle = 'rgb(161, 44, 191)';
ctx.fillRect(0, 0, cv.width, cv.height);
applyAll(filter, [
['blend', cv, 'lighten', .31],
['sepia', .5],
['contrast', .4],
]);
},
earlybird: (filter, cv) => {
const ctx = cv.getContext('2d');
const gradient = ctx.createRadialGradient(
cv.width / 2, cv.height / 2, 0,
cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
);
gradient.addColorStop(.2, '#D0BA8E');
gradient.addColorStop(1, '#1D0210');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, cv.width, cv.height);
applyAll(filter, [
['blend', cv, 'overlay', .2],
['sepia', .2],
['contrast', -.1],
]);
},
inkwell: (filter, cv) => {
applyAll(filter, [
['sepia', .3],
['brightness', .1],
['contrast', -.1],
['desaturateLuminance'],
]);
},
// Needs hue blending mode for perfect reproduction. Close enough?
maven: (filter, cv) => {
applyAll(filter, [
['sepia', .25],
['brightness', -.05],
['contrast', -.05],
['saturation', .5],
]);
},
toaster: (filter, cv) => {
const ctx = cv.getContext('2d');
const gradient = ctx.createRadialGradient(
cv.width / 2, cv.height / 2, 0,
cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
);
gradient.addColorStop(0, '#0F4E80');
gradient.addColorStop(1, '#3B003B');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, cv.width, cv.height);
applyAll(filter, [
['blend', cv, 'screen', .5],
['brightness', -.1],
['contrast', .5],
]);
},
walden: (filter, cv) => {
const ctx = cv.getContext('2d');
ctx.fillStyle = '#CC4400';
ctx.fillRect(0, 0, cv.width, cv.height);
applyAll(filter, [
['blend', cv, 'screen', .3],
['sepia', .3],
['brightness', .1],
['saturation', .6],
['hue', 350],
]);
},
valencia: (filter, cv) => {
const ctx = cv.getContext('2d');
ctx.fillStyle = '#3A0339';
ctx.fillRect(0, 0, cv.width, cv.height);
applyAll(filter, [
['blend', cv, 'exclusion', .5],
['sepia', .08],
['brightness', .08],
['contrast', .08],
]);
},
xpro: (filter, cv) => {
const ctx = cv.getContext('2d');
const gradient = ctx.createRadialGradient(
cv.width / 2, cv.height / 2, 0,
cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
);
gradient.addColorStop(.4, '#E0E7E6');
gradient.addColorStop(1, '#2B2AA1');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, cv.width, cv.height);
applyAll(filter, [
['blend', cv, 'color-burn', .7],
['sepia', .3],
]);
},
custom: (filter, cv, filterOptions) => {
const options = Object.assign({
blend: 'normal',
filterColor: '',
blur: '0',
desaturateLuminance: '0',
saturation: '0',
contrast: '0',
brightness: '0',
sepia: '0',
}, JSON.parse(filterOptions || "{}"));
const filters = [];
if (options.filterColor) {
const ctx = cv.getContext('2d');
ctx.fillStyle = options.filterColor;
ctx.fillRect(0, 0, cv.width, cv.height);
filters.push(['blend', cv, options.blend, 1]);
}
delete options.blend;
delete options.filterColor;
filters.push(...Object.entries(options).map(([filter, amount]) => [filter, parseInt(amount) / 100]));
applyAll(filter, filters);
},
};
/**
* Applies data-attributes modifications to an img tag and returns a dataURL
* containing the result. This function does not modify the original image.
*
* @param {HTMLImageElement} img the image to which modifications are applied
* @returns {string} dataURL of the image with the applied modifications
*/
async function applyModifications(img, dataOptions = {}) {
const data = Object.assign({
glFilter: '',
filter: '#0000',
quality: '75',
forceModification: false,
}, img.dataset, dataOptions);
let {
width,
height,
resizeWidth,
quality,
filter,
mimetype,
originalSrc,
glFilter,
filterOptions,
forceModification,
perspective,
svgAspectRatio,
imgAspectRatio,
} = data;
[width, height, resizeWidth] = [width, height, resizeWidth].map(s => parseFloat(s));
quality = parseInt(quality);
// Skip modifications (required to add shapes on animated GIFs).
if (isGif(mimetype) && !forceModification) {
return await _loadImageDataURL(originalSrc);
}
// Crop
const container = document.createElement('div');
const original = await loadImage(originalSrc);
container.appendChild(original);
await activateCropper(original, 0, data);
let croppedImg = $(original).cropper('getCroppedCanvas', {width, height});
$(original).cropper('destroy');
// Aspect Ratio
if (imgAspectRatio) {
document.createElement('div').appendChild(croppedImg);
imgAspectRatio = imgAspectRatio.split(':');
imgAspectRatio = parseFloat(imgAspectRatio[0]) / parseFloat(imgAspectRatio[1]);
await activateCropper(croppedImg, imgAspectRatio, {y: 0});
croppedImg = $(croppedImg).cropper('getCroppedCanvas');
$(croppedImg).cropper('destroy');
}
// Width
const result = document.createElement('canvas');
result.width = resizeWidth || croppedImg.width;
result.height = perspective ? result.width / svgAspectRatio : croppedImg.height * result.width / croppedImg.width;
const ctx = result.getContext('2d');
ctx.imageSmoothingQuality = "high";
ctx.mozImageSmoothingEnabled = true;
ctx.webkitImageSmoothingEnabled = true;
ctx.msImageSmoothingEnabled = true;
ctx.imageSmoothingEnabled = true;
// Perspective 3D
if (perspective) {
// x, y coordinates of the corners of the image as a percentage
// (relative to the width or height of the image) needed to apply
// the 3D effect.
const points = JSON.parse(perspective);
const divisions = 10;
const w = croppedImg.width, h = croppedImg.height;
const project = getProjective(w, h, [
[(result.width / 100) * points[0][0], (result.height / 100) * points[0][1]], // Top-left [x, y]
[(result.width / 100) * points[1][0], (result.height / 100) * points[1][1]], // Top-right [x, y]
[(result.width / 100) * points[2][0], (result.height / 100) * points[2][1]], // bottom-right [x, y]
[(result.width / 100) * points[3][0], (result.height / 100) * points[3][1]], // bottom-left [x, y]
]);
for (let i = 0; i < divisions; i++) {
for (let j = 0; j < divisions; j++) {
const [dx, dy] = [w / divisions, h / divisions];
const upper = {origin: [i * dx, j * dy], sides: [dx, dy], flange: 0.1, overlap: 0};
const lower = {origin: [i * dx + dx, j * dy + dy], sides: [-dx, -dy], flange: 0, overlap: 0.1};
for (let {origin, sides, flange, overlap} of [upper, lower]) {
const [[a, c, e], [b, d, f]] = getAffineApproximation(project, [
origin, [origin[0] + sides[0], origin[1]], [origin[0], origin[1] + sides[1]]
]);
const ox = (i !== divisions ? overlap * sides[0] : 0) + flange * sides[0];
const oy = (j !== divisions ? overlap * sides[1] : 0) + flange * sides[1];
origin[0] += flange * sides[0];
origin[1] += flange * sides[1];
sides[0] -= flange * sides[0];
sides[1] -= flange * sides[1];
ctx.save();
ctx.setTransform(a, b, c, d, e, f);
ctx.beginPath();
ctx.moveTo(origin[0] - ox, origin[1] - oy);
ctx.lineTo(origin[0] + sides[0], origin[1] - oy);
ctx.lineTo(origin[0] + sides[0], origin[1]);
ctx.lineTo(origin[0], origin[1] + sides[1]);
ctx.lineTo(origin[0] - ox, origin[1] + sides[1]);
ctx.closePath();
ctx.clip();
ctx.drawImage(croppedImg, 0, 0);
ctx.restore();
}
}
}
} else {
ctx.drawImage(croppedImg, 0, 0, croppedImg.width, croppedImg.height, 0, 0, result.width, result.height);
}
// GL filter
if (glFilter) {
const glf = new window.WebGLImageFilter();
const cv = document.createElement('canvas');
cv.width = result.width;
cv.height = result.height;
applyAll = _applyAll.bind(null, result);
glFilters[glFilter](glf, cv, filterOptions);
const filtered = glf.apply(result);
ctx.drawImage(filtered, 0, 0, filtered.width, filtered.height, 0, 0, result.width, result.height);
}
// Color filter
ctx.fillStyle = filter || '#0000';
ctx.fillRect(0, 0, result.width, result.height);
// Quality
return result.toDataURL(mimetype, quality / 100);
}
/**
* Loads an src into an HTMLImageElement.
*
* @param {String} src URL of the image to load
* @param {HTMLImageElement} [img] img element in which to load the image
* @returns {Promise<HTMLImageElement>} Promise that resolves to the loaded img
* or a placeholder image if the src is not found.
*/
function loadImage(src, img = new Image()) {
const handleImage = (source, resolve, reject) => {
img.addEventListener("load", () => resolve(img), {once: true});
img.addEventListener("error", reject, {once: true});
img.src = source;
};
// The server will return a placeholder image with the following src.
const placeholderHref = "/web/image/__odoo__unknown__src__/";
return new Promise((resolve, reject) => {
fetch(src)
.then(response => {
if (!response.ok) {
src = placeholderHref;
}
handleImage(src, resolve, reject);
})
.catch(error => {
src = placeholderHref;
handleImage(src, resolve, reject);
});
});
}
// Because cropperjs acquires images through XHRs on the image src and we don't
// want to load big images over the network many times when adjusting quality
// and filter, we create a local cache of the images using object URLs.
const imageCache = new Map();
/**
* Loads image object URL into cache if not already set and returns it.
*
* @param {String} src
* @returns {Promise}
*/
function _loadImageObjectURL(src) {
return _updateImageData(src);
}
/**
* Gets image dataURL from cache in the same way as object URL.
*
* @param {String} src
* @returns {Promise}
*/
function _loadImageDataURL(src) {
return _updateImageData(src, 'dataURL');
}
/**
* @param {String} src used as a key on the image cache map.
* @param {String} [key='objectURL'] specifies the image data to update/return.
* @returns {Promise<String>} resolves with either dataURL/objectURL value.
*/
async function _updateImageData(src, key = 'objectURL') {
const currentImageData = imageCache.get(src);
if (currentImageData && currentImageData[key]) {
return currentImageData[key];
}
let value = '';
const blob = await fetch(src).then(res => res.blob());
if (key === 'dataURL') {
value = await createDataURL(blob);
} else {
value = URL.createObjectURL(blob);
}
imageCache.set(src, Object.assign(currentImageData || {}, {[key]: value}));
return value;
}
/**
* Activates the cropper on a given image.
*
* @param {jQuery} $image the image on which to activate the cropper
* @param {Number} aspectRatio the aspectRatio of the crop box
* @param {DOMStringMap} dataset dataset containing the cropperDataFields
*/
async function activateCropper(image, aspectRatio, dataset) {
image.src = await _loadImageObjectURL(image.getAttribute('src'));
$(image).cropper({
viewMode: 2,
dragMode: 'move',
autoCropArea: 1.0,
aspectRatio: aspectRatio,
data: _.mapObject(_.pick(dataset, ...cropperDataFields), value => parseFloat(value)),
// Can't use 0 because it's falsy and cropperjs will then use its defaults (200x100)
minContainerWidth: 1,
minContainerHeight: 1,
});
return new Promise(resolve => image.addEventListener('ready', resolve, {once: true}));
}
/**
* Marks an <img> with its attachment data (originalId, originalSrc, mimetype)
*
* @param {HTMLImageElement} img the image whose attachment data should be found
* @param {Function} rpc a function that can be used to make the RPC. Typically
* this would be passed as 'this._rpc.bind(this)' from widgets.
* @param {string} [attachmentSrc=''] specifies the URL of the corresponding
* attachment if it can't be found in the 'src' attribute.
*/
async function loadImageInfo(img, rpc, attachmentSrc = '') {
const src = attachmentSrc || img.getAttribute('src');
// If there is a marked originalSrc, the data is already loaded.
if (img.dataset.originalSrc || !src) {
return;
}
// In order to be robust to absolute, relative and protocol relative URLs,
// the src of the img is first converted to an URL object. To do so, the URL
// of the document in which the img is located is used as a base to build
// the URL object if the src of the img is a relative or protocol relative
// URL. The original attachment linked to the img is then retrieved thanks
// to the path of the built URL object.
const srcUrl = new URL(src, img.ownerDocument.defaultView.location.href);
const relativeSrc = srcUrl.pathname;
const {original} = await rpc({
route: '/web_editor/get_image_info',
params: {src: relativeSrc},
});
// If src was an absolute "external" URL, we consider unlikely that its
// relative part matches something from the DB and even if it does, nothing
// bad happens, besides using this random image as the original when using
// the options, instead of having no option. Note that we do not want to
// check if the image is local or not here as a previous bug converted some
// local (relative src) images to absolute URL... and that before users had
// setup their website domain. That means they can have an absolute URL that
// looks like "https://mycompany.odoo.com/web/image/123" that leads to a
// "local" image even if the domain name is now "mycompany.be".
if (original && original.image_src) {
img.dataset.originalId = original.id;
img.dataset.originalSrc = original.image_src;
img.dataset.mimetype = original.mimetype;
}
}
/**
* @param {String} mimetype
* @param {Boolean} [strict=false] if true, even partially supported images (GIFs)
* won't be accepted.
* @returns {Boolean}
*/
function isImageSupportedForProcessing(mimetype, strict = false) {
if (isGif(mimetype)) {
return !strict;
}
return ['image/jpeg', 'image/png'].includes(mimetype);
}
/**
* @param {HTMLImageElement} img
* @returns {Boolean}
*/
function isImageSupportedForStyle(img) {
if (!img.parentElement) {
return false;
}
// See also `[data-oe-type='image'] > img` added as data-exclude of some
// snippet options.
const isTFieldImg = ('oeType' in img.parentElement.dataset);
// Editable root elements are technically *potentially* supported here (if
// the edited attributes are not computed inside the related view, they
// could technically be saved... but as we cannot tell the computed ones
// apart from the "static" ones, we choose to not support edition at all in
// those "root" cases).
// See also `[data-oe-xpath]` added as data-exclude of some snippet options.
const isEditableRootElement = ('oeXpath' in img.dataset);
return !isTFieldImg && !isEditableRootElement;
}
/**
* @param {Blob} blob
* @returns {Promise}
*/
function createDataURL(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('abort', reject);
reader.addEventListener('error', reject);
reader.readAsDataURL(blob);
});
}
return {
applyModifications,
cropperDataFields,
activateCropper,
loadImageInfo,
loadImage,
removeOnImageChangeAttrs: [...cropperDataFields, ...modifierFields],
isImageSupportedForProcessing,
isImageSupportedForStyle,
createDataURL,
isGif,
};
});

View file

@ -0,0 +1,50 @@
li.oe-nested {
display: block;
}
.o_table tr {
border-color: $o-gray-300;
td {
padding: 0.5rem;
}
}
$sizes: '', 'xs-', 'sm-', 'md-', 'lg-', 'xl-', 'xxl-';
.o_text_columns {
max-width: 100% !important;
padding: 0 !important;
}
// TODO adapt in master. Those following `.o_text_column` CSS rules were added
// as an attempt to align those columns with the rest of the edited text in
// backend form views, etc. It was not needed, something else fixed the issue.
// But removing them would actually display an horizontal scrollbar in backend
// html fields displayed in form views now... as somehow the edited text relies
// on a combination of `overflow: auto` and `padding: 0` (or too small...
// depends on the form view...)... this should be refactored to be possible to
// remove. We keep the bug they introduce: the columns are not properly sized,
// the first and last ones are bigger because of this. Also columns wrapping on
// multiple rows is buggy. However, we allow to disable the rule with a variable
// so that the bug can be fixed for the website, where this is more important
// and can rely on the external paddings being right.
// grep: FIXED_TEXT_COLUMNS
$--enable-no-overflow-of-text-columns: true !default;
@if $--enable-no-overflow-of-text-columns {
.o_text_columns > .row {
margin: 0 !important;
@each $size in $sizes {
@for $i from 1 through 12 {
& > .col-#{$size}#{$i}:first-of-type {
padding-left: 0;
}
& > .col-#{$size}#{$i}:last-of-type {
padding-right: 0;
}
}
}
}
}
.oe-tabs {
display: inline-block;
white-space: pre-wrap;
max-width: 40px;
width: 40px;
}

View file

@ -0,0 +1,27 @@
ul.o_checklist {
> li {
list-style: none;
position: relative;
margin-left: 20px;
&:not(.oe-nested):before {
content: '';
position: absolute;
left: -20px;
display: block;
height: 14px;
width: 14px;
top: 1px;
border: 1px solid;
cursor: pointer;
}
&.o_checked:after {
content: "";
transition: opacity .5s;
position: absolute;
left: -18px;
top: -1px;
opacity: 1;
}
}
}

View file

@ -0,0 +1,22 @@
/** @odoo-module **/
import { childNodeIndex, isBlock } from '../utils/utils.js';
Text.prototype.oAlign = function (offset, mode) {
this.parentElement.oAlign(childNodeIndex(this), mode);
};
/**
* This does not check for command state
* @param {*} offset
* @param {*} mode 'left', 'right', 'center' or 'justify'
*/
HTMLElement.prototype.oAlign = function (offset, mode) {
if (!isBlock(this)) {
return this.parentElement.oAlign(childNodeIndex(this), mode);
}
const { textAlign } = getComputedStyle(this);
const alreadyAlignedLeft = textAlign === 'start' || textAlign === 'left';
const shouldApplyStyle = !(alreadyAlignedLeft && mode === 'left');
if (shouldApplyStyle) {
this.style.textAlign = mode;
}
};

View file

@ -0,0 +1,292 @@
/** @odoo-module **/
import { UNBREAKABLE_ROLLBACK_CODE, UNREMOVABLE_ROLLBACK_CODE, REGEX_BOOTSTRAP_COLUMN } from '../utils/constants.js';
import {deleteText} from './deleteForward.js';
import {
boundariesOut,
childNodeIndex,
CTGROUPS,
CTYPES,
DIRECTIONS,
endPos,
fillEmpty,
getState,
isBlock,
isEmptyBlock,
isUnbreakable,
isUnremovable,
isVisible,
leftPos,
rightPos,
moveNodes,
nodeSize,
paragraphRelatedElements,
prepareUpdate,
setSelection,
isMediaElement,
isVisibleEmpty,
isNotEditableNode,
createDOMPathGenerator,
closestElement,
closestBlock,
getOffsetAndCharSize,
ZERO_WIDTH_CHARS,
isButton,
} from '../utils/utils.js';
Text.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
const parentElement = this.parentElement;
if (!offset) {
// Backspace at the beginning of a text node is not a specific case to
// handle, let the element implementation handle it.
parentElement.oDeleteBackward([...parentElement.childNodes].indexOf(this), alreadyMoved);
return;
}
// Get the size of the unicode character to remove.
// If the current offset split an emoji in the middle , we need to change offset to the end of the emoji
const [newOffset, charSize] = getOffsetAndCharSize(this.nodeValue, offset, DIRECTIONS.LEFT);
deleteText.call(this, charSize, newOffset - charSize, DIRECTIONS.LEFT, alreadyMoved);
};
const isDeletable = (node) => {
return isMediaElement(node) || isNotEditableNode(node);
}
HTMLElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false, offsetLimit) {
const contentIsZWS = ZERO_WIDTH_CHARS.includes(this.textContent);
let moveDest;
if (offset) {
const leftNode = this.childNodes[offset - 1];
if (isUnremovable(leftNode)) {
throw UNREMOVABLE_ROLLBACK_CODE;
}
if (
isDeletable(leftNode)
) {
leftNode.remove();
return;
}
if (!isBlock(leftNode) || isVisibleEmpty(leftNode)) {
/**
* Backspace just after an inline node, convert to backspace at the
* end of that inline node.
*
* E.g. <p>abc<i>def</i>[]</p> + BACKSPACE
* <=> <p>abc<i>def[]</i></p> + BACKSPACE
*/
leftNode.oDeleteBackward(nodeSize(leftNode), alreadyMoved);
return;
}
/**
* Backspace just after an block node, we have to move any inline
* content after it, up to the next block. If the cursor is between
* two blocks, this is a theoretical case: just do nothing.
*
* E.g. <p>abc</p>[]de<i>f</i><p>ghi</p> + BACKSPACE
* <=> <p>abcde<i>f</i></p><p>ghi</p>
*/
alreadyMoved = true;
moveDest = endPos(leftNode);
} else {
if (isUnremovable(this)) {
throw UNREMOVABLE_ROLLBACK_CODE;
}
// Empty unbreakable blocks should be removed with backspace, with the
// notable exception of Bootstrap columns.
if (isUnbreakable(this) && (REGEX_BOOTSTRAP_COLUMN.test(this.className) || !isEmptyBlock(this))) {
throw UNBREAKABLE_ROLLBACK_CODE;
}
const parentEl = this.parentElement;
// Handle editable sub-nodes
if (
parentEl &&
parentEl.getAttribute("contenteditable") === "true" &&
parentEl.oid !== "root" &&
parentEl.parentElement &&
!parentEl.parentElement.isContentEditable &&
paragraphRelatedElements.includes(this.tagName) &&
!this.previousElementSibling
) {
// The first child element of a contenteditable="true" zone which
// itself is contained in a contenteditable="false" zone can not be
// removed if it is paragraph-like.
throw UNREMOVABLE_ROLLBACK_CODE;
}
const closestLi = closestElement(this, 'li');
if ((closestLi && !closestLi.previousElementSibling) || !isBlock(this) || isVisibleEmpty(this)) {
/**
* Backspace at the beginning of an inline node, nothing has to be
* done: propagate the backspace. If the node was empty, we remove
* it before.
*
* E.g. <p>abc<b></b><i>[]def</i></p> + BACKSPACE
* <=> <p>abc<b>[]</b><i>def</i></p> + BACKSPACE
* <=> <p>abc[]<i>def</i></p> + BACKSPACE
*/
const parentOffset = childNodeIndex(this);
if (!nodeSize(this) || contentIsZWS) {
const visible = (isVisible(this) && !contentIsZWS) || isButton(this);
const restore = prepareUpdate(...boundariesOut(this));
this.remove();
restore();
fillEmpty(parentEl);
if (visible) {
// TODO this handle BR/IMG/etc removals../ to see if we
// prefer to have a dedicated handler for every possible
// HTML element or if we let this generic code handle it.
setSelection(parentEl, parentOffset);
return;
}
}
parentEl.oDeleteBackward(parentOffset, alreadyMoved);
return;
}
/** If we are at the beninning of a block node,
* And the previous node is empty, remove it.
*
* E.g. (previousEl == empty)
* <p><br></p><h1>[]def</h1> + BACKSPACE
* <=> <h1>[]def</h1>
*
* E.g. (previousEl != empty)
* <h3>abc</h3><h1>[]def</h1> + BACKSPACE
* <=> <h3>abc[]def</h3>
*/
const previousElementSiblingClosestBlock = closestBlock(this.previousElementSibling);
if (
previousElementSiblingClosestBlock &&
(isEmptyBlock(previousElementSiblingClosestBlock) ||
previousElementSiblingClosestBlock.textContent === '\u200B') &&
paragraphRelatedElements.includes(this.nodeName)
) {
previousElementSiblingClosestBlock.remove();
setSelection(this, 0);
return;
}
/**
* Backspace at the beginning of a block node. If it doesn't have a left
* block and it is one of the special block formatting tags below then
* convert the block into a P and return immediately. Otherwise, we have
* to move the inline content at its beginning outside of the element
* and propagate to the left block.
*
* E.g. (prev == block)
* <p>abc</p><div>[]def<p>ghi</p></div> + BACKSPACE
* <=> <p>abc</p>[]def<div><p>ghi</p></div> + BACKSPACE
*
* E.g. (prev != block)
* abc<div>[]def<p>ghi</p></div> + BACKSPACE
* <=> abc[]def<div><p>ghi</p></div>
*/
if (
!this.previousElementSibling &&
paragraphRelatedElements.includes(this.nodeName) &&
this.nodeName !== 'P' &&
!closestLi
) {
if (!this.textContent) {
const p = document.createElement('p');
p.replaceChildren(...this.childNodes);
this.replaceWith(p);
setSelection(p, offset);
}
return;
} else {
moveDest = leftPos(this);
}
}
const domPathGenerator = createDOMPathGenerator(DIRECTIONS.LEFT, {
leafOnly: true,
stopTraverseFunction: isDeletable,
});
const domPath = domPathGenerator(this, offset)
const leftNode = domPath.next().value;
if (leftNode && isDeletable(leftNode)) {
const [parent, offset] = rightPos(leftNode);
return parent.oDeleteBackward(offset, alreadyMoved);
}
let node = this.childNodes[offset];
const nextSibling = this.nextSibling;
let currentNodeIndex = offset;
// `offsetLimit` will ensure we never move nodes that were not initialy in
// the element => when Deleting and merging an element the containing node
// will temporarily be hosted in the common parent beside possible other
// nodes. We don't want to touch those other nodes when merging two html
// elements ex : <div>12<p>ab[]</p><p>cd</p>34</div> should never touch the
// 12 and 34 text node.
if (offsetLimit === undefined) {
while (node && !isBlock(node)) {
node = node.nextSibling;
currentNodeIndex++;
}
} else {
currentNodeIndex = offsetLimit;
}
let [cursorNode, cursorOffset] = moveNodes(...moveDest, this, offset, currentNodeIndex);
setSelection(cursorNode, cursorOffset);
// Propagate if this is still a block on the left of where the nodes were
// moved.
if (
cursorNode.nodeType === Node.TEXT_NODE &&
(cursorOffset === 0 || cursorOffset === cursorNode.length)
) {
cursorOffset = childNodeIndex(cursorNode) + (cursorOffset === 0 ? 0 : 1);
cursorNode = cursorNode.parentNode;
}
if (cursorNode.nodeType !== Node.TEXT_NODE) {
const { cType } = getState(cursorNode, cursorOffset, DIRECTIONS.LEFT);
if (cType & CTGROUPS.BLOCK && (!alreadyMoved || cType === CTYPES.BLOCK_OUTSIDE)) {
cursorNode.oDeleteBackward(cursorOffset, alreadyMoved, cursorOffset + currentNodeIndex - offset);
} else if (!alreadyMoved) {
// When removing a block node adjacent to an inline node, we need to
// ensure the block node induced line break are kept with a <br>.
// ex : <div>a<span>b</span><p>[]c</p>d</div> => deleteBakward =>
// <div>a<span>b</span>[]c<br>d</div> In this case we cannot simply
// merge the <p> content into the div parent, or we would lose the
// line break located after the <p>.
const cursorNodeNode = cursorNode.childNodes[cursorOffset];
const cursorNodeRightNode = cursorNodeNode ? cursorNodeNode.nextSibling : undefined;
if (cursorNodeRightNode &&
cursorNodeRightNode.nodeType === Node.TEXT_NODE &&
nextSibling === cursorNodeRightNode) {
moveDest[0].insertBefore(document.createElement('br'), cursorNodeRightNode);
}
}
}
};
HTMLLIElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
// If the deleteBackward is performed at the begening of a LI element,
// we take the current LI out of the list.
if (offset === 0) {
this.oToggleList(offset);
return;
}
// Otherwise, call the HTMLElement deleteBackward method.
HTMLElement.prototype.oDeleteBackward.call(this, offset, alreadyMoved);
};
HTMLBRElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
const parentOffset = childNodeIndex(this);
const rightState = getState(this.parentElement, parentOffset + 1, DIRECTIONS.RIGHT).cType;
if (rightState & CTYPES.BLOCK_INSIDE) {
this.parentElement.oDeleteBackward(parentOffset, alreadyMoved);
} else {
HTMLElement.prototype.oDeleteBackward.call(this, offset, alreadyMoved);
}
};
HTMLTableCellElement.prototype.oDeleteBackward = function (offset, alreadyMoved = false) {
if (offset) {
HTMLElement.prototype.oDeleteBackward.call(this, offset, alreadyMoved);
}
};

View file

@ -0,0 +1,242 @@
/** @odoo-module **/
import { UNREMOVABLE_ROLLBACK_CODE } from '../utils/constants.js';
import {
findNode,
isContentTextNode,
isVisibleEmpty,
nodeSize,
rightPos,
getState,
DIRECTIONS,
CTYPES,
leftPos,
isFontAwesome,
rightLeafOnlyNotBlockNotEditablePath,
rightLeafOnlyPathNotBlockNotEditablePath,
isNotEditableNode,
splitTextNode,
paragraphRelatedElements,
prepareUpdate,
isVisibleStr,
isInPre,
fillEmpty,
setSelection,
isZWS,
childNodeIndex,
boundariesOut,
isEditorTab,
isVisible,
isUnbreakable,
isEmptyBlock,
getOffsetAndCharSize,
ZERO_WIDTH_CHARS,
} from '../utils/utils.js';
/**
* Handle text node deletion for Text.oDeleteForward and Text.oDeleteBackward.
*
* @param {int} charSize
* @param {int} offset
* @param {DIRECTIONS} direction
* @param {boolean} alreadyMoved
*/
export function deleteText(charSize, offset, direction, alreadyMoved) {
const parentElement = this.parentElement;
// Split around the character where the deletion occurs.
const firstSplitOffset = splitTextNode(this, offset);
const secondSplitOffset = splitTextNode(parentElement.childNodes[firstSplitOffset], charSize);
const middleNode = parentElement.childNodes[firstSplitOffset];
// Do remove the character, then restore the state of the surrounding parts.
const restore = prepareUpdate(parentElement, firstSplitOffset, parentElement, secondSplitOffset);
const isSpace = !isVisibleStr(middleNode) && !isInPre(middleNode);
const isZWS = ZERO_WIDTH_CHARS.includes(middleNode.nodeValue);
middleNode.remove();
restore();
// If the removed element was not visible content, propagate the deletion.
const parentState = getState(parentElement, firstSplitOffset, direction);
if (
isZWS ||
(isSpace &&
(parentState.cType !== CTYPES.CONTENT || parentState.node === undefined))
) {
if (direction === DIRECTIONS.LEFT) {
parentElement.oDeleteBackward(firstSplitOffset, alreadyMoved);
} else {
if (isSpace && parentState.node == undefined) {
// multiple invisible space at the start of the node
this.oDeleteForward(offset, alreadyMoved);
} else {
parentElement.oDeleteForward(firstSplitOffset, alreadyMoved);
}
}
if (isZWS && parentElement.isConnected) {
fillEmpty(parentElement);
}
return;
}
fillEmpty(parentElement);
setSelection(parentElement, firstSplitOffset);
}
Text.prototype.oDeleteForward = function (offset, alreadyMoved = false) {
const parentElement = this.parentElement;
if (offset === this.nodeValue.length) {
// Delete at the end of a text node is not a specific case to handle,
// let the element implementation handle it.
parentElement.oDeleteForward([...parentElement.childNodes].indexOf(this) + 1);
return;
}
// Get the size of the unicode character to remove.
const [newOffset, charSize] = getOffsetAndCharSize(this.nodeValue, offset + 1, DIRECTIONS.RIGHT);
deleteText.call(this, charSize, newOffset, DIRECTIONS.RIGHT, alreadyMoved);
};
HTMLElement.prototype.oDeleteForward = function (offset) {
const filterFunc = node =>
isVisibleEmpty(node) || isContentTextNode(node) || isNotEditableNode(node);
const firstLeafNode = findNode(rightLeafOnlyNotBlockNotEditablePath(this, offset), filterFunc);
if (firstLeafNode &&
isZWS(firstLeafNode) &&
this.parentElement.hasAttribute('data-oe-zws-empty-inline')
) {
const grandparent = this.parentElement.parentElement;
if (!grandparent) {
return;
}
const parentIndex = childNodeIndex(this.parentElement);
const restore = prepareUpdate(...boundariesOut(this.parentElement));
this.parentElement.remove();
restore();
HTMLElement.prototype.oDeleteForward.call(grandparent, parentIndex);
return;
} else if (
firstLeafNode &&
firstLeafNode.nodeType === Node.TEXT_NODE &&
firstLeafNode.textContent === '\ufeff'
) {
firstLeafNode.oDeleteForward(1);
return;
}
if (
this.hasAttribute &&
this.hasAttribute('data-oe-zws-empty-inline') &&
(
isZWS(this) ||
(this.textContent === '' && this.childNodes.length === 0)
)
) {
const parent = this.parentElement;
if (!parent) {
return;
}
const index = childNodeIndex(this);
const restore = prepareUpdate(...boundariesOut(this));
this.remove();
restore();
HTMLElement.prototype.oDeleteForward.call(parent, index);
return;
}
if (firstLeafNode && (isFontAwesome(firstLeafNode) || isNotEditableNode(firstLeafNode))) {
const nextSibling = firstLeafNode.nextSibling;
const nextSiblingText = nextSibling ? nextSibling.textContent : '';
firstLeafNode.remove();
if (isEditorTab(firstLeafNode) && nextSiblingText[0] === '\u200B') {
// When deleting an editor tab, we need to ensure it's related ZWS
// il deleted as well.
nextSibling.textContent = nextSiblingText.replace('\u200B', '');
}
return;
}
if (
firstLeafNode &&
(firstLeafNode.nodeName !== 'BR' ||
getState(...rightPos(firstLeafNode), DIRECTIONS.RIGHT).cType !== CTYPES.BLOCK_INSIDE)
) {
firstLeafNode.oDeleteBackward(Math.min(1, nodeSize(firstLeafNode)));
return;
}
const nextSibling = this.nextSibling;
if (
(
offset === this.childNodes.length ||
(this.childNodes.length === 1 && this.childNodes[0].tagName === 'BR')
) &&
this.parentElement &&
nextSibling &&
['LI', 'UL', 'OL'].includes(nextSibling.tagName)
) {
const nextSiblingNestedLi = nextSibling.querySelector('li:first-child');
if (nextSiblingNestedLi) {
// Add the first LI from the next sibbling list to the current list.
this.after(nextSiblingNestedLi);
// Remove the next sibbling list if it's empty.
if (!isVisible(nextSibling, false) || nextSibling.textContent === '') {
nextSibling.remove();
}
HTMLElement.prototype.oDeleteBackward.call(nextSiblingNestedLi, 0, true);
} else {
HTMLElement.prototype.oDeleteBackward.call(nextSibling, 0);
}
return;
}
// Remove the nextSibling if it is a non-editable element.
if (
nextSibling &&
nextSibling.nodeType === Node.ELEMENT_NODE &&
!nextSibling.isContentEditable
) {
nextSibling.remove();
return;
}
const parentEl = this.parentElement;
// Prevent the deleteForward operation since it is done at the end of an
// enclosed editable zone (inside a non-editable zone in the editor).
if (
parentEl &&
parentEl.getAttribute("contenteditable") === "true" &&
parentEl.oid !== "root" &&
parentEl.parentElement &&
!parentEl.parentElement.isContentEditable &&
paragraphRelatedElements.includes(this.tagName) &&
!this.nextElementSibling
) {
throw UNREMOVABLE_ROLLBACK_CODE;
}
const firstOutNode = findNode(
rightLeafOnlyPathNotBlockNotEditablePath(
...(firstLeafNode ? rightPos(firstLeafNode) : [this, offset]),
),
filterFunc,
);
if (firstOutNode) {
// If next sibblings is an unbreadable node, and current node is empty, we
// delete the current node and put the selection at the beginning of the
// next sibbling.
if (nextSibling && isUnbreakable(nextSibling) && isEmptyBlock(this)) {
const restore = prepareUpdate(...boundariesOut(this));
this.remove();
restore();
setSelection(firstOutNode, 0);
return;
}
const [node, offset] = leftPos(firstOutNode);
// If the next node is a <LI> we call directly the htmlElement
// oDeleteBackward : because we don't want the special cases of
// deleteBackward for LI when we comme from a deleteForward.
if (node.tagName === 'LI') {
HTMLElement.prototype.oDeleteBackward.call(node, offset);
return;
}
node.oDeleteBackward(offset);
return;
}
};

View file

@ -0,0 +1,187 @@
/** @odoo-module **/
import { UNBREAKABLE_ROLLBACK_CODE } from '../utils/constants.js';
import {
childNodeIndex,
clearEmpty,
fillEmpty,
isBlock,
isUnbreakable,
prepareUpdate,
setCursorStart,
setCursorEnd,
setTagName,
splitTextNode,
toggleClass,
isVisible,
nodeSize,
setSelection,
} from '../utils/utils.js';
Text.prototype.oEnter = function (offset) {
this.parentElement.oEnter(splitTextNode(this, offset), true);
};
/**
* The whole logic can pretty much be described by this example:
*
* <p><span><b>[]xt</b>ab</span>cd</p> + ENTER
* <=> <p><span><b><br></b>[]<b>xt</b>ab</span>cd</p> + ENTER
* <=> <p><span><b><br></b></span>[]<span><b>xt</b>ab</span>cd</p> + ENTER
* <=> <p><span><b><br></b></span></p><p><span><b>[]xt</b>ab</span>cd</p> + SANITIZE
* <=> <p><br></p><p><span><b>[]xt</b>ab</span>cd</p>
*
* Propagate the split for as long as we split an inline node, then refocus the
* beginning of the first split node
*/
HTMLElement.prototype.oEnter = function (offset, firstSplit = true) {
let didSplit = false;
if (isUnbreakable(this)) {
throw UNBREAKABLE_ROLLBACK_CODE;
}
if (
!this.textContent &&
['BLOCKQUOTE', 'PRE'].includes(this.parentElement.nodeName) &&
!this.nextSibling
) {
const parent = this.parentElement;
const index = childNodeIndex(this);
if (this.previousElementSibling) {
this.remove();
return parent.oEnter(index, !didSplit);
}
return parent.oEnter(index + 1, !didSplit);
}
let restore;
if (firstSplit) {
restore = prepareUpdate(this, offset);
}
// First split the node in two and move half the children in the clone.
const splitEl = this.cloneNode(false);
while (offset < this.childNodes.length) {
splitEl.appendChild(this.childNodes[offset]);
}
if (isBlock(this) || splitEl.hasChildNodes()) {
this.after(splitEl);
if (isVisible(splitEl)) {
didSplit = true;
} else {
splitEl.remove();
}
}
// Propagate the split until reaching a block element (or continue to the
// closest list item element if there is one).
if (!isBlock(this) || (this.nodeName !== 'LI' && this.closest('LI'))) {
if (this.parentElement) {
this.parentElement.oEnter(childNodeIndex(this) + 1, !didSplit);
} else {
// There was no block parent element in the original chain, consider
// this unsplittable, like an unbreakable.
throw UNBREAKABLE_ROLLBACK_CODE;
}
}
// All split have been done, place the cursor at the right position, and
// fill/remove empty nodes.
if (firstSplit && didSplit) {
restore();
fillEmpty(clearEmpty(this));
fillEmpty(splitEl);
const focusToElement =
splitEl.nodeType === Node.ELEMENT_NODE && splitEl.tagName === 'A'
? clearEmpty(splitEl)
: splitEl;
setCursorStart(focusToElement);
}
return splitEl;
};
/**
* 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>
*/
HTMLHeadingElement.prototype.oEnter = function () {
const newEl = HTMLElement.prototype.oEnter.call(this, ...arguments);
if (newEl && [...newEl.textContent].every(char => char === '\u200B')) { // empty or all invisible
const node = setTagName(newEl, 'P');
node.replaceChildren(document.createElement('br'));
setCursorStart(node);
}
};
const isAtEdgeofLink = (link, offset) => {
const childNodes = [...link.childNodes];
let firstVisibleIndex = childNodes.findIndex(isVisible);
firstVisibleIndex = firstVisibleIndex === -1 ? 0 : firstVisibleIndex;
if (offset <= firstVisibleIndex) {
return 'start';
}
let lastVisibleIndex = childNodes.reverse().findIndex(isVisible);
lastVisibleIndex = lastVisibleIndex === -1 ? 0 : childNodes.length - lastVisibleIndex;
if (offset >= lastVisibleIndex) {
return 'end';
}
return false;
}
HTMLAnchorElement.prototype.oEnter = function (offset) {
const edge = isAtEdgeofLink(this, offset);
if (edge === 'start') {
// Do not break the link at the edge: break before it.
if (this.previousSibling) {
return HTMLElement.prototype.oEnter.call(this.previousSibling, nodeSize(this.previousSibling));
} else {
const index = childNodeIndex(this);
return HTMLElement.prototype.oEnter.call(this.parentElement, index ? index - 1 : 0);
}
} else if (edge === 'end') {
// Do not break the link at the edge: break after it.
if (this.nextSibling) {
return HTMLElement.prototype.oEnter.call(this.nextSibling, 0);
} else {
return HTMLElement.prototype.oEnter.call(this.parentElement, childNodeIndex(this));
}
} else {
HTMLElement.prototype.oEnter.call(this, ...arguments);
}
}
/**
* Same specific behavior as headings elements.
*/
HTMLQuoteElement.prototype.oEnter = HTMLHeadingElement.prototype.oEnter;
/**
* Specific behavior for list items: deletion and unindentation when empty.
*/
HTMLLIElement.prototype.oEnter = function () {
// If not empty list item, regular block split
if (this.textContent || this.querySelector('table')) {
const node = HTMLElement.prototype.oEnter.call(this, ...arguments);
if (node.classList.contains('o_checked')) {
toggleClass(node, 'o_checked');
}
return node;
}
this.oShiftTab();
};
/**
* Specific behavior for pre: insert newline (\n) in text or insert p at end.
*/
HTMLPreElement.prototype.oEnter = function (offset) {
if (offset < this.childNodes.length) {
const lineBreak = document.createElement('br');
this.insertBefore(lineBreak, this.childNodes[offset]);
setCursorEnd(lineBreak);
} else {
if (this.parentElement.nodeName === 'LI') {
setSelection(this.parentElement, childNodeIndex(this) + 1);
HTMLLIElement.prototype.oEnter.call(this.parentElement, ...arguments);
return;
}
const node = document.createElement('p');
this.parentNode.insertBefore(node, this.nextSibling);
fillEmpty(node);
setCursorStart(node);
}
};

View file

@ -0,0 +1,76 @@
/** @odoo-module **/
import {
CTYPES,
DIRECTIONS,
isFakeLineBreak,
prepareUpdate,
rightPos,
setSelection,
getState,
leftPos,
splitTextNode,
isBlock,
} from '../utils/utils.js';
Text.prototype.oShiftEnter = function (offset) {
return this.parentElement.oShiftEnter(splitTextNode(this, offset));
};
HTMLElement.prototype.oShiftEnter = function (offset) {
const restore = prepareUpdate(this, offset);
const brEl = document.createElement('br');
const brEls = [brEl];
if (offset >= this.childNodes.length) {
this.appendChild(brEl);
} else {
this.insertBefore(brEl, this.childNodes[offset]);
}
if (isFakeLineBreak(brEl) && getState(...leftPos(brEl), DIRECTIONS.LEFT).cType !== CTYPES.BR) {
const brEl2 = document.createElement('br');
brEl.before(brEl2);
brEls.unshift(brEl2);
}
restore();
for (const el of brEls) {
if (el.parentNode) {
setSelection(...rightPos(el));
break;
}
}
return brEls;
};
/**
* Special behavior for links: do not add a line break at its edges, but rather
* move the line break outside the link.
*/
HTMLAnchorElement.prototype.oShiftEnter = function () {
const brs = HTMLElement.prototype.oShiftEnter.call(this, ...arguments);
const anchor = brs[0].parentElement;
let firstChild = anchor.firstChild;
if (firstChild && firstChild.nodeType === Node.TEXT_NODE && firstChild.textContent === '\uFEFF') {
firstChild = anchor.childNodes[1];
}
let lastChild = anchor.lastChild;
if (lastChild && lastChild.nodeType === Node.TEXT_NODE && lastChild.textContent === '\uFEFF') {
lastChild = anchor.childNodes.length > 1 && anchor.childNodes[anchor.childNodes.length - 2];
}
if (brs.includes(firstChild)) {
brs.forEach(br => anchor.before(br));
} else if (brs.includes(lastChild)) {
const brToRemove = isBlock(anchor) && brs.pop();
brs.forEach(br => anchor.after(br));
if (brToRemove) {
// When the anchor tag is block, keeping the two `br` tags
// would have resulted into two new lines instead of one.
brToRemove.remove();
setSelection(...leftPos(brs[0]));
} else {
setSelection(...rightPos(brs[0]));
}
}
}

View file

@ -0,0 +1,79 @@
/** @odoo-module **/
import { isUnbreakable, preserveCursor, toggleClass, isBlock, isVisible } from '../utils/utils.js';
Text.prototype.oShiftTab = function () {
return this.parentElement.oShiftTab(0);
};
HTMLElement.prototype.oShiftTab = function (offset = undefined) {
if (!isUnbreakable(this)) {
return this.parentElement.oShiftTab(offset);
}
return false;
};
// returns: is still in a <LI> nested list
HTMLLIElement.prototype.oShiftTab = function () {
const li = this;
if (li.nextElementSibling) {
const ul = li.parentElement.cloneNode(false);
while (li.nextSibling) {
ul.append(li.nextSibling);
}
if (li.parentNode.parentNode.tagName === 'LI') {
const lip = document.createElement('li');
toggleClass(lip, 'oe-nested');
lip.append(ul);
li.parentNode.parentNode.after(lip);
} else {
li.parentNode.after(ul);
}
}
const restoreCursor = preserveCursor(this.ownerDocument);
if (
li.parentNode.parentNode.tagName === 'LI' &&
!li.parentNode.parentNode.classList.contains('nav-item')
) {
const ul = li.parentNode;
const shouldRemoveParentLi = !li.previousElementSibling && !ul.previousElementSibling;
const toremove = shouldRemoveParentLi ? ul.parentNode : null;
ul.parentNode.after(li);
if (toremove) {
if (toremove.classList.contains('oe-nested')) {
// <li>content<ul>...</ul></li>
toremove.remove();
} else {
// <li class="oe-nested"><ul>...</ul></li>
ul.remove();
}
}
restoreCursor();
return li;
} else {
const ul = li.parentNode;
const dir = ul.getAttribute('dir');
let p;
while (li.firstChild) {
if (isBlock(li.firstChild)) {
p = isVisible(p) && ul.after(p) && undefined;
ul.after(li.firstChild);
} else {
p = p || document.createElement('P');
if (dir) {
p.setAttribute('dir', dir);
p.style.setProperty('text-align', ul.style.getPropertyValue('text-align'));
}
p.append(li.firstChild);
}
}
if (isVisible(p)) ul.after(p);
restoreCursor(new Map([[li, ul.nextSibling]]));
li.remove();
if (!ul.firstElementChild) {
ul.remove();
}
}
return false;
};

View file

@ -0,0 +1,31 @@
/** @odoo-module **/
import { createList, getListMode, isBlock, preserveCursor, toggleClass } from '../utils/utils.js';
Text.prototype.oTab = function () {
return this.parentElement.oTab(0);
};
HTMLElement.prototype.oTab = function (offset) {
if (!isBlock(this)) {
return this.parentElement.oTab(offset);
}
return false;
};
HTMLLIElement.prototype.oTab = function () {
const lip = document.createElement('li');
const destul =
(this.previousElementSibling && this.previousElementSibling.querySelector('ol, ul')) ||
(this.nextElementSibling && this.nextElementSibling.querySelector('ol, ul')) ||
this.closest('ul, ol');
const ul = createList(getListMode(destul));
lip.append(ul);
const cr = preserveCursor(this.ownerDocument);
toggleClass(lip, 'oe-nested');
this.before(lip);
ul.append(this);
cr();
return true;
};

View file

@ -0,0 +1,80 @@
/** @odoo-module **/
import {
childNodeIndex,
isBlock,
preserveCursor,
insertListAfter,
getAdjacents,
closestElement,
toggleList,
} from '../utils/utils.js';
Text.prototype.oToggleList = function (offset, mode) {
// Create a new list if textNode is inside a nav-item list
if (closestElement(this, 'li').classList.contains('nav-item')) {
const restoreCursor = preserveCursor(this.ownerDocument);
insertListAfter(this, mode, [this]);
restoreCursor();
} else {
this.parentElement.oToggleList(childNodeIndex(this), mode);
}
};
HTMLElement.prototype.oToggleList = function (offset, mode = 'UL') {
if (!isBlock(this)) {
return this.parentElement.oToggleList(childNodeIndex(this));
}
const closestLi = this.closest('li');
// Do not toggle nav-item list as they don't behave like regular list items
if (closestLi && !closestLi.classList.contains('nav-item')) {
return closestLi.oToggleList(0, mode);
}
const restoreCursor = preserveCursor(this.ownerDocument);
if (this.oid === 'root') {
const callingNode = this.childNodes[offset];
const group = getAdjacents(callingNode, n => !isBlock(n));
insertListAfter(callingNode, mode, [group]);
restoreCursor();
} else {
const list = insertListAfter(this, mode, [this]);
if (this.hasAttribute('dir')) {
list.setAttribute('dir', this.getAttribute('dir'));
}
restoreCursor(new Map([[this, list.firstElementChild]]));
}
};
HTMLParagraphElement.prototype.oToggleList = function (offset, mode = 'UL') {
const restoreCursor = preserveCursor(this.ownerDocument);
const list = insertListAfter(this, mode, [[...this.childNodes]]);
const classList = [...list.classList];
for (const attribute of this.attributes) {
if (attribute.name === 'class' && attribute.value && list.className) {
list.className = `${list.className} ${attribute.value}`;
} else {
list.setAttribute(attribute.name, attribute.value);
}
}
for (const className of classList) {
list.classList.toggle(className, true); // restore list classes
}
this.remove();
restoreCursor(new Map([[this, list.firstChild]]));
return true;
};
HTMLLIElement.prototype.oToggleList = function (offset, mode) {
const restoreCursor = preserveCursor(this.ownerDocument);
toggleList(this, mode, offset);
restoreCursor();
return false;
};
HTMLTableCellElement.prototype.oToggleList = function (offset, mode) {
const restoreCursor = preserveCursor(this.ownerDocument);
const callingNode = this.childNodes[offset];
const group = getAdjacents(callingNode, n => !isBlock(n));
insertListAfter(callingNode, mode, [group]);
restoreCursor();
};

View file

@ -0,0 +1,397 @@
/** @odoo-module **/
import { patienceDiff } from './patienceDiff.js';
import { closestBlock, getRangePosition } from '../utils/utils.js';
const REGEX_RESERVED_CHARS = /[\\^$.*+?()[\]{}|]/g;
/**
* Make `num` cycle from 0 to `max`.
*/
function cycle(num, max) {
const y = max + 1;
return ((num % y) + y) % y;
}
/**
* interface PowerboxCommand {
* category: string;
* name: string;
* priority: number;
* description: string;
* fontawesome: string; // a fontawesome class name
* callback: () => void; // to execute when the command is picked
* isDisabled?: () => boolean; // return true to disable the command
* }
*/
export class Powerbox {
constructor({
categories, commands, commandFilters, editable, getContextFromParentRect,
onShow, onStop, beforeCommand, afterCommand
} = {}) {
this.categories = categories;
this.commands = commands;
this.commandFilters = commandFilters || [];
this.editable = editable;
this.getContextFromParentRect = getContextFromParentRect;
this.onShow = onShow;
this.onStop = onStop;
this.beforeCommand = beforeCommand;
this.afterCommand = afterCommand;
this.isOpen = false;
this.document = editable.ownerDocument;
// Draw the powerbox.
this.el = document.createElement('div');
this.el.className = 'oe-powerbox-wrapper';
this.el.style.display = 'none';
document.body.append(this.el);
this._mainWrapperElement = document.createElement('div');
this._mainWrapperElement.className = 'oe-powerbox-mainWrapper';
this.el.append(this._mainWrapperElement);
this.el.addEventListener('mousedown', ev => ev.stopPropagation());
// Set up events for later binding.
this._boundOnKeyup = this._onKeyup.bind(this);
this._boundOnKeydown = this._onKeydown.bind(this);
this._boundClose = this.close.bind(this);
this._events = [
[this.document, 'keyup', this._boundOnKeyup],
[this.document, 'keydown', this._boundOnKeydown, true],
[this.document, 'mousedown', this._boundClose],
]
// If the global document is different from the provided
// options.document, which happens when the editor is inside an iframe,
// we need to listen to the mouse event on both documents to be sure the
// Powerbox will always close when clicking outside of it.
if (document !== this.document) {
this._events.push(
[document, 'mousedown', this._boundClose],
);
}
}
destroy() {
if (this.isOpen) {
this.close();
}
this.el.remove();
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
/**
* Open the Powerbox with the given commands or with all instance commands.
*
* @param {PowerboxCommand[]} [commands=this.commands]
* @param {Array<{name: string, priority: number}} [categories=this.categories]
*/
open(commands=this.commands, categories=this.categories) {
commands = (commands || []).map(command => ({
...command,
category: command.category || '',
name: command.name || '',
priority: command.priority || 0,
description: command.description || '',
callback: command.callback || (() => {}),
}));
categories = (categories || []).map(category => ({
name: category.name || '',
priority: category.priority || 0,
}));
const order = (a, b) => b.priority - a.priority || a.name.localeCompare(b.name);
// Remove duplicate category names, keeping only last declared version,
// and order them.
categories = [...categories].reverse().filter((category, index, cats) => (
cats.findIndex(cat => cat.name === category.name) === index
)).sort(order);
// Apply optional filters to disable commands, then order them.
for (let filter of this.commandFilters) {
commands = filter(commands);
}
commands = commands.filter(command => !command.isDisabled || !command.isDisabled()).sort(order);
commands = this._groupCommands(commands, categories).flatMap(group => group[1]);
const selection = this.document.getSelection();
const currentBlock = (selection && closestBlock(selection.anchorNode)) || this.editable;
this._context = {
commands, categories, filteredCommands: commands, selectedCommand: undefined,
initialTarget: currentBlock, initialValue: currentBlock.textContent,
lastText: undefined,
}
this.isOpen = true;
this._render(this._context.commands, this._context.categories);
this._bindEvents();
this.show();
}
/**
* Close the Powerbox without destroying it. Unbind events, reset context
* and call the optional `onStop` hook.
*/
close() {
this.isOpen = false;
this.hide();
this._context = undefined;
this._unbindEvents();
this.onStop && this.onStop();
};
/**
* Show the Powerbox and position it. Call the optional `onShow` hook.
*/
show() {
this.onShow && this.onShow();
this.el.style.display = 'flex';
this._resetPosition();
}
/**
* Hide the Powerbox. If the Powerbox is active, close it.
*
* @see close
*/
hide() {
this.el.style.display = 'none';
if (this.isOpen) {
this.close();
}
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
/**
* Render the Powerbox with the given commands, grouped by `category`.
*
* @private
* @param {PowerboxCommand[]} commands
* @param {Array<{name: string, priority: number}} categories
*/
_render(commands, categories) {
const parser = new DOMParser();
this._mainWrapperElement.innerHTML = '';
this._hoverActive = false;
this._mainWrapperElement.classList.toggle('oe-powerbox-noResult', commands.length === 0);
this._context.selectedCommand = commands.find(command => command === this._context.selectedCommand) || commands[0];
for (const [category, categoryCommands] of this._groupCommands(commands, categories)) {
const categoryWrapperEl = parser.parseFromString(`
<div class="oe-powerbox-categoryWrapper">
<div class="oe-powerbox-category"></div>
</div>`, 'text/html').body.firstChild;
this._mainWrapperElement.append(categoryWrapperEl);
categoryWrapperEl.firstElementChild.innerText = category;
for (const command of categoryCommands) {
const commandElWrapper = document.createElement('div');
commandElWrapper.className = 'oe-powerbox-commandWrapper';
commandElWrapper.classList.toggle('active', this._context.selectedCommand === command);
commandElWrapper.replaceChildren(...parser.parseFromString(`
<div class="oe-powerbox-commandLeftCol">
<i class="oe-powerbox-commandImg fa"></i>
</div>
<div class="oe-powerbox-commandRightCol">
<div class="oe-powerbox-commandName"></div>
<div class="oe-powerbox-commandDescription"></div>
</div>`, 'text/html').body.children);
commandElWrapper.querySelector('.oe-powerbox-commandImg').classList.add(command.fontawesome);
commandElWrapper.querySelector('.oe-powerbox-commandName').innerText = command.name;
commandElWrapper.querySelector('.oe-powerbox-commandDescription').innerText = command.description;
categoryWrapperEl.append(commandElWrapper);
// Handle events on command (activate and pick).
commandElWrapper.addEventListener('mousemove', () => {
this.el.querySelector('.oe-powerbox-commandWrapper.active').classList.remove('active');
this._context.selectedCommand = command;
commandElWrapper.classList.add('active');
});
commandElWrapper.addEventListener('click', ev => {
ev.preventDefault();
ev.stopImmediatePropagation();
this._pickCommand(command);
}, true,
);
}
}
// Hide category name if there is only a single one.
if (this._mainWrapperElement.childElementCount === 1) {
this._mainWrapperElement.querySelector('.oe-powerbox-category').style.display = 'none';
}
this._resetPosition();
}
/**
* Handle the selection of a command: call the command's callback. Also call
* the `beforeCommand` and `afterCommand` hooks if they exists.
*
* @private
* @param {PowerboxCommand} [command=this._context.selectedCommand]
*/
async _pickCommand(command=this._context.selectedCommand) {
if (command) {
if (this.beforeCommand) {
await this.beforeCommand();
}
await command.callback();
if (this.afterCommand) {
await this.afterCommand();
}
}
this.close();
};
/**
* Takes a list of commands and returns an object whose keys are all
* existing category names and whose values are each of these categories'
* commands. Categories with no commands are removed.
*
* @private
* @param {PowerboxCommand[]} commands
* @param {Array<{name: string, priority: number}} categories
* @returns {{Array<[string, PowerboxCommand[]]>}>}
*/
_groupCommands(commands, categories) {
const groups = [];
for (const category of categories) {
const categoryCommands = commands.filter(command => command.category === category.name);
commands = commands.filter(command => command.category !== category.name);
groups.push([category.name, categoryCommands]);
}
// If commands remain, it means they declared categories that didn't
// exist. Add these categories alphabetically at the end of the list.
const remainingCategories = [...new Set(commands.map(command => command.category))];
for (const categoryName of remainingCategories.sort((a, b) => a.localeCompare(b))) {
const categoryCommands = commands.filter(command => command.category === categoryName);
groups.push([categoryName, categoryCommands]);
}
return groups.filter(group => group[1].length);
}
/**
* Take an array of commands or categories and return a reordered copy of
* it, based on their respective priorities.
*
* @param {PowerboxCommand[] | Array<{name: string, priority: number}} commandsOrCategories
* @returns {PowerboxCommand[] | Array<{name: string, priority: number}}
*/
_orderByPriority(commandsOrCategories) {
return [...commandsOrCategories].sort((a, b) => b.priority - a.priority || a.name.localeCompare(b.name));
}
/**
* Recompute the Powerbox's position base on the selection in the document.
*
* @private
*/
_resetPosition() {
const position = getRangePosition(this.el, this.document, { getContextFromParentRect: this.getContextFromParentRect });
if (position) {
let { left, top } = position;
this.el.style.left = `${left}px`;
this.el.style.top = `${top}px`;
} else {
this.hide();
}
}
/**
* Add all events to their given target, based on @see _events.
*
* @private
*/
_bindEvents() {
for (const [target, eventName, callback, option] of this._events) {
target.addEventListener(eventName, callback, option);
}
}
/**
* Remove all events from their given target, based on @see _events.
*
* @private
*/
_unbindEvents() {
for (const [target, eventName, callback, option] of this._events) {
target.removeEventListener(eventName, callback, option);
}
}
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
/**
* Handle keyup events to filter commands based on what was typed, and
* prevent changing selection when using the arrow keys.
*
* @private
* @param {KeyboardEvent} ev
*/
_onKeyup(ev) {
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
ev.preventDefault();
} else {
const diff = patienceDiff(
this._context.initialValue.split(''),
this._context.initialTarget.textContent.split(''),
true,
);
this._context.lastText = diff.bMove.join('').replaceAll('\ufeff', '');
const selection = this.document.getSelection();
if (
this._context.lastText.match(/\s/) ||
!selection ||
this._context.initialTarget !== closestBlock(selection.anchorNode)
) {
this.close();
} else {
const term = this._context.lastText.toLowerCase()
.replaceAll(/\s/g, '\\s')
.replaceAll('\u200B', '')
.replace(REGEX_RESERVED_CHARS, '\\$&');
if (term.length) {
const exactRegex = new RegExp(term, 'i');
const fuzzyRegex = new RegExp(term.match(/\\.|./g).join('.*'), 'i');
this._context.filteredCommands = this._context.commands.filter(command => {
const commandText = (command.category + ' ' + command.name);
const commandDescription = command.description.replace(/\s/g, '');
return commandText.match(fuzzyRegex) || commandDescription.match(exactRegex);
});
} else {
this._context.filteredCommands = this._context.commands;
}
this._render(this._context.filteredCommands, this._context.categories);
}
}
}
/**
* Handle keydown events to add keyboard interactions with the Powerbox.
*
* @private
* @param {KeyboardEvent} ev
*/
_onKeydown(ev) {
if (ev.key === 'Enter') {
ev.stopImmediatePropagation();
this._pickCommand();
ev.preventDefault();
} else if (ev.key === 'Escape') {
ev.stopImmediatePropagation();
this.close();
ev.preventDefault();
} else if (ev.key === 'Backspace' && !this._context.lastText) {
this.close();
} else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
ev.preventDefault();
ev.stopImmediatePropagation();
const commandIndex = this._context.filteredCommands.findIndex(
command => command === this._context.selectedCommand,
);
if (this._context.filteredCommands.length && commandIndex !== -1) {
const nextIndex = commandIndex + (ev.key === 'ArrowDown' ? 1 : -1);
const newIndex = cycle(nextIndex, this._context.filteredCommands.length - 1);
this._context.selectedCommand = this._context.filteredCommands[newIndex];
} else {
this._context.selectedCommand = undefined;
}
this._render(this._context.filteredCommands, this._context.categories);
const activeCommand = this.el.querySelector('.oe-powerbox-commandWrapper.active');
if (activeCommand) {
activeCommand.scrollIntoView({block: 'nearest', inline: 'nearest'});
}
}
}
}

View file

@ -0,0 +1,263 @@
/** @odoo-module **/
/**
* program: "patienceDiff" algorithm implemented in javascript.
* author: Jonathan Trent
* version: 2.0
*
* use: patienceDiff( aLines[], bLines[], diffPlusFlag)
*
* where:
* aLines[] contains the original text lines.
* bLines[] contains the new text lines.
* diffPlusFlag if true, returns additional arrays with the subset of lines that were
* either deleted or inserted. These additional arrays are used by patienceDiffPlus.
*
* returns an object with the following properties:
* lines[] with properties of:
* line containing the line of text from aLines or bLines.
* aIndex referencing the index in aLine[].
* bIndex referencing the index in bLines[].
* (Note: The line is text from either aLines or bLines, with aIndex and bIndex
* referencing the original index. If aIndex === -1 then the line is new from bLines,
* and if bIndex === -1 then the line is old from aLines.)
* moved is true if the line was moved from elsewhere in aLines[] or bLines[].
* lineCountDeleted is the number of lines from aLines[] not appearing in bLines[].
* lineCountInserted is the number of lines from bLines[] not appearing in aLines[].
* lineCountMoved is the number of lines moved outside of the Longest Common Subsequence.
*
*/
export function patienceDiff(aLines, bLines, diffPlusFlag) {
//
// findUnique finds all unique values in arr[lo..hi], inclusive. This
// function is used in preparation for determining the longest common
// subsequence. Specifically, it first reduces the array range in question
// to unique values.
//
// Returns an ordered Map, with the arr[i] value as the Map key and the
// array index i as the Map value.
//
function findUnique(arr, lo, hi) {
var lineMap = new Map();
for (let i = lo; i <= hi; i++) {
let line = arr[i];
if (lineMap.has(line)) {
lineMap.get(line).count++;
lineMap.get(line).index = i;
} else {
lineMap.set(line, { count: 1, index: i });
}
}
lineMap.forEach((val, key, map) => {
if (val.count !== 1) {
map.delete(key);
} else {
map.set(key, val.index);
}
});
return lineMap;
}
//
// uniqueCommon finds all the unique common entries between aArray[aLo..aHi]
// and bArray[bLo..bHi], inclusive. This function uses findUnique to pare
// down the aArray and bArray ranges first, before then walking the comparison
// between the two arrays.
//
// Returns an ordered Map, with the Map key as the common line between aArray
// and bArray, with the Map value as an object containing the array indexes of
// the matching unique lines.
//
function uniqueCommon(aArray, aLo, aHi, bArray, bLo, bHi) {
let ma = findUnique(aArray, aLo, aHi);
let mb = findUnique(bArray, bLo, bHi);
ma.forEach((val, key, map) => {
if (mb.has(key)) {
map.set(key, { indexA: val, indexB: mb.get(key) });
} else {
map.delete(key);
}
});
return ma;
}
//
// longestCommonSubsequence takes an ordered Map from the function uniqueCommon
// and determines the Longest Common Subsequence (LCS).
//
// Returns an ordered array of objects containing the array indexes of the
// matching lines for a LCS.
//
function longestCommonSubsequence(abMap) {
var ja = [];
// First, walk the list creating the jagged array.
abMap.forEach((val, key, map) => {
let i = 0;
while (ja[i] && ja[i][ja[i].length - 1].indexB < val.indexB) {
i++;
}
if (!ja[i]) {
ja[i] = [];
}
if (0 < i) {
val.prev = ja[i - 1][ja[i - 1].length - 1];
}
ja[i].push(val);
});
// Now, pull out the longest common subsequence.
var lcs = [];
if (0 < ja.length) {
let n = ja.length - 1;
var lcs = [ja[n][ja[n].length - 1]];
while (lcs[lcs.length - 1].prev) {
lcs.push(lcs[lcs.length - 1].prev);
}
}
return lcs.reverse();
}
// "result" is the array used to accumulate the aLines that are deleted, the
// lines that are shared between aLines and bLines, and the bLines that were
// inserted.
let result = [];
let deleted = 0;
let inserted = 0;
// aMove and bMove will contain the lines that don't match, and will be returned
// for possible searching of lines that moved.
let aMove = [];
let aMoveIndex = [];
let bMove = [];
let bMoveIndex = [];
//
// addToResult simply pushes the latest value onto the "result" array. This
// array captures the diff of the line, aIndex, and bIndex from the aLines
// and bLines array.
//
function addToResult(aIndex, bIndex) {
if (bIndex < 0) {
aMove.push(aLines[aIndex]);
aMoveIndex.push(result.length);
deleted++;
} else if (aIndex < 0) {
bMove.push(bLines[bIndex]);
bMoveIndex.push(result.length);
inserted++;
}
result.push({
line: 0 <= aIndex ? aLines[aIndex] : bLines[bIndex],
aIndex: aIndex,
bIndex: bIndex,
});
}
//
// addSubMatch handles the lines between a pair of entries in the LCS. Thus,
// this function might recursively call recurseLCS to further match the lines
// between aLines and bLines.
//
function addSubMatch(aLo, aHi, bLo, bHi) {
// Match any lines at the beginning of aLines and bLines.
while (aLo <= aHi && bLo <= bHi && aLines[aLo] === bLines[bLo]) {
addToResult(aLo++, bLo++);
}
// Match any lines at the end of aLines and bLines, but don't place them
// in the "result" array just yet, as the lines between these matches at
// the beginning and the end need to be analyzed first.
let aHiTemp = aHi;
while (aLo <= aHi && bLo <= bHi && aLines[aHi] === bLines[bHi]) {
aHi--;
bHi--;
}
// Now, check to determine with the remaining lines in the subsequence
// whether there are any unique common lines between aLines and bLines.
//
// If not, add the subsequence to the result (all aLines having been
// deleted, and all bLines having been inserted).
//
// If there are unique common lines between aLines and bLines, then let's
// recursively perform the patience diff on the subsequence.
let uniqueCommonMap = uniqueCommon(aLines, aLo, aHi, bLines, bLo, bHi);
if (uniqueCommonMap.size === 0) {
while (aLo <= aHi) {
addToResult(aLo++, -1);
}
while (bLo <= bHi) {
addToResult(-1, bLo++);
}
} else {
recurseLCS(aLo, aHi, bLo, bHi, uniqueCommonMap);
}
// Finally, let's add the matches at the end to the result.
while (aHi < aHiTemp) {
addToResult(++aHi, ++bHi);
}
}
//
// recurseLCS finds the longest common subsequence (LCS) between the arrays
// aLines[aLo..aHi] and bLines[bLo..bHi] inclusive. Then for each subsequence
// recursively performs another LCS search (via addSubMatch), until there are
// none found, at which point the subsequence is dumped to the result.
//
function recurseLCS(aLo, aHi, bLo, bHi, uniqueCommonMap) {
var x = longestCommonSubsequence(
uniqueCommonMap || uniqueCommon(aLines, aLo, aHi, bLines, bLo, bHi),
);
if (x.length === 0) {
addSubMatch(aLo, aHi, bLo, bHi);
} else {
if (aLo < x[0].indexA || bLo < x[0].indexB) {
addSubMatch(aLo, x[0].indexA - 1, bLo, x[0].indexB - 1);
}
let i;
for (i = 0; i < x.length - 1; i++) {
addSubMatch(x[i].indexA, x[i + 1].indexA - 1, x[i].indexB, x[i + 1].indexB - 1);
}
if (x[i].indexA <= aHi || x[i].indexB <= bHi) {
addSubMatch(x[i].indexA, aHi, x[i].indexB, bHi);
}
}
}
recurseLCS(0, aLines.length - 1, 0, bLines.length - 1);
if (diffPlusFlag) {
return {
lines: result,
lineCountDeleted: deleted,
lineCountInserted: inserted,
lineCountMoved: 0,
aMove: aMove,
aMoveIndex: aMoveIndex,
bMove: bMove,
bMoveIndex: bMoveIndex,
};
}
return {
lines: result,
lineCountDeleted: deleted,
lineCountInserted: inserted,
lineCountMoved: 0,
};
}

View file

@ -0,0 +1,164 @@
export const qwebSample = /* xml */ `
<h1>Qweb examples</h1>
<div>
<t t-set="foo1" t-value="2 + 1"></t>
<t t-esc="object">foo</t>
<t t-raw="object">foo_raw</t>
<t t-esc="invisible"></t>
</div>
<h2>t-esc in link</h2>
<a href="#"="foo">Link without t-esc</a>
<a href="#" t-esc="foo">Link with t-esc</a>
<a href="#"><strong t-esc="foo">Link with t-esc and strong</strong></a>
<h2>if else part 1</h2>
<div>
<t t-if="record.partner_id.parent_id">
<t t-esc="record.partner_id.name">Brandon Freeman</t> (<t t-esc="record.partner_id.parent_id.name">Azure Interior</t>),
</t>
<t t-else="">
<t t-esc="record.partner_id.name">Brandon Freeman</t>,
</t>
</div>
<h2>if else part 2</h2>
<div>
<t t-if="condition">
<p>if1</p>
<t t-if="condition">
<p>if1.a</p>
</t>
<t t-elif="condition">
<p>elif1.b</p>
</t>
<t t-else="condition">
<p>elif1.c</p>
</t>
</t>
<t t-if="condition">
<p>if2</p>
</t>
<t t-elif="condition">
<p>elif2.1</p>
</t>
<t t-else="condition">
<p>elif2.1</p>
<t t-if="condition">
<p>if2.1.1</p>
</t>
<t t-elif="condition">
<p>elif2.1.2</p>
</t>
<t t-else="condition">
<p>elif2.1.3</p>
</t>
</t>
</div>
<h2>if else part 3</h2>
<div>
<t t-if="condition">
<p>if</p>
<t t-if="condition">
<p>if/if</p>
</t>
<t t-elif="condition">
<p>if/elsif</p>
</t>
<t t-else="condition">
<p>if/else</p>
</t>
</t>
<t t-elif="condition">
<p>elif</p>
<t t-if="condition">
<p>elif/if</p>
</t>
<t t-elif="condition">
<p>elif/elif</p>
</t>
<t t-else="condition">
<p>elif/else</p>
</t>
</t>
<t t-else="condition">
<p>else</p>
<t t-if="condition">
<p>else/if</p>
</t>
<t t-elif="condition">
<p>else/elif</p>
</t>
<t t-else="condition">
<p>else/else</p>
</t>
</t>
</div>
<div>
<p t-esc="value">the value</p>
</div>
<div>
<p t-if="condition">ok</p>
</div>
<div>
<p t-if="user.birthday == today()">Happy birthday!</p>
<p t-elif="user.login == 'root'">Welcome master!</p>
<p t-else="">Welcome!</p>
</div>
<t t-foreach="[1, 2, 3]" t-as="i">
<p><t t-esc="i"></t></p>
</t>
<p t-foreach="[1, 2, 3]" t-as="i">
<t t-esc="i"></t>
</p>
<t t-set="foo2">
<li>ok</li>
</t>
<t t-esc="foo2"></t>
<t t-call="other-template"></t>
<t t-call="other-template">
<t t-set="foo3" t-value="1"></t>
</t>
<div>
This template was called with content:
<t t-raw="0"></t>
</div>
<h2>t-if should be inline</h2>
<div style="text-align: center; margin: 16px 0px 16px 0px;">
<t t-if="not is_online or object.state != 'accepted'">
<a t-attf-href="/calendar/meeting/accept?token={{object.access_token}}&amp;id={{object.event_id.id}}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px">
Accept</a>
<a t-attf-href="/calendar/meeting/decline?token={{object.access_token}}&amp;id={{object.event_id.id}}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px">
Decline</a>
</t>
<a t-attf-href="/calendar/meeting/view?token={{object.access_token}}&amp;id={{object.event_id.id}}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px"><t t-esc="'Reschedule' if is_online and target_customer else 'View'">View</t></a>
</div>
<h2>should see the t-if background color</h2>
<div style="padding-top:5px;">
<ul>
<t t-if="not is_online and object.event_id.description">
<li>Description: <t t-esc="object.event_id.description or ''" data-oe-t-inline="true">Meeting to discuss project plan and hash out the details of implementation.</t></li>
</t>
<t t-elif="is_online and object.event_id.description">
<t t-set="object.event_id.description_to_html_lines()" t-value="splitted_description" data-oe-t-inline="true"></t>
<li>Description:
<ul t-foreach="splitted_description" t-as="description_line">
<li t-out="description_line or ''">Email: my.email@test.example.com</li>
</ul>
</li>
</t>
</ul>
</div>
`;

View file

@ -0,0 +1,485 @@
.odoo-editor-editable {
.btn {
user-select: auto;
cursor: text !important;
}
::selection {
/* For color conversion over white background, use X = (Y-(1-P)*255)/P where
X = converted color component (R, G, B) (0 <= X <= 255)
Y = desired apparent color component (R, G, B) (0 <= Y <= 255)
P = opacity (0 <= P <=1)
(limitation: Y + 255P >= 255)
*/
background-color: rgba(117, 167, 249, 0.5) !important; /* #bad3fc equivalent when over white*/
}
&.o_col_resize {
cursor: col-resize;
::selection {
background-color: transparent;
}
}
&.o_row_resize {
cursor: row-resize;
::selection {
background-color: transparent;
}
}
}
.o_selected_table {
caret-color: transparent;
::selection {
background-color: transparent !important;
}
.o_selected_td {
box-shadow: 0 0 0 100vmax rgba(117, 167, 249, 0.5) inset; /* #bad3fc equivalent when over white, overlaying on the bg color*/
border-collapse: separate;
cursor: pointer !important;
}
}
.o_table_ui_container {
position: absolute;
visibility: hidden;
top: 0;
left: 0;
}
.o_table_ui {
background-color: transparent;
position: absolute;
z-index: 10;
padding: 0;
&:hover {
visibility: visible !important;
}
> div {
position: absolute;
left: 0;
top: 0;
}
.o_table_ui_menu_toggler {
cursor: pointer;
background-color: var(--o-table-ui-bg, #{$o-white});
color: var(--o-table-ui-color, #{$o-main-text-color});
border: $border-width solid rgba($color: $o-brand-odoo, $alpha: 1.0);
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
background-color: rgba($color: $o-brand-odoo, $alpha: 0.7);
}
.o_table_ui_menu {
display: none;
cursor: pointer;
background-color: var(--o-table-ui-bg, #{$o-white});
width: fit-content;
border: $border-width solid var(--o-table-ui-border, #{$border-color});
padding: 5px 0;
white-space: nowrap;
margin-left: 50%;
> div:hover {
background-color: var(--o-table-ui-hover, #{$o-gray-200});
}
span {
margin-right: 8px;
color: var(--o-table-ui-color, #{$o-main-text-color});
}
div {
padding: 2px 8px;
}
}
&.o_open {
visibility: visible !important;
.o_table_ui_menu {
display: block;
> div.o_hide {
display: none;
}
}
}
&.o_row_ui {
border-right: none !important;
min-width: 1rem;
.o_table_ui_menu_toggler {
min-width: 1rem;
}
.o_table_ui_menu {
position: absolute;
margin-left: 100%;
top: 50%;
}
}
&.o_column_ui {
border-bottom: none !important;
}
}
.odoo-editor-editable a.o_link_in_selection:not(.btn) {
background-color: #a6e3e2;
color: black !important;
border: 1px dashed #008f8c;
margin: -1px;
}
.oe-floating {
box-shadow: 0px 3px 18px rgba(0, 0, 0, .23);
border-radius: 4px;
position: absolute;
}
/* toolbar styling */
.oe-toolbar {
box-sizing: border-box;
position: absolute;
visibility: hidden;
height: fit-content;
width: fit-content;
padding-left: 5px;
padding-right: 5px;
background: #222222;
color: white;
border-radius: 8px;
&.toolbar-bottom::before {
content: '';
position: absolute;
width: 0;
height: 0;
left: var(--arrow-left-pos);
top: var(--arrow-top-pos);
border: transparent 10px solid;
border-bottom: #222222 10px solid;
z-index: 0;
}
&:not(.toolbar-bottom)::before {
content: '';
position: absolute;
width: 0;
height: 0;
left: var(--arrow-left-pos);
top: var(--arrow-top-pos);
border: transparent 10px solid;
border-top: #222222 10px solid;
z-index: 0;
pointer-events: none;
}
.button-group {
display: inline-block;
margin-right: 13px;
}
.button-group:last-of-type {
margin-right: 0;
}
.btn {
position: relative;
box-sizing: content-box;
display: inline-block;
padding: 7px;
color: white;
}
.btn:not(.disabled):hover {
background: #868686;
}
.oe-toolbar .dropdown-menu .btn {
background: #222222;
}
.btn.active {
background: #555555;
}
.dropdown-toggle {
background: transparent;
border: none;
padding: 7px;
&[aria-expanded="true"] {
background: #555555;
}
}
.dropdown-menu {
background: #222222;
min-width: max-content;
min-width: -webkit-max-content;
text-align: center;
}
.dropdown-item {
background: transparent;
color: white;
pre, h1, h2, h3, h4, h5, h6, blockquote {
margin: 0;
color: white;
}
&:hover, &:focus {
color: white;
background: #868686;
}
&.active, &:active {
color: white;
background: #555555;
}
}
li > a.dropdown-item {
color: white;
}
label, label span {
display: inline-block;
}
input[type="color"] {
width: 0;
height: 0;
padding: 0;
border: none;
box-sizing: border-box;
position: absolute;
opacity: 0;
top: 100%;
margin: 2px 0 0;
}
#colorInputButtonGroup label {
margin-bottom: 0;
}
.color-indicator {
background-color: transparent;
padding-bottom: 4px;
&.fore-color {
border-bottom: 2px solid var(--fore-color);
padding: 5px;
}
&.hilite-color {
border-bottom: 2px solid var(--hilite-color);
padding: 5px;
}
}
#style .dropdown-menu {
text-align: left;
}
}
.oe-tablepicker-dropdown .oe-tablepicker {
margin: -3px 2px -6px 2px;
}
.oe-tablepicker-wrapper.oe-floating {
padding: 3px;
// Bootstrap sets .modal z-index at 1055.
// Ensure tablepicker is visible in modals.
z-index: 1056;
background-color: var(--oeTablepicker__wrapper-bg, $o-white);
}
.oe-tablepicker-row {
line-height: 0;
}
.oe-tablepicker {
width: max-content;
width: -webkit-max-content;
.oe-tablepicker-row .oe-tablepicker-cell {
display: inline-block;
background-color: var(--oeTablepicker__cell-bg, $o-gray-200);
width: 19px;
height: 19px;
padding: 0;
margin-inline-end: 3px;
margin-bottom: 3px;
&:last-of-type {
margin-inline-end: 0;
}
&.active {
background-color: var(--oeTablepicker-color-accent, $o-brand-primary);
}
}
}
.oe-tablepicker-size {
text-align: center;
margin-top: 7px;
}
.oe-tablepicker-dropdown .oe-tablepicker-size {
color: white;
}
@media only screen and (max-width: 767px) {
.oe-toolbar {
position: relative;
visibility: visible;
width: 100%;
border-radius: 0;
background-color: white;
.btn {
color: black;
}
}
}
/* Content styling */
.oe-powerbox-wrapper {
position: absolute;
z-index: $zindex-modal;
border: black;
background: var(--oePowerbox__wrapper-bg, $o-white);
color: $o-main-text-color;
max-height: 40vh;
box-sizing: border-box;
max-width: 100%;
box-shadow: 0px 3px 18px rgba(0, 0, 0, .23);
border-radius: 4px;
overflow: hidden;
display: flex;
min-width: max-content;
::-webkit-scrollbar {
background: transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--oePowerbox__ScrollbarThumb-background-color, #D3D1CB);
}
::-webkit-scrollbar-track {
background: var(--oePowerbox__ScrollbarTrack-background-color, #EDECE9);
}
}
.oe-powerbox-mainWrapper {
flex: 1 1 auto;
overflow: auto;
padding: 5px 0;
overscroll-behavior: contain;
}
.oe-powerbox-category, .oe-powerbox-noResult {
margin: 10px;
color: var(--oePowerbox__category-color, $o-gray-600);
font-size: 11px;
}
.oe-powerbox-category {
text-transform: uppercase;
margin: 5px 12px;
}
.oe-powerbox-noResult {
display: none;
}
.oe-powerbox-commandWrapper {
display: flex;
padding: 6px 12px;
cursor: pointer;
&.active {
background: var(--oePowerbox__commandName-bg, $o-gray-100);
}
}
i.oe-powerbox-commandImg {
display: flex;
height: 30px;
width: 30px;
align-items: center;
justify-content: center;
background: var(--oePowerbox__commandImg-bg, $o-gray-100);
color: var(--oePowerbox__commandImg-color, $o-gray-800);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 7px;
font-size: 15px;
}
.oe-powerbox-commandName {
font-size: 13px;
color: var(--oePowerbox__commandName-color, $o-main-text-color);
}
.oe-powerbox-commandDescription {
color: var(--oePowerbox__commandDescription-color, $o-main-color-muted);
font-size: 12px;
}
.oe-powerbox-commandRightCol {
margin: 0 10px;
}
/* Command hints */
.oe-hint {
position: relative;
&:before {
content: attr(placeholder);
position: absolute;
top: 0;
left: 0;
display: block;
color: inherit;
opacity: 0.4;
pointer-events: none;
text-align: inherit;
width: 100%;
}
}
/* Collaboration cursor */
.oe-collaboration-selections-container {
position: absolute;
isolation: isolate;
height: 0;
width: 0;
z-index: 1;
}
.oe-collaboration-caret-top-square {
min-height: 5px;
min-width: 5px;
color: #fff;
text-shadow: 0 0 5px #000;
position: absolute;
bottom: 100%;
left: -4px;
white-space: nowrap;
&:hover {
border-radius: 2px;
padding: 0.3em 0.6em;
&::before {
content: attr(data-client-name);
}
}
}
.oe-collaboration-caret-avatar {
position: absolute;
height: 1.5rem;
width: 1.5rem;
border-radius: 50%;
transition: top 0.5s, left 0.5s;
> img {
height: 100%;
width: 100%;
border-radius: 50%;
}
&[data-overlapping-avatars]::after {
content: attr(data-overlapping-avatars);
background-color: green;
color: white;
border-radius: 50%;
font-size: 9px;
padding: 0 4px;
position: absolute;
top: 11px;
right: -5px;
z-index: 1;
}
}
code.o_inline_code {
background-color: #c5c5c5;
padding: 2px;
margin: 2px;
color: black;
font-size: inherit;
}

View file

@ -0,0 +1,161 @@
/** @odoo-module **/
import { getRangePosition } from '../utils/utils.js';
export class TablePicker extends EventTarget {
constructor(options = {}) {
super();
this.options = options;
this.options.minRowCount = this.options.minRowCount || 3;
this.options.minColCount = this.options.minColCount || 3;
this.options.getContextFromParentRect = this.options.getContextFromParentRect || (() => ({ top: 0, left: 0 }));
this.rowNumber = this.options.minRowCount;
this.colNumber = this.options.minColCount;
this.tablePickerWrapper = document.createElement('div');
this.tablePickerWrapper.classList.add('oe-tablepicker-wrapper');
this.tablePickerWrapper.innerHTML = `
<div class="oe-tablepicker"></div>
<div class="oe-tablepicker-size"></div>
`;
if (this.options.floating) {
this.tablePickerWrapper.style.position = 'absolute';
this.tablePickerWrapper.classList.add('oe-floating');
}
this.tablePickerElement = this.tablePickerWrapper.querySelector('.oe-tablepicker');
this.tablePickerSizeViewElement =
this.tablePickerWrapper.querySelector('.oe-tablepicker-size');
this.el = this.tablePickerWrapper;
this.hide();
}
render() {
this.tablePickerElement.innerHTML = '';
const colCount = Math.max(this.colNumber, this.options.minRowCount);
const rowCount = Math.max(this.rowNumber, this.options.minRowCount);
const extraCol = 1;
const extraRow = 1;
for (let rowNumber = 1; rowNumber <= rowCount + extraRow; rowNumber++) {
const rowElement = document.createElement('div');
rowElement.classList.add('oe-tablepicker-row');
this.tablePickerElement.appendChild(rowElement);
for (let colNumber = 1; colNumber <= colCount + extraCol; colNumber++) {
const cell = this.el.ownerDocument.createElement('div');
cell.classList.add('oe-tablepicker-cell', 'btn');
rowElement.appendChild(cell);
if (rowNumber <= this.rowNumber && colNumber <= this.colNumber) {
cell.classList.add('active');
}
const bindMouseMove = () => {
cell.addEventListener('mouseover', () => {
if (this.colNumber !== colNumber || this.rowNumber != rowNumber) {
this.colNumber = colNumber;
this.rowNumber = rowNumber;
this.render();
}
});
this.el.ownerDocument.removeEventListener('mousemove', bindMouseMove);
};
this.el.ownerDocument.addEventListener('mousemove', bindMouseMove);
cell.addEventListener('mousedown', this.selectCell.bind(this));
}
}
this.tablePickerSizeViewElement.textContent = `${this.colNumber}x${this.rowNumber}`;
}
show() {
this.reset();
this.el.style.display = 'block';
if (this.options.floating) {
this._showFloating();
}
}
hide() {
this.el.style.display = 'none';
}
reset() {
this.rowNumber = this.options.minRowCount;
this.colNumber = this.options.minColCount;
this.render();
}
selectCell() {
this.dispatchEvent(
new CustomEvent('cell-selected', {
detail: { colNumber: this.colNumber, rowNumber: this.rowNumber },
}),
);
}
_showFloating() {
const isRtl = this.options.direction === 'rtl';
const keydown = e => {
const actions = {
ArrowRight: {
colNumber: (this.colNumber + (isRtl ? -1 : 1)) || 1,
rowNumber: this.rowNumber,
},
ArrowLeft: {
colNumber: (this.colNumber + (isRtl ? 1 : -1)) || 1,
rowNumber: this.rowNumber,
},
ArrowUp: {
colNumber: this.colNumber,
rowNumber: this.rowNumber - 1 || 1,
},
ArrowDown: {
colNumber: this.colNumber,
rowNumber: this.rowNumber + 1,
},
};
const action = actions[e.key];
if (action) {
this.rowNumber = action.rowNumber || this.rowNumber;
this.colNumber = action.colNumber || this.colNumber;
this.render();
e.preventDefault();
} else if (e.key === 'Enter') {
this.selectCell();
e.preventDefault();
} else if (e.key === 'Escape') {
stop();
e.preventDefault();
}
};
const offset = getRangePosition(this.el, this.options.document, this.options);
if (isRtl) {
this.el.style.right = `${offset.right}px`;
} else {
this.el.style.left = `${offset.left}px`;
}
this.el.style.top = `${offset.top}px`;
const stop = () => {
this.hide();
this.options.document.removeEventListener('mousedown', stop);
this.removeEventListener('cell-selected', stop);
this.options.document.removeEventListener('keydown', keydown, true);
};
// Allow the mousedown that activate this command callback to release before adding the listener.
setTimeout(() => {
this.options.document.addEventListener('mousedown', stop);
});
this.options.document.addEventListener('keydown', keydown, true);
this.addEventListener('cell-selected', stop);
}
}

View file

@ -0,0 +1,4 @@
/** @odoo-module **/
export const UNBREAKABLE_ROLLBACK_CODE = 'UNBREAKABLE';
export const UNREMOVABLE_ROLLBACK_CODE = 'UNREMOVABLE';
export const REGEX_BOOTSTRAP_COLUMN = /(?:^| )col(-[a-zA-Z]+)?(-\d+)?(?:$| )/;

View file

@ -0,0 +1,424 @@
/** @odoo-module **/
import {
closestBlock,
closestElement,
endPos,
getListMode,
isBlock,
isVisibleEmpty,
moveNodes,
preserveCursor,
isFontAwesome,
getDeepRange,
isUnbreakable,
isEditorTab,
isZWS,
isArtificialVoidElement,
EMAIL_REGEX,
URL_REGEX_WITH_INFOS,
unwrapContents,
padLinkWithZws,
getTraversedNodes,
ZERO_WIDTH_CHARS_REGEX,
setSelection,
isVisible,
} from './utils.js';
const NOT_A_NUMBER = /[^\d]/g;
function hasPseudoElementContent (node, pseudoSelector) {
const content = getComputedStyle(node, pseudoSelector).getPropertyValue('content');
return content && content !== 'none';
}
export function areSimilarElements(node, node2) {
if (
!node ||
!node2 ||
node.nodeType !== Node.ELEMENT_NODE ||
node2.nodeType !== Node.ELEMENT_NODE
) {
return false;
}
if (node.tagName !== node2.tagName) {
return false;
}
for (const att of node.attributes) {
const att2 = node2.attributes[att.name];
if ((att2 && att2.value) !== att.value) {
return false;
}
}
for (const att of node2.attributes) {
const att2 = node.attributes[att.name];
if ((att2 && att2.value) !== att.value) {
return false;
}
}
function isNotNoneValue(value) {
return value && value !== 'none';
}
if (
isNotNoneValue(getComputedStyle(node, ':before').getPropertyValue('content')) ||
isNotNoneValue(getComputedStyle(node, ':after').getPropertyValue('content')) ||
isNotNoneValue(getComputedStyle(node2, ':before').getPropertyValue('content')) ||
isNotNoneValue(getComputedStyle(node2, ':after').getPropertyValue('content')) ||
isFontAwesome(node) || isFontAwesome(node2)
) {
return false;
}
if (node.tagName === 'LI' && node.classList.contains('oe-nested')) {
return (
node.lastElementChild &&
node2.firstElementChild &&
getListMode(node.lastElementChild) === getListMode(node2.firstElementChild)
);
}
if (['UL', 'OL'].includes(node.tagName)) {
return !isVisibleEmpty(node) && !isVisibleEmpty(node2);
}
if (isBlock(node) || isVisibleEmpty(node) || isVisibleEmpty(node2)) {
return false;
}
const nodeStyle = getComputedStyle(node);
const node2Style = getComputedStyle(node2);
return (
!+nodeStyle.padding.replace(NOT_A_NUMBER, '') &&
!+node2Style.padding.replace(NOT_A_NUMBER, '') &&
!+nodeStyle.margin.replace(NOT_A_NUMBER, '') &&
!+node2Style.margin.replace(NOT_A_NUMBER, '')
);
}
/**
* Returns a URL if link's label is a valid email of http URL, null otherwise.
*
* @param {HTMLAnchorElement} link
* @returns {String|null}
*/
function deduceURLfromLabel(link) {
// Skip modifying the href for Bootstrap tabs.
if (link && link.getAttribute("role") === "tab") {
return;
}
const label = link.innerText.trim().replace(ZERO_WIDTH_CHARS_REGEX, '');
// Check first for e-mail.
let match = label.match(EMAIL_REGEX);
if (match) {
return match[1] ? match[0] : 'mailto:' + match[0];
}
// Check for http link.
// Regex with 'g' flag is stateful, reset lastIndex before and after using
// exec.
URL_REGEX_WITH_INFOS.lastIndex = 0;
match = URL_REGEX_WITH_INFOS.exec(label);
URL_REGEX_WITH_INFOS.lastIndex = 0;
if (match && match[0] === label) {
const currentHttpProtocol = (link.href.match(/^http(s)?:\/\//gi) || [])[0];
if (match[2]) {
return match[0];
} else if (currentHttpProtocol) {
// Avoid converting a http link to https.
return currentHttpProtocol + match[0];
} else {
return 'https://' + match[0];
}
}
return null;
}
function shouldPreserveCursor(node, root) {
const selection = root.ownerDocument.getSelection();
return node.isConnected && selection &&
selection.anchorNode && root.contains(selection.anchorNode) &&
selection.focusNode && root.contains(selection.focusNode);
}
class Sanitize {
constructor(root) {
this.root = root;
this.parse(root);
// Handle unique ids.
const rootClosestBlock = closestBlock(root);
if (rootClosestBlock) {
// Ensure unique ids on checklists and stars.
const elementsWithId = [...rootClosestBlock.querySelectorAll('[id^=checkId-]')];
const maxId = Math.max(...[0, ...elementsWithId.map(node => +node.getAttribute('id').substring(8))]);
let nextId = maxId + 1;
const ids = [];
for (const node of rootClosestBlock.querySelectorAll('[id^=checkId-], .o_checklist > li, .o_stars')) {
if (
!node.classList.contains('o_stars') && (
!node.parentElement.classList.contains('o_checklist') ||
[...node.children].some(child => ['UL', 'OL'].includes(child.nodeName))
)) {
// Remove unique ids from checklists and stars from elements
// that are no longer checklist items or stars, and from
// parents of nested lists.
node.removeAttribute('id')
} else {
// Add/change IDs where needed, and ensure they're unique.
let id = node.getAttribute('id');
if (!id || ids.includes(id)) {
id = `checkId-${nextId}`;
nextId++;
node.setAttribute('id', id);
}
ids.push(id);
}
}
}
}
parse(node) {
node = closestBlock(node);
if (node && ['UL', 'OL'].includes(node.tagName)) {
node = node.parentElement;
}
this._parse(node);
}
_parse(node) {
while (node) {
const closestProtected = closestElement(node, '[data-oe-protected="true"]');
if (closestProtected && node !== closestProtected) {
return;
}
// Merge identical elements together.
while (
areSimilarElements(node, node.previousSibling) &&
!isUnbreakable(node) &&
!isEditorTab(node)
) {
getDeepRange(this.root, { select: true });
const restoreCursor = shouldPreserveCursor(node, this.root) && preserveCursor(this.root.ownerDocument);
const nodeP = node.previousSibling;
moveNodes(...endPos(node.previousSibling), node);
if (restoreCursor) {
restoreCursor();
}
node = nodeP;
}
// Remove comment nodes to avoid issues with mso comments.
if (node.nodeType === Node.COMMENT_NODE) {
node.remove();
}
const selection = this.root.ownerDocument.getSelection();
const anchor = selection && selection.anchorNode;
const anchorEl = anchor && closestElement(anchor);
// Remove zero-width spaces added by `fillEmpty` when there is
// content.
if (
node.nodeType === Node.TEXT_NODE &&
node.textContent.includes('\u200B') &&
node.parentElement.hasAttribute('data-oe-zws-empty-inline') &&
(
node.textContent.length > 1 ||
// There can be multiple ajacent text nodes, in which case
// the zero-width space is not needed either, despite being
// alone (length === 1) in its own text node.
Array.from(node.parentNode.childNodes).find(
sibling =>
sibling !== node &&
sibling.nodeType === Node.TEXT_NODE &&
sibling.length > 0
)
) &&
!isBlock(node.parentElement)
) {
const { anchorNode, focusNode, anchorOffset, focusOffset } = selection;
const restoreCursor = shouldPreserveCursor(node, this.root) && preserveCursor(this.root.ownerDocument);
const shouldAdaptAnchor = anchorNode === node && anchorOffset > node.textContent.indexOf('\u200B');
const shouldAdaptFocus = focusNode === node && focusOffset > node.textContent.indexOf('\u200B');
node.textContent = node.textContent.replace('\u200B', '');
node.parentElement.removeAttribute("data-oe-zws-empty-inline");
if (restoreCursor) {
restoreCursor();
}
if (shouldAdaptAnchor || shouldAdaptFocus) {
setSelection(
anchorNode, shouldAdaptAnchor ? anchorOffset - 1 : anchorOffset,
focusNode, shouldAdaptFocus ? focusOffset - 1 : focusOffset,
);
}
}
// Remove empty blocks in <li>
if (
node.nodeName === 'P' &&
node.parentElement.tagName === 'LI' &&
!node.parentElement.classList.contains('nav-item')
) {
const previous = node.previousSibling;
const attributes = node.attributes;
const parent = node.parentElement;
const restoreCursor = shouldPreserveCursor(node, this.root) && preserveCursor(this.root.ownerDocument);
if (attributes.length) {
const spanEl = document.createElement('span');
for (const attribute of attributes) {
spanEl.setAttribute(attribute.name, attribute.value);
}
if (spanEl.style.textAlign) {
// This is a tradeoff. Ideally, the state of the html
// after this function should be reachable by standard
// edition means and a span with display block is not.
// However, this is required in order to not break the
// design of already existing snippets.
spanEl.style.display = 'block';
}
spanEl.append(...node.childNodes);
node.replaceWith(spanEl);
} else {
unwrapContents(node);
}
if (previous && isVisible(previous) && !isBlock(previous) && previous.nodeName !== 'BR') {
const br = document.createElement('br');
previous.after(br);
}
if (restoreCursor) {
restoreCursor(new Map([[node, parent]]));
}
node = parent;
}
// Transform <li> into <p> if they are not in a <ul> / <ol>
if (node.nodeName === 'LI' && !node.closest('ul, ol')) {
const paragraph = document.createElement("p");
paragraph.replaceChildren(...node.childNodes);
node.replaceWith(paragraph);
node = paragraph;
}
// If node is UL or OL and its parent is UL or OL, nest it in an LI
// with class 'oe-nested'.
if (
['UL', 'OL'].includes(node.nodeName) &&
['UL', 'OL'].includes(node.parentNode.nodeName)
) {
const restoreCursor = shouldPreserveCursor(node, this.root) && preserveCursor(this.root.ownerDocument);
const li = document.createElement('li');
node.parentNode.insertBefore(li, node);
li.appendChild(node);
li.classList.add('oe-nested');
node = li;
if (restoreCursor) {
restoreCursor();
}
}
// Ensure a zero width space is present inside the FA element.
if (isFontAwesome(node) && node.textContent !== '\u200B') {
node.textContent = '\u200B';
}
// Ensure the editor tabs align on a 40px grid.
if (isEditorTab(node)) {
let tabPreviousSibling = node.previousSibling;
while (isZWS(tabPreviousSibling)) {
tabPreviousSibling = tabPreviousSibling.previousSibling;
}
if (isEditorTab(tabPreviousSibling)) {
node.style.width = '40px';
} else {
const editable = closestElement(node, '.odoo-editor-editable');
if (editable && editable.firstElementChild) {
const nodeRect = node.getBoundingClientRect();
const referenceRect = editable.firstElementChild.getBoundingClientRect();
// Values from getBoundingClientRect() are all zeros
// during Editor startup or saving. We cannot
// recalculate the tabs width in thoses cases.
if (nodeRect.width && referenceRect.width) {
const width = (nodeRect.left - referenceRect.left) % 40;
node.style.width = (40 - width) + 'px';
}
}
}
}
// Ensure elements which should not contain any content are tagged
// contenteditable=false to avoid any hiccup.
if (
isArtificialVoidElement(node) &&
node.getAttribute('contenteditable') !== 'false'
) {
node.setAttribute('contenteditable', 'false');
}
// Remove empty class/style attributes.
for (const attributeName of ['class', 'style']) {
if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute(attributeName) && !node.getAttribute(attributeName)) {
node.removeAttribute(attributeName);
}
}
let firstChild = node.firstChild;
// Unwrap the contents of SPAN and FONT elements without attributes.
if (
['SPAN', 'FONT'].includes(node.nodeName)
&& !node.hasAttributes()
&& !hasPseudoElementContent(node, "::before")
&& !hasPseudoElementContent(node, "::after")
) {
getDeepRange(this.root, { select: true });
const restoreCursor = shouldPreserveCursor(node, this.root) && preserveCursor(this.root.ownerDocument);
firstChild = unwrapContents(node)[0];
if (restoreCursor) {
restoreCursor();
}
}
if (firstChild) {
this._parse(firstChild);
}
// Remove link ZWNBSP not in selection
const editable = closestElement(this.root, '[contenteditable=true]');
if (
node.nodeType === Node.TEXT_NODE &&
node.textContent.includes('\uFEFF') &&
!closestElement(node, 'a') &&
!(editable && getTraversedNodes(editable).includes(node))
) {
const startsWithLegitZws = node.textContent.startsWith('\uFEFF') && node.previousSibling && node.previousSibling.nodeName === 'A';
const endsWithLegitZws = node.textContent.endsWith('\uFEFF') && node.nextSibling && node.nextSibling.nodeName === 'A';
let newText = node.textContent.replace(/\uFEFF/g, '');
if (startsWithLegitZws) {
newText = '\uFEFF' + newText;
}
if (endsWithLegitZws) {
newText = newText + '\uFEFF';
}
if (newText !== node.textContent) {
// We replace the text node with a new text node with the
// update text rather than just changing the text content of
// the node because these two methods create different
// mutations and at least the tour system breaks if all we
// send here is a text content change.
const newTextNode = document.createTextNode(newText);
node.before(newTextNode);
node.remove();
node = newTextNode;
}
}
// Update link URL if label is a new valid link.
if (node.nodeName === 'A') {
if (anchorEl === node) {
const url = deduceURLfromLabel(node);
if (url) {
node.setAttribute('href', url);
}
}
padLinkWithZws(this.root, node);
}
node = node.nextSibling;
}
}
}
export function sanitize(root) {
new Sanitize(root);
return root;
}

View file

@ -0,0 +1,80 @@
/** @odoo-module **/
// TODO: avoid empty keys when not necessary to reduce request size
export function serializeNode(node, nodesToStripFromChildren = new Set()) {
if (!node.oid) {
return;
}
const result = {
nodeType: node.nodeType,
oid: node.oid,
};
if (node.nodeType === Node.TEXT_NODE) {
result.textValue = node.nodeValue;
} else if (node.nodeType === Node.ELEMENT_NODE) {
result.tagName = node.tagName;
result.children = [];
result.attributes = {};
for (let i = 0; i < node.attributes.length; i++) {
result.attributes[node.attributes[i].name] = node.attributes[i].value;
}
let child = node.firstChild;
while (child) {
if (!nodesToStripFromChildren.has(child.oid)) {
const serializedChild = serializeNode(child, nodesToStripFromChildren);
if (serializedChild) {
result.children.push(serializedChild);
}
}
child = child.nextSibling;
}
}
return result;
}
export function unserializeNode(obj) {
if (!obj) {
return;
}
let result = undefined;
if (obj.nodeType === Node.TEXT_NODE) {
result = document.createTextNode(obj.textValue);
} else if (obj.nodeType === Node.ELEMENT_NODE) {
result = document.createElement(obj.tagName);
for (const key in obj.attributes) {
result.setAttribute(key, obj.attributes[key]);
}
obj.children.forEach(child => result.append(unserializeNode(child)));
} else {
console.warn('unknown node type');
}
if (result) {
result.oid = obj.oid;
return result;
}
}
export function serializeSelection(selection) {
if (
selection &&
selection.anchorNode &&
selection.anchorNode.oid &&
typeof selection.anchorOffset !== 'undefined' &&
selection.focusNode &&
selection.anchorNode.oid &&
typeof selection.focusOffset !== 'undefined'
) {
return {
anchorNodeOid: selection.anchorNode.oid,
anchorOffset: selection.anchorOffset,
focusNodeOid: selection.focusNode.oid,
focusOffset: selection.focusOffset,
};
} else {
return {
anchorNodeOid: undefined,
anchorOffset: undefined,
focusNodeOid: undefined,
focusOffset: undefined,
};
}
}

View file

@ -0,0 +1,47 @@
import './spec/utils.test.js';
import './spec/align.test.js';
import './spec/color.test.js';
import './spec/editor.test.js';
import './spec/copyPaste.test.js';
import './spec/htmlTables.test.js';
import './spec/list.test.js';
import './spec/link.test.js';
import './spec/format.test.js';
import './spec/insertHTML.test.js';
import './spec/fontAwesome.test.js';
import './spec/tabs.test.js';
import './spec/autostep.test.js';
import './spec/urlRegex.test.js';
import './spec/collab.test.js';
import './spec/odooFields.test.js';
import './spec/powerbox.test.js';
/* global mocha */
mocha.run(failures => {
if (failures) {
for (const faillureElement of [...document.querySelectorAll('.test.fail')]) {
const clonedFaillureElement = faillureElement.cloneNode(true);
clonedFaillureElement.querySelector('a').remove();
console.error(
[
clonedFaillureElement.querySelector('h2').innerText,
clonedFaillureElement.querySelector('.error').innerText,
].join('\n\n'),
);
}
// Better visualisation of invisible (ZWS & TABS) character in test
// report.
const report = document.querySelector("#mocha-report");
const allErrors = report.querySelectorAll('.test.fail .error');
allErrors.forEach((errorEl) => {
let errorElHtml = errorEl.outerHTML
errorElHtml = errorElHtml.replaceAll('//zws//', '<b class="zws">zws</b>');
errorElHtml = errorElHtml.replaceAll('//TAB//', '<b class="tab">Tab</b>');
errorEl.outerHTML = errorElHtml;
});
} else {
console.log('test successful');
}
});

View file

@ -0,0 +1,325 @@
@charset "utf-8";
body {
margin:0;
}
#mocha {
font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif;
margin: 60px 50px;
}
#mocha ul,
#mocha li {
margin: 0;
padding: 0;
}
#mocha ul {
list-style: none;
}
#mocha h1,
#mocha h2 {
margin: 0;
}
#mocha h1 {
margin-top: 15px;
font-size: 1em;
font-weight: 200;
}
#mocha h1 a {
text-decoration: none;
color: inherit;
}
#mocha h1 a:hover {
text-decoration: underline;
}
#mocha .suite .suite h1 {
margin-top: 0;
font-size: .8em;
}
#mocha .hidden {
display: none;
}
#mocha h2 {
font-size: 12px;
font-weight: normal;
cursor: pointer;
}
#mocha .suite {
margin-left: 15px;
}
#mocha .test {
margin-left: 15px;
overflow: hidden;
}
#mocha .test.pending:hover h2::after {
content: '(pending)';
font-family: arial, sans-serif;
}
#mocha .test.pass.medium .duration {
background: #c09853;
}
#mocha .test.pass.slow .duration {
background: #b94a48;
}
#mocha .test.pass::before {
content: '✓';
font-size: 12px;
display: block;
float: left;
margin-right: 5px;
color: #00d6b2;
}
#mocha .test.pass .duration {
font-size: 9px;
margin-left: 5px;
padding: 2px 5px;
color: #fff;
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.2);
-moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.2);
box-shadow: inset 0 1px 1px rgba(0,0,0,.2);
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
-ms-border-radius: 5px;
-o-border-radius: 5px;
border-radius: 5px;
}
#mocha .test.pass.fast .duration {
display: none;
}
#mocha .test.pending {
color: #0b97c4;
}
#mocha .test.pending::before {
content: '◦';
color: #0b97c4;
}
#mocha .test.fail {
color: #c00;
}
#mocha .test.fail pre {
color: black;
}
#mocha .test.fail::before {
content: '✖';
font-size: 12px;
display: block;
float: left;
margin-right: 5px;
color: #c00;
}
#mocha .test pre.error {
color: #c00;
max-height: 300px;
overflow: auto;
}
#mocha .test .html-error {
overflow: auto;
color: black;
display: block;
float: left;
clear: left;
font: 12px/1.5 monaco, monospace;
margin: 5px;
padding: 15px;
border: 1px solid #eee;
max-width: 85%; /*(1)*/
max-width: -webkit-calc(100% - 42px);
max-width: -moz-calc(100% - 42px);
max-width: calc(100% - 42px); /*(2)*/
max-height: 300px;
word-wrap: break-word;
border-bottom-color: #ddd;
-webkit-box-shadow: 0 1px 3px #eee;
-moz-box-shadow: 0 1px 3px #eee;
box-shadow: 0 1px 3px #eee;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
}
#mocha .test .html-error pre.error {
border: none;
-webkit-border-radius: 0;
-moz-border-radius: 0;
border-radius: 0;
-webkit-box-shadow: 0;
-moz-box-shadow: 0;
box-shadow: 0;
padding: 0;
margin: 0;
margin-top: 18px;
max-height: none;
}
/**
* (1): approximate for browsers not supporting calc
* (2): 42 = 2*15 + 2*10 + 2*1 (padding + margin + border)
* ^^ seriously
*/
#mocha .test pre {
display: block;
float: left;
clear: left;
font: 12px/1.5 monaco, monospace;
margin: 5px;
padding: 15px;
border: 1px solid #eee;
max-width: 85%; /*(1)*/
max-width: -webkit-calc(100% - 42px);
max-width: -moz-calc(100% - 42px);
max-width: calc(100% - 42px); /*(2)*/
word-wrap: break-word;
border-bottom-color: #ddd;
-webkit-box-shadow: 0 1px 3px #eee;
-moz-box-shadow: 0 1px 3px #eee;
box-shadow: 0 1px 3px #eee;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
}
#mocha .test h2 {
position: relative;
}
#mocha .test a.replay {
position: absolute;
top: 3px;
right: 0;
text-decoration: none;
vertical-align: middle;
display: block;
width: 15px;
height: 15px;
line-height: 15px;
text-align: center;
background: #eee;
font-size: 15px;
-webkit-border-radius: 15px;
-moz-border-radius: 15px;
border-radius: 15px;
-webkit-transition:opacity 200ms;
-moz-transition:opacity 200ms;
-o-transition:opacity 200ms;
transition: opacity 200ms;
opacity: 0.3;
color: #888;
}
#mocha .test:hover a.replay {
opacity: 1;
}
#mocha-report.pass .test.fail {
display: none;
}
#mocha-report.fail .test.pass {
display: none;
}
#mocha-report.pending .test.pass,
#mocha-report.pending .test.fail {
display: none;
}
#mocha-report.pending .test.pass.pending {
display: block;
}
#mocha-error {
color: #c00;
font-size: 1.5em;
font-weight: 100;
letter-spacing: 1px;
}
#mocha-stats {
position: fixed;
top: 15px;
right: 10px;
font-size: 12px;
margin: 0;
color: #888;
z-index: 1;
}
#mocha-stats .progress {
float: right;
padding-top: 0;
/**
* Set safe initial values, so mochas .progress does not inherit these
* properties from Bootstrap .progress (which causes .progress height to
* equal line height set in Bootstrap).
*/
height: auto;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
background-color: initial;
}
#mocha-stats em {
color: black;
}
#mocha-stats a {
text-decoration: none;
color: inherit;
}
#mocha-stats a:hover {
border-bottom: 1px solid #eee;
}
#mocha-stats li {
display: inline-block;
margin: 0 5px;
list-style: none;
padding-top: 11px;
}
#mocha-stats canvas {
width: 40px;
height: 40px;
}
#mocha code .comment { color: #ddd; }
#mocha code .init { color: #2f6fad; }
#mocha code .string { color: #5890ad; }
#mocha code .keyword { color: #8a6343; }
#mocha code .number { color: #2f6fad; }
@media screen and (max-device-width: 480px) {
#mocha {
margin: 60px 0px;
}
#mocha #stats {
position: absolute;
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,318 @@
import { BasicEditor, testEditor } from '../utils.js';
const justifyLeft = async function (editor) {
editor.execCommand('justifyLeft');
};
const justifyCenter = async function (editor) {
editor.execCommand('justifyCenter');
};
const justifyRight = async function (editor) {
editor.execCommand('justifyRight');
};
const justifyFull = async function (editor) {
editor.execCommand('justifyFull');
};
describe('Align', () => {
describe('left', () => {
it('should align left', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab</p><p>c[]d</p>',
stepFunction: justifyLeft,
contentAfter: '<p>ab</p><p>c[]d</p>',
});
});
it('should not align left a non-editable node', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab</p><div contenteditable="false"><p>c[]d</p></div>',
contentBeforeEdit: '<p>ab</p><div contenteditable="false" data-oe-keep-contenteditable=""><p>c[]d</p></div>',
stepFunction: justifyLeft,
contentAfterEdit: '<p>ab</p><div contenteditable="false" data-oe-keep-contenteditable=""><p>c[]d</p></div>',
contentAfter: '<p>ab</p><div contenteditable="false"><p>c[]d</p></div>',
});
});
// JW Test:
// it('should not change align style of a non-editable node', async () => {
// await testEditor(BasicEditor, {
// contentBefore: '<p>ab</p><p style="text-align: right;">c[]d</p>',
// stepFunction: async (editor: JWEditor) => {
// const domLayout = editor.plugins.get(Layout);
// const domEngine = domLayout.engines.dom;
// const editable = domEngine.components.editable[0];
// const root = editable;
// await editor.execCommand(async context => {
// root.lastChild().editable = false;
// await context.execCommand<Align>('align', { type: AlignType.LEFT });
// });
// },
// // The selection was removed because it's in a non-editable node.
// contentAfter: '<p>ab</p><p style="text-align: right;">cd</p>',
// });
// });
it('should align several paragraphs left', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[b</p><p>c]d</p>',
stepFunction: justifyLeft,
contentAfter: '<p>a[b</p><p>c]d</p>',
});
});
it('should left align a node within a right-aligned node', async () => {
await testEditor(BasicEditor, {
contentBefore: '<div style="text-align: right;"><p>ab</p><p>c[d]e</p></div>',
stepFunction: justifyLeft,
contentAfter:
'<div style="text-align: right;"><p>ab</p><p style="text-align: left;">c[d]e</p></div>',
});
});
it('should left align a node within a right-aligned node and a paragraph', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: right;"><p>ab</p><p>c[d</p></div><p>e]f</p>',
stepFunction: justifyLeft,
contentAfter:
'<div style="text-align: right;"><p>ab</p><p style="text-align: left;">c[d</p></div><p>e]f</p>',
});
});
it('should left align a node within a right-aligned node and a paragraph, with a center-aligned common ancestor', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: center;"><div style="text-align: right;"><p>ab</p><p>c[d</p></div><p>e]f</p></div>',
stepFunction: justifyLeft,
contentAfter:
'<div style="text-align: center;"><div style="text-align: right;"><p>ab</p><p style="text-align: left;">c[d</p></div><p style="text-align: left;">e]f</p></div>',
});
});
it('should left align a node within a right-aligned node and a paragraph, with a left-aligned common ancestor', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: left;"><div style="text-align: right;"><p>ab</p><p>c[d</p></div><p>e]f</p></div>',
stepFunction: justifyLeft,
contentAfter:
'<div style="text-align: left;"><div style="text-align: right;"><p>ab</p><p style="text-align: left;">c[d</p></div><p>e]f</p></div>',
});
});
it('should not left align a node that is already within a left-aligned node', async () => {
await testEditor(BasicEditor, {
contentBefore: '<div style="text-align: left;"><p>ab</p><p>c[d]e</p></div>',
stepFunction: justifyLeft,
contentAfter: '<div style="text-align: left;"><p>ab</p><p>c[d]e</p></div>',
});
});
it('should left align a container within an editable that is center-aligned', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div contenteditable="true" style="text-align: center;"><h1>a[]b</h1></div>',
stepFunction: justifyLeft,
contentAfter:
'<div contenteditable="true" style="text-align: center;"><h1 style="text-align: left;">a[]b</h1></div>',
});
});
});
describe('center', () => {
it('should align center', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab</p><p>c[]d</p>',
stepFunction: justifyCenter,
contentAfter: '<p>ab</p><p style="text-align: center;">c[]d</p>',
});
});
it('should align several paragraphs center', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[b</p><p>c]d</p>',
stepFunction: justifyCenter,
contentAfter:
'<p style="text-align: center;">a[b</p><p style="text-align: center;">c]d</p>',
});
});
it('should center align a node within a right-aligned node', async () => {
await testEditor(BasicEditor, {
contentBefore: '<div style="text-align: right;"><p>ab</p><p>c[d]e</p></div>',
stepFunction: justifyCenter,
contentAfter:
'<div style="text-align: right;"><p>ab</p><p style="text-align: center;">c[d]e</p></div>',
});
});
it('should center align a node within a right-aligned node and a paragraph', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: right;"><p>ab</p><p>c[d</p></div><p>e]f</p>',
stepFunction: justifyCenter,
contentAfter:
'<div style="text-align: right;"><p>ab</p><p style="text-align: center;">c[d</p></div><p style="text-align: center;">e]f</p>',
});
});
it('should center align a node within a right-aligned node and a paragraph, with a left-aligned common ancestor', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: left;"><div style="text-align: right;"><p>ab</p><p>c[d</p></div><p>e]f</p></div>',
stepFunction: justifyCenter,
contentAfter:
'<div style="text-align: left;"><div style="text-align: right;"><p>ab</p><p style="text-align: center;">c[d</p></div><p style="text-align: center;">e]f</p></div>',
});
});
it('should center align a node within a right-aligned node and a paragraph, with a center-aligned common ancestor', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: center;"><div style="text-align: right;"><p>ab</p><p>c[d</p></div><p>e]f</p></div>',
stepFunction: justifyCenter,
contentAfter:
'<div style="text-align: center;"><div style="text-align: right;"><p>ab</p><p style="text-align: center;">c[d</p></div><p>e]f</p></div>',
});
});
it('should not center align a node that is already within a center-aligned node', async () => {
await testEditor(BasicEditor, {
contentBefore: '<div style="text-align: center;"><p>ab</p><p>c[d]e</p></div>',
stepFunction: justifyCenter,
contentAfter: '<div style="text-align: center;"><p>ab</p><p>c[d]e</p></div>',
});
});
it('should center align a left-aligned container within an editable that is center-aligned', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div contenteditable="true" style="text-align: center;"><h1 style="text-align: left;">a[]b</h1></div>',
stepFunction: justifyCenter,
contentAfter:
'<div contenteditable="true" style="text-align: center;"><h1 style="text-align: center;">a[]b</h1></div>',
});
});
});
describe('right', () => {
it('should align right', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab</p><p>c[]d</p>',
stepFunction: justifyRight,
contentAfter: '<p>ab</p><p style="text-align: right;">c[]d</p>',
});
});
it('should align several paragraphs right', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[b</p><p>c]d</p>',
stepFunction: justifyRight,
contentAfter:
'<p style="text-align: right;">a[b</p><p style="text-align: right;">c]d</p>',
});
});
it('should right align a node within a center-aligned node', async () => {
await testEditor(BasicEditor, {
contentBefore: '<div style="text-align: center;"><p>ab</p><p>c[d]e</p></div>',
stepFunction: justifyRight,
contentAfter:
'<div style="text-align: center;"><p>ab</p><p style="text-align: right;">c[d]e</p></div>',
});
});
it('should right align a node within a center-aligned node and a paragraph', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: center;"><p>ab</p><p>c[d</p></div><p>e]f</p>',
stepFunction: justifyRight,
contentAfter:
'<div style="text-align: center;"><p>ab</p><p style="text-align: right;">c[d</p></div><p style="text-align: right;">e]f</p>',
});
});
it('should right align a node within a center-aligned node and a paragraph, with a justify-aligned common ancestor', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: justify;"><div style="text-align: center;"><p>ab</p><p>c[d</p></div><p>e]f</p></div>',
stepFunction: justifyRight,
contentAfter:
'<div style="text-align: justify;"><div style="text-align: center;"><p>ab</p><p style="text-align: right;">c[d</p></div><p style="text-align: right;">e]f</p></div>',
});
});
it('should right align a node within a center-aligned node and a paragraph, with a right-aligned common ancestor', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: right;"><div style="text-align: center;"><p>ab</p><p>c[d</p></div><p>e]f</p></div>',
stepFunction: justifyRight,
contentAfter:
'<div style="text-align: right;"><div style="text-align: center;"><p>ab</p><p style="text-align: right;">c[d</p></div><p>e]f</p></div>',
});
});
it('should not right align a node that is already within a right-aligned node', async () => {
await testEditor(BasicEditor, {
contentBefore: '<div style="text-align: right;"><p>ab</p><p>c[d]e</p></div>',
stepFunction: justifyRight,
contentAfter: '<div style="text-align: right;"><p>ab</p><p>c[d]e</p></div>',
});
});
it('should right align a container within an editable that is center-aligned', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div contenteditable="true" style="text-align: center;"><h1>a[]b</h1></div>',
stepFunction: justifyRight,
contentAfter:
'<div contenteditable="true" style="text-align: center;"><h1 style="text-align: right;">a[]b</h1></div>',
});
});
});
describe('justify', () => {
it('should align justify', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab</p><p>c[]d</p>',
stepFunction: justifyFull,
contentAfter: '<p>ab</p><p style="text-align: justify;">c[]d</p>',
});
});
it('should align several paragraphs justify', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[b</p><p>c]d</p>',
stepFunction: justifyFull,
contentAfter:
'<p style="text-align: justify;">a[b</p><p style="text-align: justify;">c]d</p>',
});
});
it('should justify align a node within a right-aligned node', async () => {
await testEditor(BasicEditor, {
contentBefore: '<div style="text-align: right;"><p>ab</p><p>c[d]e</p></div>',
stepFunction: justifyFull,
contentAfter:
'<div style="text-align: right;"><p>ab</p><p style="text-align: justify;">c[d]e</p></div>',
});
});
it('should justify align a node within a right-aligned node and a paragraph', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: right;"><p>ab</p><p>c[d</p></div><p>e]f</p>',
stepFunction: justifyFull,
contentAfter:
'<div style="text-align: right;"><p>ab</p><p style="text-align: justify;">c[d</p></div><p style="text-align: justify;">e]f</p>',
});
});
it('should justify align a node within a right-aligned node and a paragraph, with a center-aligned common ancestor', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: center;"><div style="text-align: right;"><p>ab</p><p>c[d</p></div><p>e]f</p></div>',
stepFunction: justifyFull,
contentAfter:
'<div style="text-align: center;"><div style="text-align: right;"><p>ab</p><p style="text-align: justify;">c[d</p></div><p style="text-align: justify;">e]f</p></div>',
});
});
it('should justify align a node within a right-aligned node and a paragraph, with a justify-aligned common ancestor', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div style="text-align: justify;"><div style="text-align: right;"><p>ab</p><p>c[d</p></div><p>e]f</p></div>',
stepFunction: justifyFull,
contentAfter:
'<div style="text-align: justify;"><div style="text-align: right;"><p>ab</p><p style="text-align: justify;">c[d</p></div><p>e]f</p></div>',
});
});
it('should not justify align a node that is already within a justify-aligned node', async () => {
await testEditor(BasicEditor, {
contentBefore: '<div style="text-align: justify;"><p>ab</p><p>c[d]e</p></div>',
stepFunction: justifyFull,
contentAfter: '<div style="text-align: justify;"><p>ab</p><p>c[d]e</p></div>',
});
});
it('should justify align a container within an editable that is center-aligned', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<div contenteditable="true" style="text-align: center;"><h1>a[]b</h1></div>',
stepFunction: justifyFull,
contentAfter:
'<div contenteditable="true" style="text-align: center;"><h1 style="text-align: justify;">a[]b</h1></div>',
});
});
});
});

View file

@ -0,0 +1,158 @@
import { BasicEditor, testEditor } from '../utils.js';
const timeoutPromise = ms =>
new Promise(resolve => {
setTimeout(() => resolve(), ms);
});
const appendSpan = (element, id = 1) => {
// The ID is necessary to prevent the editor to merge elements together:
// If multiple spans are sibblings and nothing differentiates them,
// the Editor will try to merge them.
const span = document.createElement('SPAN');
span.id = 'id-' + id;
span.textContent = '*';
element.append(span);
};
describe('Autostep', () => {
it('should record a change not made through the editor itself', async function () {
this.slow(600);
await testEditor(BasicEditor, {
contentBefore: '<p>a[]</p>',
stepFunction: async editor => {
const originalHistoryLength = editor._historySteps.length;
// Wait for some plugin (e.g. QWEB) that currently use a
// setTimeout in order to wait for the editable to be inserted
// in the DOM to perform logic that reset the autostep timeout.
await timeoutPromise(20);
appendSpan(editor.editable.querySelector('p'));
await timeoutPromise(20).then(() => {
window.chai.assert.strictEqual(
editor._historySteps.length,
originalHistoryLength,
);
});
await timeoutPromise(120).then(() => {
window.chai.assert.strictEqual(
editor._historySteps.length,
originalHistoryLength + 1,
'There is 1 more step in the history',
);
});
},
contentAfter: '<p>a[]<span id="id-1">*</span></p>',
});
});
it('should not record a change not made through the editor itself', async function () {
this.slow(600);
await testEditor(BasicEditor, {
contentBefore: '<p>a[]</p>',
stepFunction: async editor => {
editor.automaticStepUnactive('test');
const originalHistoryLength = editor._historySteps.length;
appendSpan(editor.editable.querySelector('p'));
await timeoutPromise(20).then(() => {
window.chai.assert.strictEqual(
editor._historySteps.length,
originalHistoryLength,
);
});
await timeoutPromise(120).then(() => {
window.chai.assert.strictEqual(
editor._historySteps.length,
originalHistoryLength,
'There is not more steps in the history',
);
});
},
contentAfter: '<p>a[]<span id="id-1">*</span></p>',
});
});
it('should record changes not made through the editor itself once reactivated', async function () {
this.slow(600);
await testEditor(BasicEditor, {
contentBefore: '<p>a[]</p>',
stepFunction: async editor => {
const originalHistoryLength = editor._historySteps.length;
// Wait for some plugin (e.g. QWEB) that currently use a
// setTimeout in order to wait for the editable to be inserted
// in the DOM to perform logic that reset the autostep timeout.
await timeoutPromise(20);
editor.automaticStepUnactive('test');
appendSpan(editor.editable.querySelector('p'), 1);
editor.automaticStepActive('test');
appendSpan(editor.editable.querySelector('p'), 2);
await timeoutPromise(120).then(() => {
window.chai.assert.strictEqual(
editor._historySteps.length,
originalHistoryLength + 1,
'There is 1 more steps in the history',
);
});
},
contentAfter: '<p>a[]<span id="id-1">*</span><span id="id-2">*</span></p>',
});
});
it('should record a change not made through the editor itself if not everyone has reactivated autostep', async function () {
this.slow(600);
await testEditor(BasicEditor, {
contentBefore: '<p>a[]</p>',
stepFunction: async editor => {
const originalHistoryLength = editor._historySteps.length;
editor.automaticStepUnactive('test');
editor.automaticStepUnactive('test2');
appendSpan(editor.editable.querySelector('p'), 1);
editor.automaticStepActive('test');
appendSpan(editor.editable.querySelector('p'), 2);
await timeoutPromise(120).then(() => {
window.chai.assert.strictEqual(
editor._historySteps.length,
originalHistoryLength,
'There is not more steps in the history',
);
});
},
contentAfter: '<p>a[]<span id="id-1">*</span><span id="id-2">*</span></p>',
});
});
it('should record a change not made through the editor itself if everyone has reactivated autostep', async function () {
this.slow(600);
await testEditor(BasicEditor, {
contentBefore: '<p>a[]</p>',
stepFunction: async editor => {
const originalHistoryLength = editor._historySteps.length;
// Wait for some plugin (e.g. QWEB) that currently use a
// setTimeout in order to wait for the editable to be inserted
// in the DOM to perform logic that reset the autostep timeout.
await timeoutPromise(20);
editor.automaticStepUnactive('test');
editor.automaticStepUnactive('test2');
appendSpan(editor.editable.querySelector('p'), 1);
editor.automaticStepActive('test');
appendSpan(editor.editable.querySelector('p'), 2);
editor.automaticStepActive('test2');
appendSpan(editor.editable.querySelector('p'), 3);
await timeoutPromise(120).then(() => {
window.chai.assert.strictEqual(
editor._historySteps.length,
originalHistoryLength + 1,
'There is 1 more steps in the history',
);
});
},
contentAfter: '<p>a[]<span id="id-1">*</span><span id="id-2">*</span><span id="id-3">*</span></p>',
});
});
});

View file

@ -0,0 +1,566 @@
import { OdooEditor, insertCharsAt } from '../../src/OdooEditor.js';
import {
parseMultipleTextualSelection,
redo,
setTestSelection,
targetDeepest,
undo,
patchEditorIframe,
} from '../utils.js';
const applyConcurentActions = (clientInfos, concurentActions) => {
const clientInfosList = Object.values(clientInfos);
for (const clientInfo of clientInfosList) {
if (typeof concurentActions[clientInfo.clientId] === 'function') {
concurentActions[clientInfo.clientId](clientInfo.editor);
}
}
};
const mergeClientsSteps = clientInfos => {
const clientInfosList = Object.values(clientInfos);
for (const clientInfoA of clientInfosList) {
for (const clientInfoB of clientInfosList) {
if (clientInfoA === clientInfoB) {
continue;
}
for (const step of clientInfoB.recordedHistorySteps) {
clientInfoA.editor.onExternalHistorySteps([JSON.parse(JSON.stringify(step))]);
}
}
}
};
const testSameHistory = clientInfos => {
const clientInfosList = Object.values(clientInfos);
const firstClientInfo = clientInfosList[0];
const historyLength = firstClientInfo.editor._historySteps.length;
for (const clientInfo of clientInfosList.slice(1)) {
window.chai
.expect(firstClientInfo.editor._historySteps.length)
.to.be.equal(historyLength, 'The history size should be the same.');
for (let i = 0; i < historyLength; i++) {
try {
window.chai
.expect(firstClientInfo.editor._historySteps[i].id)
.to.be.equal(
clientInfo.editor._historySteps[i].id,
`History steps are not consistent accross clients.`,
);
} catch (e) {
console.log(
'Clients steps:',
clientInfosList.map(x => x.editor._historySteps.map(y => `${y.id}`)),
);
throw e;
}
}
}
};
const testMultiEditor = spec => {
const clientInfos = {};
const concurentActions = spec.concurentActions || [];
const clientIds = spec.clientIds || Object.keys(concurentActions);
for (const clientId of clientIds) {
clientInfos[clientId] = {
clientId,
recordedHistorySteps: [],
};
const clientInfo = clientInfos[clientId];
clientInfo.iframe = document.createElement('iframe');
if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) {
// Firefox reset the page without this hack.
// With this hack, chrome does not render content.
clientInfo.iframe.setAttribute('src', ' javascript:void(0);');
}
document.body.appendChild(clientInfo.iframe);
patchEditorIframe(clientInfo.iframe);
clientInfo.editable = document.createElement('div');
clientInfo.editable.setAttribute('contenteditable', 'true');
clientInfo.editable.innerHTML = spec.contentBefore;
}
const clientInfosList = Object.values(clientInfos);
let shouldListenSteps = false;
// Init the editors
for (const clientInfo of clientInfosList) {
const selections = parseMultipleTextualSelection(clientInfo.editable);
const iframeWindow = clientInfo.iframe.contentWindow;
const iframeDocument = iframeWindow.document;
iframeDocument.body.appendChild(clientInfo.editable);
// Insure all the client will have the same starting id.
let nextId = 1;
OdooEditor.prototype._generateId = () => 'fake_id_' + nextId++;
clientInfo.editor = new OdooEditor(clientInfo.editable, {
toSanitize: false,
document: iframeDocument,
collaborationClientId: clientInfo.clientId,
onHistoryStep: step => {
if (shouldListenSteps) {
clientInfo.recordedHistorySteps.push(step);
}
},
onHistoryMissingParentSteps: ({ step, fromStepId }) => {
const missingSteps = clientInfos[step.clientId].editor.historyGetMissingSteps({
fromStepId,
toStepId: step.id,
});
if (missingSteps === -1 || !missingSteps.length) {
throw new Error('Impossible to get the missing steps.');
}
clientInfo.editor.onExternalHistorySteps(missingSteps.concat([step]));
},
});
clientInfo.editor.keyboardType = 'PHYSICAL';
const selection = selections[clientInfo.clientId];
if (selection) {
setTestSelection(selection, iframeDocument);
} else {
iframeDocument.getSelection().removeAllRanges();
}
// Flush the history so that steps generated by the parsing of the
// selection and the editor loading are not recorded.
clientInfo.editor.observerFlush();
}
shouldListenSteps = true;
// From now, any any step from a client must have a different ID.
let concurentNextId = 1;
OdooEditor.prototype._generateId = () => 'fake_concurent_id_' + concurentNextId++;
if (spec.afterCreate) {
spec.afterCreate(clientInfos);
}
shouldListenSteps = false;
// Render textual selection.
const cursorNodes = {};
for (const clientInfo of clientInfosList) {
const iframeDocument = clientInfo.iframe.contentWindow.document;
const clientSelection = iframeDocument.getSelection();
if (clientSelection.anchorNode === null) {
continue;
}
const [anchorNode, anchorOffset] = targetDeepest(
clientSelection.anchorNode,
clientSelection.anchorOffset,
);
const [focusNode, focusOffset] = targetDeepest(
clientSelection.focusNode,
clientSelection.focusOffset,
);
const clientId = clientInfo.clientId;
cursorNodes[focusNode.oid] = cursorNodes[focusNode.oid] || [];
cursorNodes[focusNode.oid].push({ type: 'focus', clientId, offset: focusOffset });
cursorNodes[anchorNode.oid] = cursorNodes[anchorNode.oid] || [];
cursorNodes[anchorNode.oid].push({ type: 'anchor', clientId, offset: anchorOffset });
}
for (const nodeOid of Object.keys(cursorNodes)) {
cursorNodes[nodeOid] = cursorNodes[nodeOid].sort((a, b) => {
return b.offset - a.offset || b.clientId.localeCompare(a.clientId);
});
}
for (const clientInfo of clientInfosList) {
clientInfo.editor.observerUnactive();
for (const [nodeOid, cursorsData] of Object.entries(cursorNodes)) {
const node = clientInfo.editor.idFind(nodeOid);
for (const cursorData of cursorsData) {
const cursorString =
cursorData.type === 'anchor'
? `[${cursorData.clientId}}`
: `{${cursorData.clientId}]`;
insertCharsAt(cursorString, node, cursorData.offset);
}
}
}
if (spec.contentAfter) {
for (const clientInfo of clientInfosList) {
const value = clientInfo.editable.innerHTML;
window.chai
.expect(value)
.to.be.equal(spec.contentAfter, `error with client ${clientInfo.clientId}`);
}
}
if (spec.afterCursorInserted) {
spec.afterCursorInserted(clientInfos);
}
for (const clientInfo of clientInfosList) {
clientInfo.editor.destroy();
clientInfo.iframe.remove();
}
};
describe('Collaboration', () => {
describe('Conflict resolution', () => {
it('all client steps should be on the same order', () => {
testMultiEditor({
clientIds: ['c1', 'c2', 'c3'],
contentBefore: '<p><x>a[c1}{c1]</x><y>e[c2}{c2]</y><z>i[c3}{c3]</z></p>',
afterCreate: clientInfos => {
applyConcurentActions(clientInfos, {
c1: editor => {
editor.execCommand('insert', 'b');
editor.execCommand('insert', 'c');
editor.execCommand('insert', 'd');
},
c2: editor => {
editor.execCommand('insert', 'f');
editor.execCommand('insert', 'g');
editor.execCommand('insert', 'h');
},
c3: editor => {
editor.execCommand('insert', 'j');
editor.execCommand('insert', 'k');
editor.execCommand('insert', 'l');
},
});
mergeClientsSteps(clientInfos);
testSameHistory(clientInfos);
},
contentAfter: '<p><x>abcd[c1}{c1]</x><y>efgh[c2}{c2]</y><z>ijkl[c3}{c3]</z></p>',
});
});
it('should 2 client insertText in 2 different paragraph', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>ab[c1}{c1]</p><p>cd[c2}{c2]</p>',
afterCreate: clientInfos => {
applyConcurentActions(clientInfos, {
c1: editor => {
editor.execCommand('insert', 'e');
},
c2: editor => {
editor.execCommand('insert', 'f');
},
});
mergeClientsSteps(clientInfos);
testSameHistory(clientInfos);
},
contentAfter: '<p>abe[c1}{c1]</p><p>cdf[c2}{c2]</p>',
});
});
it('should 2 client insertText twice in 2 different paragraph', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>ab[c1}{c1]</p><p>cd[c2}{c2]</p>',
afterCreate: clientInfos => {
applyConcurentActions(clientInfos, {
c1: editor => {
editor.execCommand('insert', 'e');
editor.execCommand('insert', 'f');
},
c2: editor => {
editor.execCommand('insert', 'g');
editor.execCommand('insert', 'h');
},
});
mergeClientsSteps(clientInfos);
testSameHistory(clientInfos);
},
contentAfter: '<p>abef[c1}{c1]</p><p>cdgh[c2}{c2]</p>',
});
});
it('should insertText with client 1 and deleteBackward with client 2', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>ab[c1}{c1][c2}{c2]c</p>',
afterCreate: clientInfos => {
applyConcurentActions(clientInfos, {
c1: editor => {
editor.execCommand('insert', 'd');
},
c2: editor => {
editor.execCommand('oDeleteBackward');
},
});
mergeClientsSteps(clientInfos);
testSameHistory(clientInfos);
},
contentAfter: '<p>a[c2}{c2]c[c1}{c1]dc</p>',
});
});
it('should insertText twice with client 1 and deleteBackward twice with client 2', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>ab[c1}{c1][c2}{c2]c</p>',
afterCreate: clientInfos => {
applyConcurentActions(clientInfos, {
c1: editor => {
editor.execCommand('insert', 'd');
editor.execCommand('insert', 'e');
},
c2: editor => {
editor.execCommand('oDeleteBackward');
editor.execCommand('oDeleteBackward');
},
});
mergeClientsSteps(clientInfos);
testSameHistory(clientInfos);
},
contentAfter: '<p>[c2}{c2]cd[c1}{c1]ec</p>',
});
});
});
it('should reset from snapshot', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>a[c1}{c1]</p>',
afterCreate: clientInfos => {
clientInfos.c1.editor.execCommand('insert', 'b');
clientInfos.c1.editor._historyMakeSnapshot();
// Insure the snapshot is considered to be older than 30 seconds.
clientInfos.c1.editor._historySnapshots[0].time = 1;
const { steps } = clientInfos.c1.editor.historyGetSnapshotSteps();
clientInfos.c2.editor.historyResetFromSteps(steps);
chai.expect(clientInfos.c2.editor._historySteps.map(x => x.id)).to.deep.equal([
'fake_concurent_id_2',
]);
chai.expect(
clientInfos.c2.editor._historySteps[0].mutations.map(x => x.id),
).to.deep.equal(['fake_id_1']);
},
contentAfter: '<p>ab[c1}{c1]</p>',
});
});
describe('steps whith no parent in history', () => {
it('should be able to retreive steps when disconnected from clients that has send step', () => {
testMultiEditor({
clientIds: ['c1', 'c2', 'c3'],
contentBefore: '<p><x>a[c1}{c1]</x><y>b[c2}{c2]</y><z>c[c3}{c3]</z></p>',
afterCreate: clientInfos => {
clientInfos.c1.editor.execCommand('insert', 'd');
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
clientInfos.c2.editor.execCommand('insert', 'e');
clientInfos.c1.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[2],
]);
clientInfos.c3.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[2],
]);
// receive step 1 after step 2
clientInfos.c3.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
testSameHistory(clientInfos);
},
contentAfter: '<p><x>ad[c1}{c1]</x><y>be[c2}{c2]</y><z>c[c3}{c3]</z></p>',
});
});
it('should receive steps where parent was not received', () => {
testMultiEditor({
clientIds: ['c1', 'c2', 'c3'],
contentBefore: '<p><i>a[c1}{c1]</i><b>b[c2}{c2]</b></p>',
afterCreate: clientInfos => {
clientInfos.c1.editor.execCommand('insert', 'c');
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
// Client 3 connect firt to client 1 that made a snapshot.
clientInfos.c1.editor._historyMakeSnapshot();
// Fake the time of the snapshot so it is considered to be
// older than 30 seconds.
clientInfos.c1.editor._historySnapshots[0].time = 1;
const { steps } = clientInfos.c1.editor.historyGetSnapshotSteps();
clientInfos.c3.editor.historyResetFromSteps(steps);
// In the meantime client 2 send the step to client 1
clientInfos.c2.editor.execCommand('insert', 'd');
clientInfos.c2.editor.execCommand('insert', 'e');
clientInfos.c1.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[2],
]);
clientInfos.c1.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[3],
]);
// Now client 2 is connected to client 3 and client 2 make a new step.
clientInfos.c2.editor.execCommand('insert', 'f');
clientInfos.c1.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[4],
]);
clientInfos.c3.editor.onExternalHistorySteps([
clientInfos.c2.editor._historySteps[4],
]);
},
contentAfter: '<p><i>ac[c1}{c1]</i><b>bdef[c2}{c2]</b></p>',
});
});
});
describe('sanitize', () => {
it('should sanitize when adding a node', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p><x>a</x></p>',
afterCreate: clientInfos => {
const script = document.createElement('script');
script.innerHTML = 'console.log("xss")';
clientInfos.c1.editable.append(script);
clientInfos.c1.editor.historyStep();
window.chai.expect(clientInfos.c1.editor._historySteps[1]).is.not.undefined;
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
window.chai
.expect(clientInfos.c2.editor.editable.innerHTML)
.to.equal('<p><x>a</x></p>');
},
});
});
it('should sanitize when adding a script as descendant', async () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>a[c1}{c1][c2}{c2]</p>',
afterCreate: clientInfos => {
const i = document.createElement('i');
i.innerHTML = '<b>b</b><script>alert("c");</script>';
clientInfos.c1.editable.appendChild(i);
clientInfos.c1.editor.historyStep();
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
},
afterCursorInserted: clientInfos => {
chai.expect(clientInfos.c2.editable.innerHTML).to.equal(
'<p>a[c1}{c1][c2}{c2]</p><i><b>b</b></i>',
);
},
});
});
it('should sanitize when changing an attribute', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>a<img></p>',
afterCreate: clientInfos => {
const img = clientInfos.c1.editable.childNodes[0].childNodes[1];
img.setAttribute('class', 'b');
img.setAttribute('onerror', 'console.log("xss")');
clientInfos.c1.editor.historyStep();
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
window.chai
.expect(clientInfos.c1.editor.editable.innerHTML)
.to.equal('<p>a<img class="b" onerror="console.log(&quot;xss&quot;)"></p>');
window.chai
.expect(clientInfos.c2.editor.editable.innerHTML)
.to.equal('<p>a<img class="b"></p>');
},
});
});
it('should sanitize when undo is adding a script node', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>a</p>',
afterCreate: clientInfos => {
const script = document.createElement('script');
script.innerHTML = 'console.log("xss")';
clientInfos.c1.editable.append(script);
clientInfos.c1.editor.historyStep();
script.remove();
clientInfos.c1.editor.historyStep();
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
// Change the client in order to be undone from client 2
clientInfos.c1.editor._historySteps[2].clientId = 'c2';
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[2],
]);
clientInfos.c2.editor.historyUndo();
window.chai
.expect(clientInfos.c2.editor.editable.innerHTML)
.to.equal('<p>a</p>');
},
});
});
it('should sanitize when undo is adding a descendant script node', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>a</p>',
afterCreate: clientInfos => {
const div = document.createElement('div');
div.innerHTML = '<i>b</i><script>console.log("xss")</script>';
clientInfos.c1.editable.append(div);
clientInfos.c1.editor.historyStep();
div.remove();
clientInfos.c1.editor.historyStep();
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
// Change the client in order to be undone from client 2
clientInfos.c1.editor._historySteps[2].clientId = 'c2';
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[2],
]);
clientInfos.c2.editor.historyUndo();
window.chai
.expect(clientInfos.c2.editor.editable.innerHTML)
.to.equal('<p>a</p><div><i>b</i></div>');
},
});
});
it('should sanitize when undo is changing an attribute', () => {
testMultiEditor({
clientIds: ['c1', 'c2'],
contentBefore: '<p>a<img></p>',
afterCreate: clientInfos => {
const img = clientInfos.c1.editable.childNodes[0].childNodes[1];
img.setAttribute('class', 'b');
img.setAttribute('onerror', 'console.log("xss")');
clientInfos.c1.editor.historyStep();
img.setAttribute('class', '');
img.setAttribute('onerror', '');
clientInfos.c1.editor.historyStep();
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[1],
]);
// Change the client in order to be undone from client 2
clientInfos.c1.editor._historySteps[2].clientId = 'c2';
clientInfos.c2.editor.onExternalHistorySteps([
clientInfos.c1.editor._historySteps[2],
]);
clientInfos.c2.editor.historyUndo();
window.chai
.expect(clientInfos.c2.editor.editable.innerHTML)
.to.equal('<p>a<img class="b"></p>');
},
});
});
it('should not sanitize contenteditable attribute (check DOMPurify DEFAULT_ALLOWED_ATTR)', () => {
testMultiEditor({
clientIds: ['c1'],
contentBefore: '<div class="remove-me" contenteditable="true">[c1}{c1]<br></div>',
afterCreate: clientInfos => {
const editor = clientInfos.c1.editor;
const target = editor.editable.querySelector('.remove-me');
target.classList.remove("remove-me");
editor.historyStep();
undo(editor);
redo(editor);
},
contentAfter: '<div contenteditable="true">[c1}{c1]<br></div>',
});
});
});
});

View file

@ -0,0 +1,334 @@
import { BasicEditor, testEditor, unformat } from '../utils.js';
import { rgbToHex } from '../../src/utils/utils.js';
const setColor = (color, mode) => {
return async editor => {
await editor.execCommand('applyColor', color, mode);
};
};
describe('applyColor', () => {
it('should apply a color to a slice of text in a span in a font', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a<font class="a">b<span class="b">c[def]g</span>h</font>i</p>',
stepFunction: setColor('rgb(255, 0, 0)', 'color'),
contentAfter: '<p>a<font class="a">b<span class="b">c</span></font>' +
'<font class="a" style="color: rgb(255, 0, 0);"><span class="b">[def]</span></font>' +
'<font class="a"><span class="b">g</span>h</font>i</p>',
});
});
it('should apply a background color to a slice of text in a span in a font', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a<font class="a">b<span class="b">c[def]g</span>h</font>i</p>',
stepFunction: setColor('rgb(255, 0, 0)', 'backgroundColor'),
contentAfter: '<p>a<font class="a">b<span class="b">c</span></font>' +
'<font class="a" style="background-color: rgb(255, 0, 0);"><span class="b">[def]</span></font>' +
'<font class="a"><span class="b">g</span>h</font>i</p>',
});
});
it('should get ready to type with a different color', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]cd</p>',
stepFunction: setColor('rgb(255, 0, 0)', 'color'),
contentAfter: '<p>ab<font style="color: rgb(255, 0, 0);">[]\u200B</font>cd</p>',
});
});
it('should get ready to type with a different background color', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]cd</p>',
stepFunction: setColor('rgb(255, 0, 0)', 'backgroundColor'),
contentAfter: '<p>ab<font style="background-color: rgb(255, 0, 0);">[]\u200B</font>cd</p>',
});
});
it('should apply a color on empty selection', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[<br></p><p><br></p><p>]<br></p>',
stepFunction: setColor('rgb(255, 0, 0)', 'color'),
contentAfterEdit: '<p>[<font data-oe-zws-empty-inline="" style="color: rgb(255, 0, 0);">\u200B</font></p>' +
'<p><font data-oe-zws-empty-inline="" style="color: rgb(255, 0, 0);">\u200B</font></p>' +
'<p>]<font data-oe-zws-empty-inline="" style="color: rgb(255, 0, 0);">\u200B</font></p>',
contentAfter: '<p>[</p><p></p><p>]</p>',
});
});
it('should apply a background color on empty selection', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[<br></p><p><br></p><p>]<br></p>',
stepFunction: setColor('rgb(255, 0, 0)', 'backgroundColor'),
contentAfterEdit: '<p>[<font data-oe-zws-empty-inline="" style="background-color: rgb(255, 0, 0);">\u200B</font></p>' +
'<p><font data-oe-zws-empty-inline="" style="background-color: rgb(255, 0, 0);">\u200B</font></p>' +
'<p>]<font data-oe-zws-empty-inline="" style="background-color: rgb(255, 0, 0);">\u200B</font></p>',
contentAfter: '<p>[</p><p></p><p>]</p>',
});
});
it('should not merge line on background color change', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><strong>[abcd</strong><br><strong>efghi]</strong></p>',
stepFunction: setColor('rgb(255, 0, 0)', 'backgroundColor'),
contentAfter: '<p><strong><font style="background-color: rgb(255, 0, 0);">[abcd</font></strong><br>' +
'<strong><font style="background-color: rgb(255, 0, 0);">efghi]</font></strong></p>',
});
});
it('should not merge line on color change', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><strong>[abcd</strong><br><strong>efghi]</strong></p>',
stepFunction: setColor('rgb(255, 0, 0)', 'color'),
contentAfter: '<p><strong><font style="color: rgb(255, 0, 0);">[abcd</font></strong><br>' +
'<strong><font style="color: rgb(255, 0, 0);">efghi]</font></strong></p>',
});
});
it('should not apply color on an uneditable element', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[a</p><p contenteditable="false">b</p><p>c]</p>',
stepFunction: setColor('rgb(255, 0, 0)', 'color'),
contentAfter: unformat(`
<p><font style="color: rgb(255, 0, 0);">[a</font></p>
<p contenteditable="false">b</p>
<p><font style="color: rgb(255, 0, 0);">c]</font></p>
`),
});
});
it('should not apply background color on an uneditable selected cell in a table', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(`
<table><tbody>
<tr><td class="o_selected_td">[ab</td></tr>
<tr><td contenteditable="false" class="o_selected_td">cd</td></tr>
<tr><td class="o_selected_td">ef]</td></tr>
</tbody></table>
`),
stepFunction: setColor('rgb(255, 0, 0)', 'backgroundColor'),
contentAfter: unformat(`
<table><tbody>
<tr><td style="background-color: rgb(255, 0, 0);">[]ab</td></tr>
<tr><td contenteditable="false">cd</td></tr>
<tr><td style="background-color: rgb(255, 0, 0);">ef</td></tr>
</tbody></table>
`),
});
});
it('should remove font tag after removing font color', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font style="color: rgb(255, 0, 0);">[abcabc]</font></p>',
stepFunction: setColor('', 'color'),
contentAfter: '<p>[abcabc]</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p><font class="text-400">[abcabc]</font></p>',
stepFunction: setColor('', 'color'),
contentAfter: '<p>[abcabc]</p>',
});
});
it('should remove font tag after removing background color applied as style', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font style="background-color: rgb(255, 0, 0);">[abcabc]</font></p>',
stepFunction: setColor('', 'backgroundColor'),
contentAfter: '<p>[abcabc]</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p><font class="bg-200">[abcabc]</font></p>',
stepFunction: setColor('', 'backgroundColor'),
contentAfter: '<p>[abcabc]</p>',
});
});
it('should remove font tag if font-color and background-color both are removed one by one', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font style="color: rgb(255, 0, 0);" class="bg-200">[abcabc]</font></p>',
stepFunction: setColor('','backgroundColor'),
stepFunction: setColor('','color'),
contentAfter: '<p>[abcabc]</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p><font style="background-color: rgb(255, 0, 0);" class="text-900">[abcabc]</font></p>',
stepFunction: setColor('','color'),
stepFunction: setColor('','backgroundColor'),
contentAfter: '<p>[abcabc]</p>',
});
});
it('should remove font tag after removing gradient color applied as style', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">[abcabc]</font></p>',
stepFunction: setColor('', 'backgroundColor'),
contentAfter: '<p>[abcabc]</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">[abcabc]</font></p>',
stepFunction: setColor('', 'color'),
contentAfter: '<p>[abcabc]</p>',
});
});
it('Shall not apply font tag to t nodes (protects if else nodes separation)', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(`[
<p>
<t t-if="object.partner_id.parent_id">
<t t-out="object.partner_id.parent_id.name or ''">Azure Interior</t>
</t>
<t t-else="">
<t t-out="object.partner_id.name or ''">Brandon Freeman</t>,
</t>
</p>
]`),
stepFunction: setColor('red', 'backgroundColor'),
contentAfter: unformat(`
<p>
<t t-if="object.partner_id.parent_id">
<t t-out="object.partner_id.parent_id.name or ''">
<font style="background-color: red;">[AzureInterior</font>
</t>
</t>
<t t-else="">
<t t-out="object.partner_id.name or ''">
<font style="background-color: red;">BrandonFreeman</font>
</t>
<font style="background-color: red;">,]</font>
</t>
</p>
`),
});
});
it("should apply text color whithout interrupting gradient background color on selected text", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">ab[ca]bc</font></p>',
stepFunction: setColor("rgb(255, 0, 0)", "color"),
contentAfter: '<p><font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">ab<font style="color: rgb(255, 0, 0);">[ca]</font>bc</font></p>',
});
});
it("should apply background color whithout interrupting gradient text color on selected text", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">ab[ca]bc</font></p>',
stepFunction: setColor("rgb(255, 0, 0)", "backgroundColor"),
contentAfter: '<p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">ab<font style="background-color: rgb(255, 0, 0);">[ca]</font>bc</font></p>',
});
});
it("should apply background color whithout interrupting gradient background color on selected text", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">ab[ca]bc</font></p>',
stepFunction: setColor("rgb(255, 0, 0)", "backgroundColor"),
contentAfter: '<p><font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">ab<font style="background-color: rgb(255, 0, 0);">[ca]</font>bc</font></p>',
});
});
it("should apply text color whithout interrupting gradient text color on selected text", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">ab[ca]bc</font></p>',
stepFunction: setColor("rgb(255, 0, 0)", "color"),
contentAfter: '<p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">ab<font style="-webkit-text-fill-color: rgb(255, 0, 0); color: rgb(255, 0, 0);">[ca]</font>bc</font></p>',
});
});
it("should break gradient color on selected text", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">ab[ca]bc</font></p>',
stepFunction: setColor("linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%)", "backgroundColor"),
contentAfter: '<p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">ab</font>' +
'<font style="background-image: linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%);">[ca]</font>' +
'<font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);">bc</font></p>',
});
});
it("should update the gradient color and remove the nested background color to make the gradient visible", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);"><font style="background-color: rgb(255, 0, 0);">[abc]</font></font></p>',
stepFunction: setColor("linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%)", "backgroundColor"),
contentAfter: '<p><font style="background-image: linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%);">[abc]</font></p>',
});
});
it("should update the gradient text color and remove the nested text color to make the gradient visible", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);"><font style="-webkit-text-fill-color: rgb(255, 0, 0); color: rgb(255, 0, 0);">[abc]</font></font></p>',
stepFunction: setColor("linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%)", "color"),
contentAfter: '<p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%);">[abc]</font></p>',
});
});
it("should apply gradient color when a when background color is applied on span", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><span style="background-color: rgb(255, 0, 0)">ab[ca]bc</span></p>',
stepFunction: setColor("linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%)", "color"),
contentAfter: '<p><span style="background-color: rgb(255, 0, 0)">ab<font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%);">[ca]</font>bc</span></p>',
});
});
it("should apply a gradient color to a slice of text in a span", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><span class="a">ab[ca]bc</span></p>',
stepFunction: setColor("linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%)", "color"),
contentAfter: '<p><span class="a">ab<font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%);">[ca]</font>bc</span></p>',
});
});
it("should applied background color to slice of text in a span without interrupting gradient background color", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);"><span class="a">ab[ca]bc</span><font></p>',
stepFunction: setColor("rgb(255, 0, 0)", "backgroundColor"),
contentAfter: '<p><font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);"><span class="a">ab<font style="background-color: rgb(255, 0, 0);">[ca]</font>bc</span></font></p>',
});
});
it("should break a gradient and apply gradient background color to a slice of text within a span", async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);"><span class="a">ab<font style="background-color: rgb(255, 0, 0);">[ca]</font>bc</span></font></p>',
stepFunction: setColor("linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%)", "color"),
contentAfter: '<p><font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);"><span class="a">ab</span></font>' +
'<font style="background-image: linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%);" class="text-gradient"><span class="a"><font style="background-color: rgb(255, 0, 0);">[ca]</font></span></font>' +
'<font style="background-image: linear-gradient(135deg, rgb(214, 255, 127) 0%, rgb(0, 179, 204) 100%);"><span class="a">bc</span></font></p>',
});
});
it("should apply gradient color on selected text", async () => {
await testEditor(BasicEditor, {
contentBefore: '<div style="background-image:none"><p>[ab<strong>cd</strong>ef]</p></div>',
stepFunction: setColor("linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%)", "backgroundColor"),
contentAfter: '<div style="background-image:none"><p><font style="background-image: linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%);">[ab<strong>cd</strong>ef]</font></p></div>'
});
});
it("should apply gradient text color on selected text", async () => {
await testEditor(BasicEditor, {
contentBefore: '<div style="background-image:none"><p>[ab<strong>cd</strong>ef]</p></div>',
stepFunction: setColor("linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%)", "color"),
contentAfter: '<div style="background-image:none"><p><font class="text-gradient" style="background-image: linear-gradient(135deg, rgb(255, 174, 127) 0%, rgb(109, 204, 0) 100%);">[ab<strong>cd</strong>ef]</font></p></div>'
});
});
});
describe('rgbToHex', () => {
it('should convert an rgb color to hexadecimal', async () => {
window.chai.expect(rgbToHex('rgb(0, 0, 255)')).to.be.equal('#0000ff');
window.chai.expect(rgbToHex('rgb(0,0,255)')).to.be.equal('#0000ff');
});
it('should convert an rgba color to hexadecimal (background is hexadecimal)', async () => {
const parent = document.createElement('div');
const node = document.createElement('div');
parent.style.backgroundColor = '#ff0000'; // red, should be irrelevant
node.style.backgroundColor = '#0000ff'; // blue
parent.append(node);
document.body.append(parent);
// white with 50% opacity over blue = light blue
window.chai.expect(rgbToHex('rgba(255, 255, 255, 0.5)', node)).to.be.equal('#7f7fff');
parent.remove();
});
it('should convert an rgba color to hexadecimal (background is color name)', async () => {
const parent = document.createElement('div');
const node = document.createElement('div');
parent.style.backgroundColor = '#ff0000'; // red, should be irrelevant
node.style.backgroundColor = 'blue'; // blue
parent.append(node);
document.body.append(parent);
// white with 50% opacity over blue = light blue
window.chai.expect(rgbToHex('rgba(255, 255, 255, 0.5)', node)).to.be.equal('#7f7fff');
parent.remove();
});
it('should convert an rgba color to hexadecimal (background is rgb)', async () => {
const parent = document.createElement('div');
const node = document.createElement('div');
parent.style.backgroundColor = '#ff0000'; // red, should be irrelevant
node.style.backgroundColor = 'rgb(0, 0, 255)'; // blue
parent.append(node);
document.body.append(parent);
// white with 50% opacity over blue = light blue
window.chai.expect(rgbToHex('rgba(255, 255, 255, 0.5)', node)).to.be.equal('#7f7fff');
parent.remove();
});
it('should convert an rgba color to hexadecimal (background is rgba)', async () => {
const parent = document.createElement('div');
const node = document.createElement('div');
parent.style.backgroundColor = 'rgb(255, 0, 0)'; // red
node.style.backgroundColor = 'rgba(0, 0, 255, 0.5)'; // blue
parent.append(node);
document.body.append(parent);
// white with 50% opacity over blue with 50% opacity over red = light purple
window.chai.expect(rgbToHex('rgba(255, 255, 255, 0.5)', node)).to.be.equal('#bf7fbf');
parent.remove();
});
});

View file

@ -0,0 +1,513 @@
import { BasicEditor, insertText, testEditor, deleteForward, deleteBackward } from '../utils.js';
describe('FontAwesome', () => {
describe('parse/render', () => {
it('should parse an old-school fontawesome', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><i class="fa fa-star"></i></p>',
contentBeforeEdit: '<p><i class="fa fa-star" contenteditable="false">\u200b</i></p>',
contentAfter: '<p><i class="fa fa-star"></i></p>',
});
});
it('should parse a brand fontawesome', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><i class="fab fa-opera"></i></p>',
contentBeforeEdit: '<p><i class="fab fa-opera" contenteditable="false">\u200b</i></p>',
contentAfter: '<p><i class="fab fa-opera"></i></p>',
});
});
it('should parse a duotone fontawesome', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><i class="fad fa-bus-alt"></i></p>',
contentBeforeEdit: '<p><i class="fad fa-bus-alt" contenteditable="false">\u200b</i></p>',
contentAfter: '<p><i class="fad fa-bus-alt"></i></p>',
});
});
it('should parse a light fontawesome', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><i class="fab fa-accessible-icon"></i></p>',
contentBeforeEdit:
'<p><i class="fab fa-accessible-icon" contenteditable="false">\u200b</i></p>',
contentAfter: '<p><i class="fab fa-accessible-icon"></i></p>',
});
});
it('should parse a regular fontawesome', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><i class="far fa-money-bill-alt"></i></p>',
contentBeforeEdit:
'<p><i class="far fa-money-bill-alt" contenteditable="false">\u200b</i></p>',
contentAfter: '<p><i class="far fa-money-bill-alt"></i></p>',
});
});
it('should parse a solid fontawesome', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><i class="fa fa-pastafarianism"></i></span></p>',
contentBeforeEdit:
'<p><i class="fa fa-pastafarianism" contenteditable="false">\u200b</i></p>',
contentAfter: '<p><i class="fa fa-pastafarianism"></i></p>',
});
});
it('should parse a fontawesome in a <span>', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><span class="fa fa-pastafarianism"></span></p>',
contentBeforeEdit:
'<p><span class="fa fa-pastafarianism" contenteditable="false">\u200b</span></p>',
contentAfter: '<p><span class="fa fa-pastafarianism"></span></p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p><span class="oi oi-pastafarianism"></span></p>',
contentBeforeEdit:
'<p><span class="oi oi-pastafarianism" contenteditable="false">\u200b</span></p>',
contentAfter: '<p><span class="oi oi-pastafarianism"></span></p>',
});
});
it('should parse a fontawesome in a <i>', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><i class="fa fa-pastafarianism"></i></i></p>',
contentBeforeEdit:
'<p><i class="fa fa-pastafarianism" contenteditable="false">\u200b</i></p>',
contentAfter: '<p><i class="fa fa-pastafarianism"></i></p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p><i class="oi oi-pastafarianism"></i></i></p>',
contentBeforeEdit:
'<p><i class="oi oi-pastafarianism" contenteditable="false">\u200b</i></p>',
contentAfter: '<p><i class="oi oi-pastafarianism"></i></p>',
});
});
it('should parse a fontawesome with more classes', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><i class="red fa bordered fa-pastafarianism big"></i></p>',
contentBeforeEdit:
'<p><i class="red fa bordered fa-pastafarianism big" contenteditable="false">\u200b</i></p>',
contentAfter: '<p><i class="red fa bordered fa-pastafarianism big"></i></p>',
});
});
it('should parse a fontawesome with multi-line classes', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p><i class="fa
fa-pastafarianism"></i></p>`,
contentBeforeEdit: `<p><i class="fa
fa-pastafarianism" contenteditable="false">\u200b</i></p>`,
contentAfter: `<p><i class="fa
fa-pastafarianism"></i></p>`,
});
});
it('should parse a fontawesome with more multi-line classes', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p><i class="red fa bordered
big fa-pastafarianism scary"></i></p>`,
contentBeforeEdit: `<p><i class="red fa bordered
big fa-pastafarianism scary" contenteditable="false">\u200b</i></p>`,
contentAfter: `<p><i class="red fa bordered
big fa-pastafarianism scary"></i></p>`,
});
});
it('should parse a fontawesome at the beginning of a paragraph', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><i class="fa fa-pastafarianism"></i>a[b]c</p>',
contentBeforeEdit:
'<p><i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>a[b]c</p>',
contentAfter: '<p><i class="fa fa-pastafarianism"></i>a[b]c</p>',
});
});
it('should parse a fontawesome in the middle of a paragraph', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[b]c<i class="fa fa-pastafarianism"></i>def</p>',
contentBeforeEdit:
'<p>a[b]c<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>def</p>',
contentAfter: '<p>a[b]c<i class="fa fa-pastafarianism"></i>def</p>',
});
});
it('should parse a fontawesome at the end of a paragraph', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[b]c<i class="fa fa-pastafarianism"></i></p>',
contentBeforeEdit:
'<p>a[b]c<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i></p>',
contentAfter: '<p>a[b]c<i class="fa fa-pastafarianism"></i></p>',
});
});
/** not sure this is necessary, keep for now in case it is
it('should insert navigation helpers when before a fontawesome, in an editable', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>abc[]<i class="fa fa-pastafarianism"></i></p>',
contentAfter:
'<p>abc[]\u200B<i class="fa fa-pastafarianism" contenteditable="false"></i>\u200B</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p>[]<i class="fa fa-pastafarianism"></i></p>',
contentAfter:
'<p>\u200B[]<i class="fa fa-pastafarianism" contenteditable="false"></i>\u200B</p>',
});
});
it('should insert navigation helpers when after a fontawesome, in an editable', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><i class="fa fa-pastafarianism"></i>[]abc</p>',
contentAfter:
'<p>\u200B<i class="fa fa-pastafarianism" contenteditable="false"></i>\u200B[]abc</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p><i class="fa fa-pastafarianism"></i>[]</p>',
contentAfter:
'<p>\u200B<i class="fa fa-pastafarianism" contenteditable="false"></i>\u200B[]</p>',
});
});
it('should not insert navigation helpers when not adjacent to a fontawesome, in an editable', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]c<i class="fa fa-pastafarianism"></i></p>',
contentAfter:
'<p>ab[]c<i class="fa fa-pastafarianism" contenteditable="false"></i></p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p><i class="fa fa-pastafarianism"></i>a[]bc</p>',
contentAfter:
'<p><i class="fa fa-pastafarianism" contenteditable="false"></i>a[]bc</p>',
});
});
it('should not insert navigation helpers when adjacent to a fontawesome in contenteditable=false container', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<p contenteditable="false">abc[]<i class="fa fa-pastafarianism"></i></p>',
contentAfter:
'<p contenteditable="false">abc<i class="fa fa-pastafarianism" contenteditable="false"></i></p>',
});
await testEditor(BasicEditor, {
contentBefore:
'<p contenteditable="false"><i class="fa fa-pastafarianism"></i>[]abc</p>',
contentAfter:
'<p contenteditable="false"><i class="fa fa-pastafarianism" contenteditable="false"></i>abc</p>',
});
});
it('should not insert navigation helpers when adjacent to a fontawesome in contenteditable=false format', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<p contenteditable="true"><b contenteditable="false">abc[]<i class="fa fa-pastafarianism"></i></b></p>',
contentAfter:
'<p contenteditable="true"><b contenteditable="false">abc<i class="fa fa-pastafarianism" contenteditable="false"></i></b></p>',
});
await testEditor(BasicEditor, {
contentBefore:
'<p contenteditable="true"><b contenteditable="false"><i class="fa fa-pastafarianism"></i>[]abc</b></p>',
contentAfter:
'<p contenteditable="true"><b contenteditable="false"><i class="fa fa-pastafarianism" contenteditable="false"></i>abc</b></p>',
});
});
it('should not insert navigation helpers when adjacent to a fontawesome in contenteditable=false format (oe-nested)', async () => {
await testEditor(BasicEditor, {
contentBefore:
'<p contenteditable="true"><a contenteditable="true"><b contenteditable="false">abc[]<i class="fa fa-pastafarianism"></i></b></a></p>',
contentAfter:
'<p contenteditable="true"><a contenteditable="true"><b contenteditable="false">abc<i class="fa fa-pastafarianism" contenteditable="false"></i></b></a></p>',
});
await testEditor(BasicEditor, {
contentBefore:
'<p contenteditable="true"><a contenteditable="true"><b contenteditable="false"><i class="fa fa-pastafarianism"></i>[]abc</b></a></p>',
contentAfter:
'<p contenteditable="true"><a contenteditable="true"><b contenteditable="false"><i class="fa fa-pastafarianism" contenteditable="false"></i>abc</b></a></p>',
});
});*/
});
describe('deleteForward', () => {
describe('Selection collapsed', () => {
describe('Basic', () => {
it('should delete a fontawesome', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]<i class="fa fa-pastafarianism"></i>cd</p>',
contentBeforeEdit: '<p>ab[]<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>cd</p>',
stepFunction: deleteForward,
contentAfter: '<p>ab[]cd</p>',
});
});
it('should not delete a fontawesome', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab<i class="fa fa-pastafarianism"></i>[]cd</p>',
contentBeforeEdit: '<p>ab<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]cd</p>',
stepFunction: deleteForward,
contentAfterEdit:
'<p>ab<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]d</p>',
contentAfter: '<p>ab<i class="fa fa-pastafarianism"></i>[]d</p>',
});
});
it('should not delete a fontawesome after multiple deleteForward', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]cde<i class="fa fa-pastafarianism"></i>fghij</p>',
contentBeforeEdit: '<p>ab[]cde<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>fghij</p>',
stepFunction: async editor => {
await deleteForward(editor);
await deleteForward(editor);
await deleteForward(editor);
},
contentAfterEdit:
'<p>ab[]<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>fghij</p>',
contentAfter: '<p>ab[]<i class="fa fa-pastafarianism"></i>fghij</p>',
});
});
it('should not delete a fontawesome after one deleteForward with spaces', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[] <i class="fa fa-pastafarianism"></i> cd</p>',
contentBeforeEdit: '<p>ab[] <i class="fa fa-pastafarianism" contenteditable="false">\u200b</i> cd</p>',
stepFunction: async editor => {
await deleteForward(editor);
},
contentAfterEdit:
'<p>ab[]<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i> cd</p>',
contentAfter:
'<p>ab[]<i class="fa fa-pastafarianism"></i> cd</p>',
});
});
it('should not delete a fontawesome after multiple deleteForward with spaces', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[]b <i class="fa fa-pastafarianism"></i> cd</p>',
contentBeforeEdit: '<p>a[]b <i class="fa fa-pastafarianism" contenteditable="false">\u200b</i> cd</p>',
stepFunction: async editor => {
await deleteForward(editor);
await deleteForward(editor);
},
contentAfterEdit:
'<p>a[]<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i> cd</p>',
contentAfter:
'<p>a[]<i class="fa fa-pastafarianism"></i> cd</p>',
});
});
it('should not delete a fontawesome after multiple deleteForward with spaces inside a <span>', async () => {
await testEditor(BasicEditor, {
contentBefore: '<div><span class="a">ab[]c </span><i class="fa fa-star"></i> def</div>',
contentBeforeEdit:
'<div><span class="a">ab[]c </span><i class="fa fa-star" contenteditable="false">\u200b</i> def</div>',
stepFunction: async editor => {
await deleteForward(editor);
await deleteForward(editor);
},
contentAfterEdit:
'<div><span class="a">ab[]</span><i class="fa fa-star" contenteditable="false">\u200b</i> def</div>',
contentAfter: '<div><span class="a">ab[]</span><i class="fa fa-star"></i> def</div>',
});
});
});
});
describe('Selection not collapsed', () => {
describe('Basic', () => {
it('should delete a fontawesome', async () => {
// Forward selection
await testEditor(BasicEditor, {
contentBefore: '<p>ab[<i class="fa fa-pastafarianism"></i>]cd</p>',
stepFunction: deleteForward,
contentAfter: '<p>ab[]cd</p>',
});
// Backward selection
await testEditor(BasicEditor, {
contentBefore: '<p>ab]<i class="fa fa-pastafarianism"></i>[cd</p>',
stepFunction: deleteForward,
contentAfter: '<p>ab[]cd</p>',
});
});
});
});
});
describe('deleteBackward', () => {
describe('Selection collapsed', () => {
describe('Basic', () => {
it('should delete a fontawesome', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab<i class="fa fa-pastafarianism"></i>[]cd</p>',
contentBeforeEdit:
'<p>ab<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]cd</p>',
stepFunction: deleteBackward,
contentAfter: '<p>ab[]cd</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p>ab<i class="oi oi-pastafarianism"></i>[]cd</p>',
contentBeforeEdit:
'<p>ab<i class="oi oi-pastafarianism" contenteditable="false">\u200b</i>[]cd</p>',
stepFunction: deleteBackward,
contentAfter: '<p>ab[]cd</p>',
});
});
it('should delete a fontawesome before a span', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab<i class="fa fa-pastafarianism"></i><span class="a">[]cd</span></p>',
contentBeforeEdit:
'<p>ab<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i><span class="a">[]cd</span></p>',
stepFunction: deleteBackward,
contentAfter: '<p>ab<span class="a">[]cd</span></p>',
});
});
it('should not delete a fontawesome before a span', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab<i class="fa fa-pastafarianism"></i><span class="a">c[]d</span></p>',
contentBeforeEdit:
'<p>ab<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i><span class="a">c[]d</span></p>',
stepFunction: deleteBackward,
contentAfterEdit:
'<p>ab<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i><span class="a">[]d</span></p>',
contentAfter: '<p>ab<i class="fa fa-pastafarianism"></i><span class="a">[]d</span></p>',
});
});
it('should not delete a fontawesome', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]<i class="fa fa-pastafarianism"></i>cd</p>',
contentBeforeEdit:
'<p>ab[]<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>cd</p>',
stepFunction: deleteBackward,
contentAfterEdit:
'<p>a[]<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>cd</p>',
contentAfter: '<p>a[]<i class="fa fa-pastafarianism"></i>cd</p>',
});
});
it('should not delete a fontawesome after multiple deleteBackward', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>abcde<i class="fa fa-pastafarianism"></i>fgh[]ij</p>',
contentBeforeEdit:
'<p>abcde<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>fgh[]ij</p>',
stepFunction: async editor => {
await deleteBackward(editor);
await deleteBackward(editor);
await deleteBackward(editor);
},
contentAfterEdit:
'<p>abcde<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]ij</p>',
contentAfter: '<p>abcde<i class="fa fa-pastafarianism"></i>[]ij</p>',
});
});
it('should not delete a fontawesome after multiple deleteBackward with spaces', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>abcde <i class="fa fa-pastafarianism"></i> fg[]hij</p>',
contentBeforeEdit:
'<p>abcde <i class="fa fa-pastafarianism" contenteditable="false">\u200b</i> fg[]hij</p>',
stepFunction: async editor => {
await deleteBackward(editor);
await deleteBackward(editor);
await deleteBackward(editor);
},
contentAfterEdit:
'<p>abcde <i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]hij</p>',
contentAfter: '<p>abcde <i class="fa fa-pastafarianism"></i>[]hij</p>',
});
});
});
});
describe('Selection not collapsed', () => {
describe('Basic', () => {
it('should delete a fontawesome', async () => {
// Forward selection
await testEditor(BasicEditor, {
contentBefore: '<p>ab[<i class="fa fa-pastafarianism"></i>]cd</p>',
stepFunction: deleteBackward,
contentAfter: '<p>ab[]cd</p>',
});
// Backward selection
await testEditor(BasicEditor, {
contentBefore: '<p>ab]<i class="fa fa-pastafarianism"></i>[cd</p>',
stepFunction: deleteBackward,
contentAfter: '<p>ab[]cd</p>',
});
});
});
});
});
describe('FontAwesome insertion', () => {
it('should insert a fontAwesome at the start of an element', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[]abc</p>',
stepFunction: async editor => {
editor.execCommand('insertFontAwesome', 'fa fa-star');
},
contentAfterEdit:
'<p><i class="fa fa-star" contenteditable="false">\u200b</i>[]abc</p>',
contentAfter: '<p><i class="fa fa-star"></i>[]abc</p>',
});
});
it('should insert a fontAwesome within an element', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]cd</p>',
stepFunction: async editor => {
editor.execCommand('insertFontAwesome', 'fa fa-star');
},
contentAfterEdit:
'<p>ab<i class="fa fa-star" contenteditable="false">\u200b</i>[]cd</p>',
contentAfter: '<p>ab<i class="fa fa-star"></i>[]cd</p>',
});
});
it('should insert a fontAwesome at the end of an element', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>abc[]</p>',
stepFunction: async editor => {
editor.execCommand('insertFontAwesome', 'fa fa-star');
},
contentAfterEdit:
'<p>abc<i class="fa fa-star" contenteditable="false">\u200b</i>[]</p>',
contentAfter: '<p>abc<i class="fa fa-star"></i>[]</p>',
});
});
it('should insert a fontAwesome after', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab<i class="fa fa-pastafarianism"></i>c[]d</p>',
stepFunction: async editor => {
editor.execCommand('insertFontAwesome', 'fa fa-star');
},
contentAfterEdit:
'<p>ab<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>c<i class="fa fa-star" contenteditable="false">\u200b</i>[]d</p>',
contentAfter:
'<p>ab<i class="fa fa-pastafarianism"></i>c<i class="fa fa-star"></i>[]d</p>',
});
});
it('should insert a fontAwesome before', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]<i class="fa fa-pastafarianism"></i>cd</p>',
contentBeforeEdit: '<p>ab[]<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>cd</p>',
stepFunction: async editor => {
editor.execCommand('insertFontAwesome', 'fa fa-star');
},
contentAfterEdit:
'<p>ab<i class="fa fa-star" contenteditable="false">\u200b</i>[]<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>cd</p>',
contentAfter:
'<p>ab<i class="fa fa-star"></i>[]<i class="fa fa-pastafarianism"></i>cd</p>',
});
});
it.skip('should insert a fontAwesome and replace the icon', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[<i class="fa fa-pastafarianism"></i>]cd</p>',
stepFunction: async editor => {
editor.execCommand('insertFontAwesome', 'fa fa-star');
},
contentAfter:
'<p>abs<i class="fa fa-star"></i>[]cd</p>',
});
});
});
describe('Text insertion', () => {
it('should insert a character before', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]<i class="fa fa-pastafarianism"></i>cd</p>',
contentBeforeEdit: '<p>ab[]<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>cd</p>',
stepFunction: async editor => {
await insertText(editor, 's');
},
contentAfterEdit:
'<p>abs[]<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>cd</p>',
contentAfter: '<p>abs[]<i class="fa fa-pastafarianism"></i>cd</p>',
});
});
it('should insert a character after', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab<i class="fa fa-pastafarianism"></i>[]cd</p>',
contentBeforeEdit: '<p>ab<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]cd</p>',
stepFunction: async editor => {
await insertText(editor, 's');
},
contentAfterEdit:
'<p>ab<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>s[]cd</p>',
contentAfter: '<p>ab<i class="fa fa-pastafarianism"></i>s[]cd</p>',
});
});
it.skip('should insert a character and replace the icon', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[<i class="fa fa-pastafarianism"></i>]cd</p>',
stepFunction: async editor => {
await insertText(editor, 's');
},
contentAfter: '<p>abs[]cd</p>',
});
});
});
});

View file

@ -0,0 +1,498 @@
import {
BasicEditor,
testEditor,
pasteHtml,
} from "../utils.js";
// The tests below are very sensitive to whitespaces as they do represent actual
// whitespace text nodes in the DOM. The tests will fail if those are removed.
describe('Paste HTML tables', () => {
describe('From Microsoft Excel Online', async () => {
it('should keep all allowed style (Excel Online)', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[]</p>',
stepFunction: async editor => {
await pasteHtml(editor,
`<div ccp_infra_version='3' ccp_infra_timestamp='1684505961078' ccp_infra_user_hash='540904553' ccp_infra_copy_id=''
data-ccp-timestamp='1684505961078'>
<html>
<head>
<meta http-equiv=Content-Type content="text/html; charset=utf-8">
<meta name=ProgId content=Excel.Sheet>
<meta name=Generator content="Microsoft Excel 15">
<style>
table {
mso-displayed-decimal-separator: "\\,";
mso-displayed-thousand-separator: "\\.";
}
tr {
mso-height-source: auto;
}
col {
mso-width-source: auto;
}
td {
padding-top: 1px;
padding-right: 1px;
padding-left: 1px;
mso-ignore: padding;
color: black;
font-size: 11.0pt;
font-weight: 400;
font-style: normal;
text-decoration: none;
font-family: Calibri, sans-serif;
mso-font-charset: 0;
text-align: general;
vertical-align: bottom;
border: none;
white-space: nowrap;
mso-rotate: 0;
}
.font12 {
color: #495057;
font-size: 10.0pt;
font-weight: 400;
font-style: italic;
text-decoration: none;
font-family: "Odoo Unicode Support Noto";
mso-generic-font-family: auto;
mso-font-charset: 0;
}
.font13 {
color: #495057;
font-size: 10.0pt;
font-weight: 700;
font-style: italic;
text-decoration: none;
font-family: "Odoo Unicode Support Noto";
mso-generic-font-family: auto;
mso-font-charset: 0;
}
.font33 {
color: #495057;
font-size: 10.0pt;
font-weight: 700;
font-style: normal;
text-decoration: none;
font-family: "Odoo Unicode Support Noto";
mso-generic-font-family: auto;
mso-font-charset: 0;
}
.xl87 {
font-size: 14.0pt;
font-family: "Roboto Mono";
mso-generic-font-family: auto;
mso-font-charset: 1;
text-align: center;
}
.xl88 {
color: #495057;
font-size: 10.0pt;
font-style: italic;
font-family: "Odoo Unicode Support Noto";
mso-generic-font-family: auto;
mso-font-charset: 0;
text-align: center;
}
.xl89 {
color: #495057;
font-size: 10.0pt;
font-style: italic;
font-family: Arial;
mso-generic-font-family: auto;
mso-font-charset: 1;
text-align: center;
}
.xl90 {
color: #495057;
font-size: 10.0pt;
font-weight: 700;
font-family: "Odoo Unicode Support Noto";
mso-generic-font-family: auto;
mso-font-charset: 0;
text-align: center;
}
.xl91 {
color: #495057;
font-size: 10.0pt;
font-weight: 700;
text-decoration: underline;
text-underline-style: single;
font-family: Arial;
mso-generic-font-family: auto;
mso-font-charset: 1;
text-align: center;
}
.xl92 {
color: red;
font-size: 10.0pt;
font-family: Arial;
mso-generic-font-family: auto;
mso-font-charset: 1;
text-align: center;
}
.xl93 {
color: red;
font-size: 10.0pt;
text-decoration: underline;
text-underline-style: single;
font-family: Arial;
mso-generic-font-family: auto;
mso-font-charset: 1;
text-align: center;
}
.xl94 {
color: #495057;
font-size: 10.0pt;
font-family: "Odoo Unicode Support Noto";
mso-generic-font-family: auto;
mso-font-charset: 1;
text-align: center;
background: yellow;
mso-pattern: black none;
}
.xl95 {
color: red;
font-size: 10.0pt;
font-family: Arial;
mso-generic-font-family: auto;
mso-font-charset: 1;
text-align: center;
background: yellow;
mso-pattern: black none;
white-space: normal;
}
</style>
</head>
<body link="#0563C1" vlink="#954F72">
<table width=398 style='border-collapse:collapse;width:299pt'><!--StartFragment-->
<col width=187 style='width:140pt'>
<col width=211 style='width:158pt'>
<tr height=20 style='height:15.0pt'>
<td width=187 height=20 class=xl88 dir=LTR style='width:140pt;height:15.0pt'><span class=font12>Italic
then also </span><span class=font13>BOLD</span></td>
<td width=211 class=xl89 dir=LTR style='width:158pt'><s>Italic strike</s></td>
</tr>
<tr height=20 style='height:15.0pt'>
<td height=20 class=xl90 dir=LTR style='height:15.0pt'><span class=font33>Just bold </span><span
class=font12>Just Italic</span></td>
<td class=xl91 dir=LTR>Bold underline</td>
</tr>
<tr height=20 style='height:15.0pt'>
<td height=20 class=xl92 dir=LTR style='height:15.0pt'>Color text</td>
<td class=xl93 dir=LTR><s>Color strike and underline</s></td>
</tr>
<tr height=20 style='height:15.0pt'>
<td height=20 class=xl94 dir=LTR style='height:15.0pt'>Color background</td>
<td width=211 class=xl95 dir=LTR style='width:158pt'>Color text on color background</td>
</tr>
<tr height=27 style='height:20.25pt'>
<td colspan=2 width=398 height=27 class=xl87 dir=LTR style='width:299pt;height:20.25pt'>14pt MONO TEXT
</td>
</tr><!--EndFragment-->
</table>
</body>
</html>
</div>`,
);
},
contentAfter:
`<table class="table table-bordered">
<tbody><tr>
<td>Italic
then also BOLD</td>
<td><s>Italic strike</s></td>
</tr>
<tr>
<td>Just bold Just Italic</td>
<td>Bold underline</td>
</tr>
<tr>
<td>Color text</td>
<td><s>Color strike and underline</s></td>
</tr>
<tr>
<td>Color background</td>
<td>Color text on color background</td>
</tr>
<tr>
<td>14pt MONO TEXT
</td>
</tr>
</tbody></table><p>
[]</p>`,
});
});
});
describe('From Google Sheets', async () => {
it('should keep all allowed style (Google Sheets)', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[]</p>',
stepFunction: async editor => {
await pasteHtml(editor,
`<google-sheets-html-origin>
<style type="text/css">
td {
border: 1px solid #cccccc;
}
br {
mso-data-placement: same-cell;
}
</style>
<table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1"
style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none">
<colgroup>
<col width="170" />
<col width="187" />
</colgroup>
<tbody>
<tr style="height:21px;">
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-family:Odoo Unicode Support Noto;font-weight:normal;font-style:italic;color:#495057;"
data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;Italic then also BOLD&quot;}"
data-sheets-textstyleruns="{&quot;1&quot;:0,&quot;2&quot;:{&quot;3&quot;:&quot;Arial&quot;}}{&quot;1&quot;:17,&quot;2&quot;:{&quot;3&quot;:&quot;Arial&quot;,&quot;5&quot;:1}}">
<span style="font-size:10pt;font-family:Arial;font-style:italic;color:#495057;">Italic then also
</span><span
style="font-size:10pt;font-family:Arial;font-weight:bold;font-style:italic;color:#495057;">BOLD</span>
</td>
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-style:italic;text-decoration:line-through;color:#495057;"
data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;Italic strike&quot;}">Italic strike</td>
</tr>
<tr style="height:21px;">
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-family:Odoo Unicode Support Noto;font-weight:bold;color:#495057;"
data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;Just bold Just italic&quot;}"
data-sheets-textstyleruns="{&quot;1&quot;:0,&quot;2&quot;:{&quot;3&quot;:&quot;Arial&quot;}}{&quot;1&quot;:10,&quot;2&quot;:{&quot;3&quot;:&quot;Arial&quot;,&quot;5&quot;:0,&quot;6&quot;:1}}">
<span
style="font-size:10pt;font-family:Arial;font-weight:bold;font-style:normal;color:#495057;">Just
Bold </span><span style="font-size:10pt;font-family:Arial;font-style:italic;color:#495057;">Just
Italic</span>
</td>
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-weight:bold;text-decoration:underline;color:#495057;"
data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;Bold underline&quot;}">Bold underline</td>
</tr>
<tr style="height:21px;">
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;"
data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;Color text&quot;}"><span style="color:#ff0000;">Color text</span></td>
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-decoration:underline line-through;color:#ff0000;"
data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;Color strike and underline&quot;}">Color
strike and underline</td>
</tr>
<tr style="height:21px;">
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;background-color:#ffff00;font-family:Odoo Unicode Support Noto;font-weight:normal;color:#495057;"
data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;Color background&quot;}">Color background
</td>
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;background-color:#ffff00;color:#ff0000;"
data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;Color text on color background&quot;}">Color
text on color background</td>
</tr>
<tr style="height:21px;">
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-family:Roboto Mono;font-size:14pt;font-weight:normal;text-align:center;"
rowspan="1" colspan="2"
data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;14pt MONO TEXT&quot;}">14pt MONO TEXT</td>
</tr>
</tbody>
</table>
</google-sheets-html-origin>`
);
},
contentAfter:
`<table class="table table-bordered">
<tbody>
<tr>
<td>
Italic then also
BOLD
</td>
<td>Italic strike</td>
</tr>
<tr>
<td>
Just
Bold Just
Italic
</td>
<td>Bold underline</td>
</tr>
<tr>
<td>Color text</td>
<td>Color
strike and underline</td>
</tr>
<tr>
<td>Color background
</td>
<td>Color
text on color background</td>
</tr>
<tr>
<td>14pt MONO TEXT</td>
</tr>
</tbody>
</table><p>
[]</p>`,
});
});
});
describe('From Libre Office', async () => {
it('should keep all allowed style (Libre Office)', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[]</p>',
stepFunction: async editor => {
await pasteHtml(editor,
`<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title></title>
<meta name="generator" content="LibreOffice 6.4.7.2 (Linux)" />
<style type="text/css">
body,
div,
table,
thead,
tbody,
tfoot,
tr,
th,
td,
p {
font-family: "Arial";
font-size: x-small
}
a.comment-indicator:hover+comment {
background: #ffd;
position: absolute;
display: block;
border: 1px solid black;
padding: 0.5em;
}
a.comment-indicator {
background: red;
display: inline-block;
border: 1px solid black;
width: 0.5em;
height: 0.5em;
}
comment {
display: none;
}
</style>
</head>
<body>
<table cellspacing="0" border="0">
<colgroup width="212"></colgroup>
<colgroup width="209"></colgroup>
<tr>
<td style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000"
height="20" align="left"><i>Italic then also BOLD</i></td>
<td style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000"
align="left"><i><s>Italic strike</s></i></td>
</tr>
<tr>
<td style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000"
height="20" align="left"><b>Just bold Just italic</b></td>
<td style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000"
align="left"><b><u>Bold underline</u></b></td>
</tr>
<tr>
<td style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000"
height="20" align="left">
<font color="#FF0000">Color text</font>
</td>
<td style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000"
align="left"><u><s>
<font color="#FF0000">Color strike and underline</font>
</s></u></td>
</tr>
<tr>
<td style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000"
height="20" align="left" bgcolor="#FFFF00">Color background</td>
<td style="border-top: 1px solid #000000; border-bottom: 1px solid #000000; border-left: 1px solid #000000; border-right: 1px solid #000000"
align="left" bgcolor="#FFFF00">
<font color="#FF0000">Color text on color background</font>
</td>
</tr>
<tr>
<td colspan=2 height="26" align="center" valign=middle>
<font face="Andale Mono" size=4>14pt MONO TEXT</font>
</td>
</tr>
</table>
</body>
</html>`
);
},
contentAfter:
`<table class="table table-bordered">
<tbody><tr>
<td><i>Italic then also BOLD</i></td>
<td><i><s>Italic strike</s></i></td>
</tr>
<tr>
<td><b>Just bold Just italic</b></td>
<td><b><u>Bold underline</u></b></td>
</tr>
<tr>
<td>
Color text
</td>
<td><u><s>
Color strike and underline
</s></u></td>
</tr>
<tr>
<td>Color background</td>
<td>
Color text on color background
</td>
</tr>
<tr>
<td>
14pt MONO TEXT
</td>
</tr>
</tbody></table><p>
[]</p>`,
});
});
});
});

View file

@ -0,0 +1,318 @@
import { parseHTML } from '../../src/utils/utils.js';
import {
BasicEditor,
testEditor,
unformat,
insertText,
deleteBackward,
nextTick,
} from '../utils.js';
const span = text => {
const span = document.createElement('span');
span.innerText = text;
span.classList.add('a');
return span;
}
describe('insert HTML', () => {
describe('collapsed selection', () => {
it('should insert html in an empty paragraph / empty editable', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[]<br></p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<i class="fa fa-pastafarianism"></i>'));
},
contentAfterEdit:
'<p><i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]</p>',
contentAfter: '<p><i class="fa fa-pastafarianism"></i>[]</p>',
});
});
it('should insert html between two letters', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[]b<br></p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<i class="fa fa-pastafarianism"></i>'));
},
contentAfterEdit:
'<p>a<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]b<br></p>',
contentAfter: '<p>a<i class="fa fa-pastafarianism"></i>[]b<br></p>',
});
});
it('should insert html in between naked text in the editable', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[]b<br></p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<i class="fa fa-pastafarianism"></i>'));
},
contentAfterEdit:
'<p>a<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]b<br></p>',
contentAfter: '<p>a<i class="fa fa-pastafarianism"></i>[]b<br></p>',
});
});
it('should insert several html nodes in between naked text in the editable', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[]e<br></p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<p>b</p><p>c</p><p>d</p>'));
},
contentAfter: '<p>ab</p><p>c</p><p>d[]e<br></p>',
});
});
it('should keep a paragraph after a div block', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[]<br></p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<div><p>content</p></div>'));
},
contentAfter: '<div><p>content</p></div><p>[]<br></p>',
});
});
it('should not split a pre to insert another pre but just insert the text', async () => {
await testEditor(BasicEditor, {
contentBefore: '<pre>abc[]<br>ghi</pre>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<pre>def</pre>'));
},
contentAfter: '<pre>abcdef[]<br>ghi</pre>',
});
});
it('should keep an "empty" block which contains fontawesome nodes when inserting multiple nodes', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>content[]</p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<p>unwrapped</p><div><i class="fa fa-circle-o-notch"></i></div><p>culprit</p><p>after</p>'));
},
contentAfter: '<p>contentunwrapped</p><div><i class="fa fa-circle-o-notch"></i></div><p>culprit</p><p>after[]</p>',
});
});
it('should not remove the trailing <br> when pasting content ending with a <br>', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[]<br></p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<p>abc<br></p>'));
},
contentAfter: '<p>abc<br>[]<br></p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]cd</p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<p>efg<br></p>'));
},
contentAfter: '<p>abefg<br>[]cd</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p>[]<br></p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<p><br><br><br></p>'));
},
contentAfter: '<p><br><br><br>[]<br></p>',
});
});
it('should paste an "empty" block', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>abcd[]</p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<p>efgh</p><p></p>'));
},
contentAfter: '<p>abcdefgh</p><p><br>[]</p>',
});
});
});
describe('not collapsed selection', () => {
it('should delete selection and insert html in its place', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[a]<br></p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<i class="fa fa-pastafarianism"></i>'));
},
contentAfterEdit: '<p><i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]</p>',
contentAfter: '<p><i class="fa fa-pastafarianism"></i>[]</p>',
});
});
it('should delete selection and insert html in its place (2)', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a[b]c<br></p>',
stepFunction: async editor => {
await editor.execCommand('insert', parseHTML('<i class="fa fa-pastafarianism"></i>'));
},
contentAfterEdit:
'<p>a<i class="fa fa-pastafarianism" contenteditable="false">\u200b</i>[]c<br></p>',
contentAfter: '<p>a<i class="fa fa-pastafarianism"></i>[]c<br></p>',
});
});
it('should remove a fully selected table then insert a span before it', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(
`<p>a[b</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>k]l</p>`,
),
stepFunction: editor => editor.execCommand('insert', span('TEST')),
contentAfter: '<p>a<span class="a">TEST</span>[]l</p>',
});
});
it('should only remove the text content of cells in a partly selected table', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(
`<table><tbody>
<tr><td>cd</td><td class="o_selected_td">e[f</td><td>gh</td></tr>
<tr><td>ij</td><td class="o_selected_td">k]l</td><td>mn</td></tr>
<tr><td>op</td><td>qr</td><td>st</td></tr>
</tbody></table>`,
),
stepFunction: editor => editor.execCommand('insert', span('TEST')),
contentAfter: unformat(
`<table><tbody>
<tr><td>cd</td><td><span class="a">TEST</span>[]</td><td>gh</td></tr>
<tr><td>ij</td><td><br></td><td>mn</td></tr>
<tr><td>op</td><td>qr</td><td>st</td></tr>
</tbody></table>`,
),
});
});
it('should remove some text and a table (even if the table is partly selected)', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(
`<p>a[b</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>g]h</td><td>ij</td></tr>
</tbody></table>
<p>kl</p>`,
),
stepFunction: editor => editor.execCommand('insert', span('TEST')),
contentAfter: unformat(
`<p>a<span class="a">TEST</span>[]</p>
<p>kl</p>`,
),
});
});
it('should remove a table and some text (even if the table is partly selected)', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(
`<p>ab</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>i[j</td></tr>
</tbody></table>
<p>k]l</p>`,
),
stepFunction: editor => editor.execCommand('insert', span('TEST')),
contentAfter: unformat(
`<p>ab</p>
<p><span class="a">TEST</span>[]l</p>`,
),
});
});
it('should remove some text, a table and some more text', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(
`<p>a[b</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>k]l</p>`,
),
stepFunction: editor => editor.execCommand('insert', span('TEST')),
contentAfter: `<p>a<span class="a">TEST</span>[]l</p>`,
});
});
it('should remove a selection of several tables', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(
`<table><tbody>
<tr><td>cd</td><td>e[f</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<table><tbody>
<tr><td>cd</td><td>e]f</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>`,
),
stepFunction: async editor => {
// Table selection happens on selectionchange event which is
// fired in the next tick.
await nextTick();
editor.execCommand('insert', span('TEST'));
},
contentAfter: `<p><span class="a">TEST</span>[]</p>`,
});
});
it('should remove a selection including several tables', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(
`<p>0[1</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>23</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>45</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>67]</p>`,
),
stepFunction: editor => editor.execCommand('insert', span('TEST')),
contentAfter: `<p>0<span class="a">TEST</span>[]</p>`,
});
});
it('should remove everything, including several tables', async () => {
await testEditor(BasicEditor, {
contentBefore: unformat(
`<p>[01</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>23</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>45</p>
<table><tbody>
<tr><td>cd</td><td>ef</td></tr>
<tr><td>gh</td><td>ij</td></tr>
</tbody></table>
<p>67]</p>`,
),
stepFunction: editor => editor.execCommand('insert', span('TEST')),
contentAfter: `<p><span class="a">TEST</span>[]</p>`,
});
});
});
});
describe('insert text', () => {
describe('not collapsed selection', () => {
it('should insert a character in a fully selected font in a heading, preserving its style', async () => {
await testEditor(BasicEditor, {
contentBefore: '<h1><font style="background-color: red;">[abc</font><br></h1><p>]def</p>',
stepFunction: async editor => insertText(editor, 'g'),
contentAfter: '<h1><font style="background-color: red;">g[]</font><br></h1><p>def</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<h1><font style="background-color: red;">[abc</font><br></h1><p>]def</p>',
stepFunction: async editor => {
await deleteBackward(editor);
await insertText(editor, 'g');
},
contentAfter: '<h1><font style="background-color: red;">g[]</font><br></h1><p>def</p>',
});
});
});
});

View file

@ -0,0 +1,21 @@
import { BasicEditor, testEditor, unformat } from '../utils.js';
describe('Odoo fields', () => {
describe('monetary field', () => {
it('should make a span inside a monetary field be unremovable', async () => {
const content = unformat(`
<p>
<span data-oe-model="product.template" data-oe-id="27" data-oe-field="list_price" data-oe-type="monetary" data-oe-expression="product.list_price" data-oe-xpath="/t[1]/div[1]/h3[2]/span[1]" class="o_editable">
$&nbsp;
<span class="oe_currency_value">[]</span>
</span>
</p>
`);
await testEditor(BasicEditor, {
contentBefore: content,
stepFunction: (editor) => editor.execCommand('oDeleteBackward'),
contentAfter: content,
});
});
});
});

View file

@ -0,0 +1,351 @@
import { setSelection } from '../../src/OdooEditor.js';
import { Powerbox } from '../../src/powerbox/Powerbox.js';
import { BasicEditor, _isMobile, insertText, nextTick, testEditor, triggerEvent } from '../utils.js';
const getCurrentCommandNames = powerbox => {
return [...powerbox.el.querySelectorAll('.oe-powerbox-commandName')].map(c => c.innerText);
}
describe('Powerbox', () => {
describe('integration', () => {
it('should open the Powerbox on type `/`', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]</p>',
stepFunction: async editor => {
window.chai.expect(editor.powerbox.isOpen).to.eql(false);
window.chai.expect(editor.powerbox.el.style.display).to.eql('none');
await insertText(editor, '/');
window.chai.expect(editor.powerbox.isOpen).to.eql(true);
window.chai.expect(editor.powerbox.el.style.display).not.to.eql('none');
},
});
});
it('should filter the Powerbox contents with term', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]</p>',
stepFunction: async editor => {
await insertText(editor, '/');
await insertText(editor, 'head');
await triggerEvent(editor.editable, 'keyup');
window.chai.expect(getCurrentCommandNames(editor.powerbox)).to.eql(['Heading 1', 'Heading 2', 'Heading 3']);
},
});
});
it('should not filter the powerbox contents when collaborator type on two different blocks', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab</p><p>c[]d</p>',
stepFunction: async editor => {
await insertText(editor, '/');
await insertText(editor, 'heading');
setSelection(editor.editable.firstChild, 1);
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Mimick a collaboration scenario where another user types
// random text, using `insert` as it won't trigger keyup.
editor.execCommand('insert', 'random text');
window.chai.expect(editor.powerbox.isOpen).to.be.true;
setSelection(editor.editable.lastChild, 9);
window.chai.expect(editor.powerbox.isOpen).to.be.true;
await insertText(editor, '1');
window.chai.expect(editor.powerbox.isOpen).to.be.true;
window.chai.expect(getCurrentCommandNames(editor.powerbox)).to.eql(['Heading 1']);
},
});
});
it('should execute command and remove term and hot character on Enter', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]</p>',
stepFunction: async editor => {
editor.powerbox.el.classList.add('yo');
await insertText(editor, '/');
await insertText(editor, 'head');
await triggerEvent(editor.editable, 'keyup');
await triggerEvent(editor.editable, 'keydown', { key: 'Enter' });
},
contentAfter: '<h1>ab[]</h1>',
});
});
it('should not reinsert the selected text after command validation', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>[]<br></p>',
stepFunction: async editor => {
await insertText(editor, 'abc');
const p = editor.editable.querySelector('p');
setSelection(p.firstChild, 0, p.lastChild, 1);
await nextTick();
await insertText(editor, '/');
window.chai.expect(editor.powerbox.isOpen).to.be.true;
await insertText(editor, 'h1');
await triggerEvent(editor.editable, "keydown", { key: "Enter" });
},
contentAfter: '<h1>[]<br></h1>',
});
});
it('should close the powerbox if keyup event is called on other block', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab</p><p>c[]d</p>',
stepFunction: async (editor) => {
await insertText(editor, '/');
window.chai.expect(editor.powerbox.isOpen).to.be.true;
setSelection(editor.editable.firstChild, 1);
await triggerEvent(editor.editable, 'keyup');
window.chai.expect(editor.powerbox.isOpen).to.be.false;
},
});
});
});
it('should insert a 3x3 table on type `/table` in mobile view', async () => {
if(_isMobile()){
await testEditor(BasicEditor, {
contentBefore: `<p>[]<br></p>`,
stepFunction: async editor => {
await insertText(editor,'/');
await insertText(editor, 'table');
await triggerEvent(editor.editable,'keyup');
await triggerEvent(editor.editable,'keydown', {key: 'Enter'});
},
contentAfter: `<table class="table table-bordered o_table"><tbody><tr><td>[]<p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr><tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr><tr><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr></tbody></table><p><br></p>`,
});
}
});
describe('class', () => {
it('should properly order default commands and categories', async () => {
const editable = document.createElement('div');
document.body.append(editable);
const powerbox = new Powerbox({
categories: [
{name: 'a', priority: 2},
{name: 'b', priority: 4},
{name: 'c', priority: 1}, // redefined lower -> ignore
{name: 'd', priority: 4}, // same as b -> alphabetical
{name: 'c', priority: 3},
],
commands: [
{category: 'f', name: 'f1'}, // category doesn't exist -> end
{category: 'e', name: 'e1'}, // category doesn't exist -> end
{category: 'a', name: 'a1', priority: 1},
{category: 'a', name: 'a4', priority: 3},
{category: 'a', name: 'a2', priority: 3},
{category: 'a', name: 'a3', priority: 2},
{category: 'b', name: 'b3'}, // no priority -> priority 1
{category: 'b', name: 'b1'},
{category: 'b', name: 'b2'},
{category: 'c', name: 'c1'},
{category: 'd', name: 'd1'},
],
editable,
});
powerbox.open();
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(
['b1', 'b2', 'b3', 'd1', 'c1', 'a2', 'a4', 'a3', 'a1', 'e1', 'f1']
);
powerbox.destroy();
editable.remove();
});
it('should navigate through commands with arrow keys', async () => {
const editable = document.createElement('div');
document.body.append(editable);
const powerbox = new Powerbox({
categories: [],
commands: [
{category: 'a', name: '2'},
{category: 'a', name: '3'},
{category: 'a', name: '1'},
],
editable,
});
powerbox.open();
window.chai.expect(powerbox._context.selectedCommand.name).to.eql('1');
await triggerEvent(editable, 'keydown', { key: 'ArrowDown'});
window.chai.expect(powerbox._context.selectedCommand.name).to.eql('2');
await triggerEvent(editable, 'keydown', { key: 'ArrowDown'});
window.chai.expect(powerbox._context.selectedCommand.name).to.eql('3');
await triggerEvent(editable, 'keydown', { key: 'ArrowUp'});
window.chai.expect(powerbox._context.selectedCommand.name).to.eql('2');
await triggerEvent(editable, 'keydown', { key: 'ArrowUp'});
window.chai.expect(powerbox._context.selectedCommand.name).to.eql('1');
powerbox.destroy();
editable.remove();
});
it('should execute command on press Enter', async () => {
const editable = document.createElement('div');
editable.classList.add('odoo-editor-editable');
document.body.append(editable);
const powerbox = new Powerbox({
categories: [],
commands: [
{category: 'a', name: '2', callback: () => editable.innerText = '2'},
{category: 'a', name: '3', callback: () => editable.innerText = '3'},
{category: 'a', name: '1', callback: () => editable.innerText = '1'},
],
editable,
});
setSelection(editable, 0);
powerbox.open();
window.chai.expect(editable.innerText).to.eql('');
await triggerEvent(editable, 'keydown', { key: 'Enter'});
window.chai.expect(editable.innerText).to.eql('1');
powerbox.open();
await triggerEvent(editable, 'keydown', { key: 'ArrowDown'});
await triggerEvent(editable, 'keydown', { key: 'Enter'});
window.chai.expect(editable.innerText).to.eql('2');
powerbox.destroy();
editable.remove();
});
it('should filter commands with `commandFilters`', async () => {
const editable = document.createElement('div');
document.body.append(editable);
const powerbox = new Powerbox({
categories: [],
commands: [
{category: 'a', name: 'a2'},
{category: 'a', name: 'a3'},
{category: 'a', name: 'a1'},
{category: 'b', name: 'b2x'},
{category: 'b', name: 'b3x'},
{category: 'b', name: 'b1y'},
],
editable,
commandFilters: [commands => commands.filter(command => command.category === 'b')],
});
powerbox.open();
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['b1y', 'b2x', 'b3x']);
powerbox.close();
powerbox.commandFilters.push(commands => commands.filter(command => command.name.includes('x')))
powerbox.open();
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['b2x', 'b3x']);
powerbox.destroy();
editable.remove();
});
it('should filter commands with `isDisabled`', async () => {
const editable = document.createElement('div');
document.body.append(editable);
let disableCommands = false;
const powerbox = new Powerbox({
categories: [],
commands: [
{category: 'a', name: 'a2', isDisabled: () => disableCommands},
{category: 'a', name: 'a3', isDisabled: () => disableCommands},
{category: 'a', name: 'a1', isDisabled: () => disableCommands},
{category: 'b', name: 'b2x'},
{category: 'b', name: 'b3x'},
{category: 'b', name: 'b1y'},
],
editable,
});
powerbox.open();
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['a1', 'a2', 'a3', 'b1y', 'b2x', 'b3x']);
powerbox.close();
disableCommands = true;
powerbox.open();
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['b1y', 'b2x', 'b3x']);
powerbox.close();
disableCommands = false;
powerbox.open();
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['a1', 'a2', 'a3', 'b1y', 'b2x', 'b3x']);
powerbox.destroy();
editable.remove();
});
it('should filter commands with filter text', async () => {
const editable = document.createElement('div');
editable.classList.add('odoo-editor-editable');
document.body.append(editable);
editable.append(document.createTextNode('original text'));
setSelection(editable.firstChild, 13);
const powerbox = new Powerbox({
categories: [],
commands: [
{category: 'a', name: 'a2'},
{category: 'a', name: 'a3'},
{category: 'a', name: 'a1'},
{category: 'b', name: 'b2x'},
{category: 'b', name: 'b3x'},
{category: 'b', name: 'b1y'},
],
editable,
});
powerbox.open();
window.chai.expect(powerbox._context.initialValue).to.eql('original text');
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['a1', 'a2', 'a3', 'b1y', 'b2x', 'b3x']);
// filter: '1'
editable.append(document.createTextNode('1'));
await triggerEvent(editable, 'keyup');
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['a1', 'b1y']);
// filter: ''
editable.lastChild.remove();
await triggerEvent(editable, 'keyup');
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['a1', 'a2', 'a3', 'b1y', 'b2x', 'b3x']);
// filter: 'a'
editable.append(document.createTextNode('a'));
await triggerEvent(editable, 'keyup'); // filter: 'a'.
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['a1', 'a2', 'a3']);
editable.append(document.createTextNode('1'));
await triggerEvent(editable, 'keyup'); // filter: 'a1'.
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['a1']);
powerbox.destroy();
editable.remove();
});
it('should close the Powerbox on remove last filter text with Backspace', async () => {
const editable = document.createElement('div');
editable.classList.add('odoo-editor-editable');
document.body.append(editable);
editable.append(document.createTextNode('1'));
setSelection(editable.firstChild, 13);
const powerbox = new Powerbox({
categories: [],
commands: [
{category: 'a', name: 'a2'},
{category: 'a', name: 'a3'},
{category: 'a', name: 'a1'},
{category: 'b', name: 'b2x'},
{category: 'b', name: 'b3x'},
{category: 'b', name: 'b1y'},
],
editable,
});
powerbox.open();
// Text: '1'
window.chai.expect(powerbox._context.initialValue).to.eql('1');
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['a1', 'a2', 'a3', 'b1y', 'b2x', 'b3x']);
// Text: '1y' -> filter: 'y'
editable.append(document.createTextNode('x'));
await triggerEvent(editable, 'keyup');
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['b2x', 'b3x']);
// Text: '1'
editable.lastChild.remove();
await triggerEvent(editable, 'keydown', { key: 'Backspace' });
await triggerEvent(editable, 'keyup');
window.chai.expect(getCurrentCommandNames(powerbox)).to.eql(['a1', 'a2', 'a3', 'b1y', 'b2x', 'b3x']);
window.chai.expect(powerbox.isOpen).to.eql(true);
window.chai.expect(powerbox.el.style.display).not.to.eql('none');
// Text: ''
editable.lastChild.remove();
await triggerEvent(editable, 'keydown', { key: 'Backspace' });
await triggerEvent(editable, 'keyup');
window.chai.expect(powerbox.isOpen).to.eql(false);
window.chai.expect(powerbox.el.style.display).to.eql('none');
powerbox.destroy();
editable.remove();
});
it('should close the Powerbox on press Escape', async () => {
const editable = document.createElement('div');
document.body.append(editable);
const powerbox = new Powerbox({
categories: [],
commands: [
{category: 'a', name: '2'},
{category: 'a', name: '3'},
{category: 'a', name: '1'},
],
editable,
});
powerbox.open();
window.chai.expect(powerbox.isOpen).to.eql(true);
window.chai.expect(powerbox.el.style.display).not.to.eql('none');
await triggerEvent(editable, 'keydown', { key: 'Escape' });
window.chai.expect(powerbox.isOpen).to.eql(false);
window.chai.expect(powerbox.el.style.display).to.eql('none');
powerbox.destroy();
editable.remove();
});
});
});

View file

@ -0,0 +1,551 @@
import {
BasicEditor,
deleteBackward,
deleteForward,
insertText,
triggerEvent,
testEditor,
} from '../utils.js';
const TAB_WIDTH = 40;
describe('Tabs', () => {
const oeTab = (size, contenteditable = true) => (
`<span class="oe-tabs"` +
(contenteditable ? '' : ' contenteditable="false"') +
(size ?` style="width: ${size}px;"` : '') +
`>\u0009</span>\u200B`
);
describe('insert tabulation', () => {
it('should insert a tab character', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a[]b</p>`,
stepFunction: async editor => {
await triggerEvent(editor.editable, 'keydown', { key: 'Tab'});
},
contentAfterEdit: `<p>a${oeTab(32.8906, false)}[]b</p>`,
contentAfter: `<p>a${oeTab(32.8906)}[]b</p>`,
});
});
it('should keep selection and insert a tab character at the beginning of the paragraph', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a[xxx]b</p>`,
stepFunction: async editor => {
await triggerEvent(editor.editable, 'keydown', { key: 'Tab'});
},
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}a[xxx]b</p>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}a[xxx]b</p>`,
});
});
it('should insert two tab characters', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a[]b</p>`,
stepFunction: async editor => {
await triggerEvent(editor.editable, 'keydown', { key: 'Tab'});
await triggerEvent(editor.editable, 'keydown', { key: 'Tab'});
},
contentAfterEdit: `<p>a${oeTab(32.8906, false)}${oeTab(TAB_WIDTH, false)}[]b</p>`,
contentAfter: `<p>a${oeTab(32.8906)}${oeTab(TAB_WIDTH)}[]b</p>`,
});
});
it('should insert two tab characters with one char between them', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a[]b</p>`,
stepFunction: async editor => {
await triggerEvent(editor.editable, 'keydown', { key: 'Tab'});
await insertText(editor,'a');
await triggerEvent(editor.editable, 'keydown', { key: 'Tab'});
},
contentAfterEdit: `<p>a${oeTab(32.8906, false)}a${oeTab(32.8906, false)}[]b</p>`,
contentAfter: `<p>a${oeTab(32.8906)}a${oeTab(32.8906)}[]b</p>`,
});
});
it('should insert tab characters at the beginning of two separate paragraphs', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a[b</p>` +
`<p>c]d</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}a[b</p>` +
`<p>${oeTab(TAB_WIDTH, false)}c]d</p>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}a[b</p>` +
`<p>${oeTab(TAB_WIDTH)}c]d</p>`,
});
});
it('should insert tab characters at the beginning of two separate indented paragraphs', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}a[b</p>` +
`<p>${oeTab()}c]d</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}a[b</p>` +
`<p>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}c]d</p>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}a[b</p>` +
`<p>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}c]d</p>`,
});
});
it('should insert tab characters at the beginning of two separate paragraphs (one indented, the other not)', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}a[b</p>` +
`<p>c]d</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}a[b</p>` +
`<p>${oeTab(TAB_WIDTH, false)}c]d</p>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}a[b</p>` +
`<p>${oeTab(TAB_WIDTH)}c]d</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a[b</p>` +
`<p>${oeTab()}c]d</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}a[b</p>` +
`<p>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}c]d</p>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}a[b</p>` +
`<p>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}c]d</p>`,
});
});
it('should insert tab characters at the beginning of two separate paragraphs with tabs in them', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}a[${oeTab()}b${oeTab()}</p>` +
`<p>c${oeTab()}]d${oeTab()}</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}a[${oeTab(32.8906, false)}b${oeTab(32, false)}</p>` +
`<p>${oeTab(TAB_WIDTH, false)}c${oeTab(32.8906, false)}]d${oeTab(32, false)}</p>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}a[${oeTab(32.8906)}b${oeTab(32)}</p>` +
`<p>${oeTab(TAB_WIDTH)}c${oeTab(32.8906)}]d${oeTab(32)}</p>`,
});
});
it('should insert tab characters at the beginning of three separate blocks', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>xxx</p>` +
`<p>a[b</p>` +
`<h1>cd</h1>` +
`<blockquote>e]f</blockquote>` +
`<h4>zzz</h4>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
contentAfterEdit: `<p>xxx</p>` +
`<p>${oeTab(TAB_WIDTH, false)}a[b</p>` +
`<h1>${oeTab(TAB_WIDTH, false)}cd</h1>` +
`<blockquote>${oeTab(TAB_WIDTH, false)}e]f</blockquote>` +
`<h4>zzz</h4>`,
contentAfter: `<p>xxx</p>` +
`<p>${oeTab(TAB_WIDTH)}a[b</p>` +
`<h1>${oeTab(TAB_WIDTH)}cd</h1>` +
`<blockquote>${oeTab(TAB_WIDTH)}e]f</blockquote>` +
`<h4>zzz</h4>`,
});
});
it('should insert tab characters at the beginning of three separate indented blocks', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}xxx</p>` +
`<p>${oeTab()}a[b</p>` +
`<h1>${oeTab()}cd</h1>` +
`<blockquote>${oeTab()}e]f</blockquote>` +
`<h4>${oeTab()}zzz</h4>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}xxx</p>` +
`<p>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}a[b</p>` +
`<h1>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}cd</h1>` +
`<blockquote>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}e]f</blockquote>` +
`<h4>${oeTab(TAB_WIDTH, false)}zzz</h4>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}xxx</p>` +
`<p>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}a[b</p>` +
`<h1>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}cd</h1>` +
`<blockquote>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}e]f</blockquote>` +
`<h4>${oeTab(TAB_WIDTH)}zzz</h4>`,
});
});
it('should insert tab characters at the beginning of three separate blocks of mixed indentation', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>xxx</p>` +
`<p>${oeTab()}${oeTab()}a[b</p>` +
`<h1>${oeTab()}cd</h1>` +
`<blockquote>e]f</blockquote>` +
`<h4>zzz</h4>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
contentAfterEdit: `<p>xxx</p>` +
`<p>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}a[b</p>` +
`<h1>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}cd</h1>` +
`<blockquote>${oeTab(TAB_WIDTH, false)}e]f</blockquote>` +
`<h4>zzz</h4>`,
contentAfter: `<p>xxx</p>` +
`<p>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}a[b</p>` +
`<h1>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}cd</h1>` +
`<blockquote>${oeTab(TAB_WIDTH)}e]f</blockquote>` +
`<h4>zzz</h4>`,
});
});
it('should insert tab characters at the beginning of three separate blocks with tabs in them', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>xxx</p>` +
`<p>${oeTab()}a[${oeTab()}b${oeTab()}</p>` +
`<h1>c${oeTab()}d${oeTab()}</h1>` +
`<blockquote>e${oeTab()}]f</blockquote>` +
`<h4>zzz</h4>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
contentAfterEdit: `<p>xxx</p>` +
`<p>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}a[${oeTab(32.8906, false)}b${oeTab(32, false)}</p>` +
`<h1>${oeTab(TAB_WIDTH, false)}c${oeTab(25.7969, false)}d${oeTab(22.2031, false)}</h1>` +
`<blockquote>${oeTab(TAB_WIDTH, false)}e${oeTab(32.8906, false)}]f</blockquote>` +
`<h4>zzz</h4>`,
contentAfter: `<p>xxx</p>` +
`<p>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}a[${oeTab(32.8906)}b${oeTab(32)}</p>` +
`<h1>${oeTab(TAB_WIDTH)}c${oeTab(25.7969)}d${oeTab(22.2031)}</h1>` +
`<blockquote>${oeTab(TAB_WIDTH)}e${oeTab(32.8906)}]f</blockquote>` +
`<h4>zzz</h4>`,
});
});
it('should insert tab characters in blocks and indent lists', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}a[${oeTab()}b${oeTab()}</p>` +
`<ul>` +
`<li>c${oeTab()}d${oeTab()}</li>` +
`<li class="oe-nested"><ul><li>${oeTab()}e${oeTab()}</li></ul></li>` +
`</ul>` +
`<blockquote>f${oeTab()}]g</blockquote>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}${oeTab(TAB_WIDTH, false)}a[${oeTab(32.8906, false)}b${oeTab(32, false)}</p>` +
`<ul>` +
`<li class="oe-nested"><ul><li>c${oeTab(32.8906, false)}d${oeTab(32, false)}</li>` +
`<li class="oe-nested"><ul><li>${oeTab(TAB_WIDTH, false)}e${oeTab(32.8906, false)}</li></ul></li></ul></li>` +
`</ul>` +
`<blockquote>${oeTab(TAB_WIDTH, false)}f${oeTab(34.6719, false)}]g</blockquote>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}${oeTab(TAB_WIDTH)}a[${oeTab(32.8906)}b${oeTab(32)}</p>` +
`<ul>` +
`<li class="oe-nested"><ul><li>c${oeTab(32.8906)}d${oeTab(32)}</li>` +
`<li class="oe-nested"><ul><li>${oeTab(TAB_WIDTH)}e${oeTab(32.8906)}</li></ul></li></ul></li>` +
`</ul>` +
`<blockquote>${oeTab(TAB_WIDTH)}f${oeTab(34.6719)}]g</blockquote>`,
});
});
});
describe('delete backward tabulation', () => {
it('should remove one tab character', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}[]b</p>`,
stepFunction: async editor => {
await deleteBackward(editor);
},
contentAfter: `<p>a[]b</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}[]${oeTab()}b</p>`,
stepFunction: async editor => {
await deleteBackward(editor);
},
contentAfter: `<p>a[]${oeTab(32.8906)}b</p>`,
});
});
it('should remove two tab characters', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}${oeTab()}[]b</p>`,
stepFunction: async editor => {
await deleteBackward(editor);
await deleteBackward(editor);
},
contentAfter: `<p>a[]b</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}${oeTab()}[]${oeTab()}b</p>`,
stepFunction: async editor => {
await deleteBackward(editor);
await deleteBackward(editor);
},
contentAfter: `<p>a[]${oeTab(32.8906)}b</p>`,
});
});
it('should remove three tab characters', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}${oeTab()}${oeTab()}[]b</p>`,
stepFunction: async editor => {
await deleteBackward(editor);
await deleteBackward(editor);
await deleteBackward(editor);
},
contentAfter: `<p>a[]b</p>`,
});
});
});
describe('delete forward tabulation', () => {
it('should remove one tab character', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a[]${oeTab(32.8906)}b1</p>`,
stepFunction: async editor => {
await deleteForward(editor);
},
contentAfter: `<p>a[]b1</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}[]${oeTab()}b2</p>`,
stepFunction: async editor => {
await deleteForward(editor);
},
contentAfter: `<p>a${oeTab(32.8906)}[]b2</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a[]${oeTab(32.8906)}${oeTab()}b3</p>`,
stepFunction: async editor => {
await deleteForward(editor);
},
contentAfter: `<p>a[]${oeTab(32.8906)}b3</p>`,
});
});
it('should remove two tab characters', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a[]${oeTab(32.8906)}${oeTab()}b1</p>`,
stepFunction: async editor => {
await deleteForward(editor);
await deleteForward(editor);
},
contentAfter: `<p>a[]b1</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a[]${oeTab(32.8906)}${oeTab()}${oeTab()}b2</p>`,
stepFunction: async editor => {
await deleteForward(editor);
await deleteForward(editor);
},
contentAfter: `<p>a[]${oeTab(32.8906)}b2</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}[]${oeTab()}${oeTab()}b3</p>`,
stepFunction: async editor => {
await deleteForward(editor);
await deleteForward(editor);
},
contentAfter: `<p>a${oeTab(32.8906)}[]b3</p>`,
});
});
it('should remove three tab characters', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a[]${oeTab(32.8906)}${oeTab()}${oeTab()}b</p>`,
stepFunction: async editor => {
await deleteForward(editor);
await deleteForward(editor);
await deleteForward(editor);
},
contentAfter: `<p>a[]b</p>`,
});
});
});
describe('delete mixed tabulation', () => {
it('should remove all tab characters', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}[]${oeTab()}b1</p>`,
stepFunction: async editor => {
await deleteForward(editor);
await deleteBackward(editor);
},
contentAfter: `<p>a[]b1</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}[]${oeTab()}b2</p>`,
stepFunction: async editor => {
await deleteBackward(editor);
await deleteForward(editor);
},
contentAfter: `<p>a[]b2</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}${oeTab()}[]${oeTab()}b3</p>`,
stepFunction: async editor => {
await deleteBackward(editor);
await deleteForward(editor);
await deleteBackward(editor);
},
contentAfter: `<p>a[]b3</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab(32.8906)}[]${oeTab()}${oeTab()}b4</p>`,
stepFunction: async editor => {
await deleteForward(editor);
await deleteBackward(editor);
await deleteForward(editor);
},
contentAfter: `<p>a[]b4</p>`,
});
});
});
describe('remove tabulation with shift+tab', () => {
it('should not remove a non-leading tab character', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>a${oeTab()}[]b</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfterEdit: `<p>a${oeTab(32.8906, false)}[]b</p>`,
contentAfter: `<p>a${oeTab(32.8906)}[]b</p>`,
});
});
it('should remove a tab character', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}a[]b</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfter: `<p>a[]b</p>`,
});
});
it('should keep selection and remove a tab character from the beginning of the paragraph', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}a[xxx]b</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfter: `<p>a[xxx]b</p>`,
});
});
it('should remove two tab characters', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}${oeTab()}a[]b</p>`,
stepFunction: async editor => {
await triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true });
await triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true });
},
contentAfter: `<p>a[]b</p>`,
});
});
it('should remove tab characters from the beginning of two separate paragraphs', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}a[b</p>` +
`<p>${oeTab()}c]d</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfter: `<p>a[b</p>` +
`<p>c]d</p>`,
});
});
it('should remove tab characters from the beginning of two separate double-indented paragraphs', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}${oeTab()}a[b</p>` +
`<p>${oeTab()}${oeTab()}c]d</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}a[b</p>` +
`<p>${oeTab(TAB_WIDTH, false)}c]d</p>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}a[b</p>` +
`<p>${oeTab(TAB_WIDTH)}c]d</p>`,
});
});
it('should remove tab characters from the beginning of two separate paragraphs of mixed indentations', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}${oeTab()}a[b</p>` +
`<p>${oeTab()}c]d</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}a[b</p>` +
`<p>c]d</p>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}a[b</p>` +
`<p>c]d</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>a[b</p>` +
`<p>${oeTab()}c]d</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfter: `<p>a[b</p>` +
`<p>c]d</p>`,
});
});
it('should remove tab characters from the beginning of two separate paragraphs with tabs in them', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}a[${oeTab()}b${oeTab()}</p>` +
`<p>c${oeTab()}]d${oeTab()}</p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfterEdit: `<p>a[${oeTab(32.8906, false)}b${oeTab(32, false)}</p>` +
`<p>c${oeTab(32.8906, false)}]d${oeTab(32, false)}</p>`,
contentAfter: `<p>a[${oeTab(32.8906)}b${oeTab(32)}</p>` +
`<p>c${oeTab(32.8906)}]d${oeTab(32)}</p>`,
});
});
it('should remove tab characters from the beginning of three separate blocks', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>xxx</p>` +
`<p>${oeTab()}a[b</p>` +
`<h1>${oeTab()}cd</h1>` +
`<blockquote>${oeTab()}e]f</blockquote>` +
`<h4>zzz</h4>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfter: `<p>xxx</p>` +
`<p>a[b</p>` +
`<h1>cd</h1>` +
`<blockquote>e]f</blockquote>` +
`<h4>zzz</h4>`,
});
});
it('should remove tab characters from the beginning of three separate blocks of mixed indentation', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>xxx</p>` +
`<p>${oeTab()}${oeTab()}a[b</p>` +
`<h1>${oeTab()}cd</h1>` +
`<blockquote>e]f</blockquote>` +
`<h4>zzz</h4>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfterEdit: `<p>xxx</p>` +
`<p>${oeTab(TAB_WIDTH, false)}a[b</p>` +
`<h1>cd</h1>` +
`<blockquote>e]f</blockquote>` +
`<h4>zzz</h4>`,
contentAfter: `<p>xxx</p>` +
`<p>${oeTab(TAB_WIDTH)}a[b</p>` +
`<h1>cd</h1>` +
`<blockquote>e]f</blockquote>` +
`<h4>zzz</h4>`,
});
});
it('should remove tab characters from the beginning of three separate blocks with tabs in them', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>xxx</p>` +
`<p>${oeTab()}a[${oeTab()}b${oeTab()}</p>` +
`<h1>${oeTab()}c${oeTab()}d${oeTab()}</h1>` +
`<blockquote>${oeTab()}e${oeTab()}]f</blockquote>` +
`<h4>zzz</h4>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfterEdit: `<p>xxx</p>` +
`<p>a[${oeTab(32.8906, false)}b${oeTab(32, false)}</p>` +
`<h1>c${oeTab(25.7969, false)}d${oeTab(22.2031, false)}</h1>` +
`<blockquote>e${oeTab(32.8906, false)}]f</blockquote>` +
`<h4>zzz</h4>`,
contentAfter: `<p>xxx</p>` +
`<p>a[${oeTab(32.8906)}b${oeTab(32)}</p>` +
`<h1>c${oeTab(25.7969)}d${oeTab(22.2031)}</h1>` +
`<blockquote>e${oeTab(32.8906)}]f</blockquote>` +
`<h4>zzz</h4>`,
});
});
it('should remove tab characters from the beginning of blocks and outdent lists', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}${oeTab()}a[${oeTab()}b${oeTab()}</p>` +
`<ul>` +
`<li class="oe-nested"><ul><li>c${oeTab()}d${oeTab()}</li>` +
`<li class="oe-nested"><ul><li>${oeTab()}e${oeTab()}</li></ul></li></ul></li>` +
`</ul>` +
`<blockquote>${oeTab()}f${oeTab()}]g</blockquote>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfterEdit: `<p>${oeTab(TAB_WIDTH, false)}a[${oeTab(32.8906, false)}b${oeTab(32, false)}</p>` +
`<ul>` +
`<li>c${oeTab(32.8906, false)}d${oeTab(32, false)}</li>` +
`<li class="oe-nested"><ul><li>${oeTab(TAB_WIDTH, false)}e${oeTab(32.8906, false)}</li></ul></li>` +
`</ul>` +
`<blockquote>f${oeTab(34.6719, false)}]g</blockquote>`,
contentAfter: `<p>${oeTab(TAB_WIDTH)}a[${oeTab(32.8906)}b${oeTab(32)}</p>` +
`<ul>` +
`<li>c${oeTab(32.8906)}d${oeTab(32)}</li>` +
`<li class="oe-nested"><ul><li>${oeTab(TAB_WIDTH)}e${oeTab(32.8906)}</li></ul></li>` +
`</ul>` +
`<blockquote>f${oeTab(34.6719)}]g</blockquote>`,
});
});
it('should remove a tab character from formatted text', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p><strong>${oeTab()}a[]b</strong></p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfter: `<p><strong>a[]b</strong></p>`,
});
});
it('should remove tab characters from the beginning of two separate formatted paragraphs', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${oeTab()}<strong>a[b</strong></p>` +
`<p>${oeTab()}<strong>c]d</strong></p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfter: `<p><strong>a[b</strong></p>` +
`<p><strong>c]d</strong></p>`,
});
});
it('should remove a tab character from styled text', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p><font style="background-color: rgb(255,255,0);">${oeTab()}a[]b</font></p>`,
stepFunction: editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab', shiftKey: true }),
contentAfter: `<p><font style="background-color: rgb(255,255,0);">a[]b</font></p>`,
});
});
});
});

View file

@ -0,0 +1,103 @@
import { URL_REGEX, URL_REGEX_WITH_INFOS } from '../../src/OdooEditor.js';
describe('urlRegex', () => {
it('should match foo.com', () => {
const url = 'foo.com';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX);
chai.expect(match[0]).to.be.equal(url);
});
it('should not match foo.else', () => {
const url = 'foo.else';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX);
chai.expect(match).to.be.equal(null);
});
it('should match www.abc.abc', () => {
const url = 'www.abc.abc';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX);
chai.expect(match[0]).to.be.equal(url);
});
it('should match abc.abc.com', () => {
const url = 'abc.abc.com';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX);
chai.expect(match[0]).to.be.equal(url);
});
it('should not match abc.abc.abc', () => {
const url = 'abc.abc.abc';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX);
chai.expect(match).to.be.equal(null);
});
it('should match http://abc.abc.abc', () => {
const url = 'http://abc.abc.abc';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX);
chai.expect(match[0]).to.be.equal(url);
});
it('should match https://abc.abc.abc', () => {
const url = 'https://abc.abc.abc';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX);
chai.expect(match[0]).to.be.equal(url);
});
it('should match 1234-abc.runbot007.odoo.com/web#id=3&menu_id=221', () => {
const url = '1234-abc.runbot007.odoo.com/web#id=3&menu_id=221';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX);
chai.expect(match[0]).to.be.equal(url);
});
it('should match https://1234-abc.runbot007.odoo.com/web#id=3&menu_id=221', () => {
const url = 'https://1234-abc.runbot007.odoo.com/web#id=3&menu_id=221';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX);
chai.expect(match[0]).to.be.equal(url);
});
});
describe('urlRegex with infos', () => {
it('should match foo.com', () => {
const url = 'foo.com';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX_WITH_INFOS);
chai.expect(match[0]).to.be.equal(url);
});
it('should not match foo.else', () => {
const url = 'foo.else';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX_WITH_INFOS);
chai.expect(match).to.be.equal(null);
});
it('should match www.abc.abc', () => {
const url = 'www.abc.abc';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX_WITH_INFOS);
chai.expect(match[0]).to.be.equal(url);
});
it('should match http://abc.abc.abc', () => {
const url = 'http://abc.abc.abc';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX_WITH_INFOS);
chai.expect(match[0]).to.be.equal(url);
});
it('should match https://abc.abc.abc', () => {
const url = 'https://abc.abc.abc';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX_WITH_INFOS);
chai.expect(match[0]).to.be.equal(url);
});
it('should match 1234-abc.runbot007.odoo.com/web#id=3&menu_id=221', () => {
const url = '1234-abc.runbot007.odoo.com/web#id=3&menu_id=221';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX_WITH_INFOS);
chai.expect(match[0]).to.be.equal(url);
});
it('should match https://1234-abc.runbot007.odoo.com/web#id=3&menu_id=221', () => {
const url = 'https://1234-abc.runbot007.odoo.com/web#id=3&menu_id=221';
const text = `abc ${url} abc`;
const match = text.match(URL_REGEX_WITH_INFOS);
chai.expect(match[0]).to.be.equal(url);
});
});

View file

@ -0,0 +1,696 @@
/** @odoo-module **/
import { OdooEditor } from '../src/OdooEditor.js';
import { sanitize } from '../src/utils/sanitize.js';
import {
closestElement,
makeZeroWidthCharactersVisible,
insertSelectionChars,
} from '../src/utils/utils.js';
export const Direction = {
BACKWARD: 'BACKWARD',
FORWARD: 'FORWARD',
};
// True iff test is being run with its mobile implementation.
let isMobileTest = false;
// True iff test has mobile implementation for any called method.
let hasMobileTest = false;
function _nextNode(node) {
let next = node.firstChild || node.nextSibling;
if (!next) {
next = node;
while (next.parentNode && !next.nextSibling) {
next = next.parentNode;
}
next = next && next.nextSibling;
}
return next;
}
function _toDomLocation(node, index) {
let container;
let offset;
if (node.textContent.length) {
container = node;
offset = index;
} else {
container = node.parentNode;
offset = Array.from(node.parentNode.childNodes).indexOf(node);
}
return [container, offset];
}
export function parseTextualSelection(testContainer) {
let anchorNode;
let anchorOffset;
let focusNode;
let focusOffset;
let direction = Direction.FORWARD;
let node = testContainer;
while (node && !(anchorNode && focusNode)) {
let next;
if (node.nodeType === Node.TEXT_NODE) {
// Look for special characters in the text content and remove them.
const anchorIndex = node.textContent.indexOf('[');
node.textContent = node.textContent.replace('[', '');
const focusIndex = node.textContent.indexOf(']');
node.textContent = node.textContent.replace(']', '');
// Set the nodes and offsets if we found the selection characters.
if (anchorIndex !== -1) {
[anchorNode, anchorOffset] = _toDomLocation(node, anchorIndex);
// If the focus node has already been found by this point then
// it is before the anchor node, so the selection is backward.
if (focusNode) {
direction = Direction.BACKWARD;
}
}
if (focusIndex !== -1) {
[focusNode, focusOffset] = _toDomLocation(node, focusIndex);
// If the anchor character is within the same parent and is
// after the focus character, then the selection is backward.
// Adapt the anchorOffset to account for the focus character
// that was removed.
if (anchorNode === focusNode && anchorOffset > focusOffset) {
direction = Direction.BACKWARD;
anchorOffset--;
}
}
// Get the next node to check.
next = _nextNode(node);
// Remove the textual range node if it is empty.
if (!node.textContent.length) {
node.parentNode.removeChild(node);
}
} else {
next = _nextNode(node);
}
node = next;
}
if (anchorNode && focusNode) {
return {
anchorNode: anchorNode,
anchorOffset: anchorOffset,
focusNode: focusNode,
focusOffset: focusOffset,
direction: direction,
};
}
}
export function parseMultipleTextualSelection(testContainer) {
let currentNode = testContainer;
const clients = {};
while (currentNode) {
if (currentNode.nodeType === Node.TEXT_NODE) {
// Look for special characters in the text content and remove them.
let match;
const regex = new RegExp(/(?:\[(\w+)\})|(?:\{(\w+)])/, 'gd');
while ((match = regex.exec(currentNode.textContent))) {
regex.lastIndex = 0;
const indexes = match.indices[0];
if (match[0].includes('}')) {
const clientId = match[1];
clients[clientId] = clients[clientId] || {};
clients[clientId].anchorNode = currentNode;
clients[clientId].anchorOffset = indexes[0];
if (clients[clientId].focusNode) {
clients[clientId].direction = Direction.FORWARD;
}
} else {
const clientId = match[2];
clients[clientId] = clients[clientId] || {};
clients[clientId].focusNode = currentNode;
clients[clientId].focusOffset = indexes[0];
if (clients[clientId].anchorNode) {
clients[clientId].direction = Direction.BACKWARD;
}
}
currentNode.textContent =
currentNode.textContent.slice(0, indexes[0]) +
currentNode.textContent.slice(indexes[1]);
}
}
currentNode = _nextNode(currentNode);
}
return clients;
}
/**
* Set a range in the DOM.
*
* @param selection
*/
export async function setTestSelection(selection, doc = document) {
const domRange = doc.createRange();
if (selection.direction === Direction.FORWARD) {
domRange.setStart(selection.anchorNode, selection.anchorOffset);
domRange.collapse(true);
} else {
domRange.setEnd(selection.anchorNode, selection.anchorOffset);
domRange.collapse(false);
}
const domSelection = doc.getSelection();
domSelection.removeAllRanges();
domSelection.addRange(domRange);
try {
domSelection.extend(selection.focusNode, selection.focusOffset);
} catch {
// Firefox throws NS_ERROR_FAILURE when setting selection on element
// with contentEditable=false for no valid reason since non-editable
// content are selectable by the user anyway.
}
await nextTick(); // Wait a tick for selectionchange events.
}
/**
* Return the deepest child of a given container at a given offset, and its
* adapted offset.
*
* @param container
* @param offset
*/
export function targetDeepest(container, offset) {
// TODO check at which point the method is necessary, for now it creates
// a bug where there is not: it causes renderTextualSelection to put "[]"
// chars inside a <br/>.
// while (container.hasChildNodes()) {
// let childNodes;
// if (container instanceof Element && container.shadowRoot) {
// childNodes = container.shadowRoot.childNodes;
// } else {
// childNodes = container.childNodes;
// }
// if (offset >= childNodes.length) {
// container = container.lastChild;
// // The new container might be a text node, so considering only
// // the `childNodes` property would be wrong.
// offset = nodeLength(container);
// } else {
// container = childNodes[offset];
// offset = 0;
// }
// }
return [container, offset];
}
export function nodeLength(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.nodeValue.length;
} else if (node instanceof Element && node.shadowRoot) {
return node.shadowRoot.childNodes.length;
} else {
return node.childNodes.length;
}
}
/**
* Insert in the DOM:
* - `SELECTION_ANCHOR_CHAR` in place for the selection start
* - `SELECTION_FOCUS_CHAR` in place for the selection end
*
* This is used in the function `testEditor`.
*/
export function renderTextualSelection(editor) {
const selection = editor.document.getSelection();
if (selection.rangeCount === 0) {
return;
}
editor.observerUnactive('renderTextualSelection');
insertSelectionChars(selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset);
editor.observerActive('renderTextualSelection');
}
/**
* Return a more readable test error messages
*/
export function customErrorMessage(assertLocation, value, expected) {
const tab = '//TAB//';
value = makeZeroWidthCharactersVisible(value).replaceAll('\u0009', tab);
expected = makeZeroWidthCharactersVisible(expected).replaceAll('\u0009', tab);
return `${(isMobileTest ? '[MOBILE VERSION: ' : '[')}${assertLocation}]\nactual : '${value}'\nexpected: '${expected}'\n\nStackTrace `;
}
/**
* Return whether the device is in mobile view or not
*/
export function _isMobile(){
return matchMedia('(max-width: 767px)').matches;
}
/**
* Remove all check-ids from the test container (checklists, stars)
*
* @param {Element} testContainer
*/
function removeCheckIds(testContainer) {
for (const li of testContainer.querySelectorAll('li[id^="checkId-"]')) {
li.removeAttribute('id');
}
}
export async function testEditor(Editor = OdooEditor, spec, options = {}) {
hasMobileTest = false;
isMobileTest = options.isMobile;
const testNode = document.createElement('div');
const testContainer = document.querySelector('#editor-test-container');
testContainer.innerHTML = '';
testContainer.append(testNode);
testContainer.append(document.createTextNode('')); // Formatting spaces.
let styleTag;
if (spec.styleContent) {
styleTag = document.createElement('style');
styleTag.textContent = spec.styleContent;
testContainer.append(styleTag);
}
// Add the content to edit and remove the "[]" markers *before* initializing
// the editor as otherwise those would genererate mutations the editor would
// consider and the tests would make no sense.
testNode.innerHTML = spec.contentBefore;
// Setting a selection in the DOM before initializing the editor to ensure
// every test is run with the same preconditions.
await setTestSelection({
anchorNode: testNode.parentElement, anchorOffset: 0,
focusNode: testNode.parentElement, focusOffset: 0,
});
const selection = parseTextualSelection(testNode);
// We disable the `toSanitize` option so we set the test selection on the
// raw, unsanitized HTML. We'll sanitize after having set the selection.
const editor = new Editor(testNode, Object.assign({ toSanitize: false }, options));
let error = false;
try {
editor.keyboardType = 'PHYSICAL';
editor.testMode = true;
if (selection) {
await setTestSelection(selection);
} else {
document.getSelection().removeAllRanges();
}
// Now the selection is set we can finally sanitize.
sanitize(editor.editable);
// In normal circumstances the editor sanitizes its content in its
// constructor, before initializing the history. For the purposes of
// setting the test's selection, we only sanitize now, which means the
// changes made by `sanitize` are included in a step (which could well
// be rolled back if what the step function does triggers it), and the
// sanitization can be undone. This is not how the editor normally
// behaves. To make it closer to reality, we now reset the history.
editor.historyReset();
editor.historyStep();
if (selection) {
editor._recordHistorySelection();
}
if (spec.contentBeforeEdit) {
if (spec.removeCheckIds) {
removeCheckIds(testContainer);
}
renderTextualSelection(editor);
const beforeEditValue = testNode.innerHTML;
window.chai.expect(beforeEditValue).to.be.equal(
spec.contentBeforeEdit,
customErrorMessage('contentBeforeEdit', beforeEditValue, spec.contentBeforeEdit));
const selection = parseTextualSelection(testNode);
if (selection) {
await setTestSelection(selection);
}
}
// Wait for selectionchange handlers to react before any actual testing.
await Promise.resolve();
if (spec.stepFunction) {
try {
await spec.stepFunction(editor);
} catch (e) {
e.message = (isMobileTest ? '[MOBILE VERSION] ' : '') + e.message;
throw e;
}
}
if (spec.contentAfterEdit) {
renderTextualSelection(editor);
if (spec.removeCheckIds) {
removeCheckIds(testContainer);
}
const afterEditValue = testNode.innerHTML;
window.chai.expect(afterEditValue).to.be.equal(
spec.contentAfterEdit,
customErrorMessage('contentAfterEdit', afterEditValue, spec.contentAfterEdit));
const selection = parseTextualSelection(testNode);
if (selection) {
await setTestSelection(selection);
}
}
} catch (err) {
error = err;
}
await editor.clean();
// Same as above: disconnect mutation observers and other things, otherwise
// reading the "[]" markers would broke the test.
await editor.destroy();
if (!error) {
try {
if (spec.contentAfter) {
renderTextualSelection(editor);
if (spec.removeCheckIds) {
removeCheckIds(testContainer);
}
const value = testNode.innerHTML;
window.chai.expect(value).to.be.equal(
spec.contentAfter,
customErrorMessage('contentAfter', value, spec.contentAfter));
}
} catch (err) {
error = err;
}
}
await testNode.remove();
if (error) {
throw error;
} else if (hasMobileTest && !isMobileTest) {
const li = document.createElement('li');
li.classList.add('test', 'pass', 'pending');
const h2 = document.createElement('h2');
h2.textContent = 'FIXME: [Mobile Test] skipped';
li.append(h2);
const mochaSuite = [...document.querySelectorAll('#mocha-report li.suite > ul')].pop();
if (mochaSuite) {
mochaSuite.append(li);
}
// Mobile tests are temporarily disabled because they are not
// representative of reality. They will be re-enabled when the mobile
// editor will be ready.
// await testEditor(Editor, spec, { ...options, isMobile: true });
}
}
/**
* Unformat the given html in order to use it with `innerHTML`.
*/
export function unformat(html) {
return html
.replace(/(^|[^ ])[\s\n]+([^<>]*?)</g, '$1$2<')
.replace(/>([^<>]*?)[\s\n]+([^ ]|$)/g, '>$1$2');
}
/**
* await the next tick (as settimeout 0)
*
*/
export async function nextTick() {
await new Promise(resolve => {
setTimeout(resolve);
});
}
/**
* await the next tick (as settimeout 0) after the next redrawing frame
*
*/
export async function nextTickFrame() {
await new Promise(resolve => {
window.requestAnimationFrame(resolve);
});
await nextTick();
}
/**
* simple simulation of a click on an element
*
* @param el
* @param options
*/
export async function click(el, options) {
el.scrollIntoView();
await nextTickFrame();
const pos = el.getBoundingClientRect();
options = Object.assign(
{
clientX: pos.left + 1,
clientY: pos.top + 1,
},
options,
);
triggerEvent(el, 'mousedown', options);
await nextTickFrame();
triggerEvent(el, 'mouseup', options);
await nextTick();
triggerEvent(el, 'click', options);
await nextTickFrame();
}
export async function deleteForward(editor) {
const selection = document.getSelection();
if (selection.isCollapsed) {
editor.execCommand('oDeleteForward');
} else {
// Better representation of what happened in the editor when the user
// presses the delete key.
await triggerEvent(editor.editable, 'keydown', { key: 'Delete' });
editor.document.execCommand('delete');
}
}
export async function deleteBackward(editor) {
// This method has two implementations (desktop and mobile).
if (isMobileTest) {
// Some mobile keyboard use input event to trigger delete.
// This is a way to simulate this behavior.
const inputEvent = new InputEvent('input', {
inputType: 'deleteContentBackward',
data: null,
bubbles: true,
cancelable: false,
});
editor._onInput(inputEvent);
} else {
hasMobileTest = true; // Flag test for a re-run as mobile.
const selection = document.getSelection();
if (selection.isCollapsed) {
editor.execCommand('oDeleteBackward');
} else {
// Better representation of what happened in the editor when the user
// presses the backspace key.
await triggerEvent(editor.editable, 'keydown', { key: 'Backspace' });
editor.document.execCommand('delete');
}
}
}
export async function insertParagraphBreak(editor) {
editor.execCommand('oEnter');
}
export async function switchDirection(editor) {
editor.execCommand('switchDirection');
}
export async function insertLineBreak(editor) {
editor.execCommand('oShiftEnter');
}
export async function indentList(editor) {
editor.execCommand('indentList');
}
export async function outdentList(editor) {
editor.execCommand('indentList', 'outdent');
}
export async function toggleOrderedList(editor) {
editor.execCommand('toggleList', 'OL');
}
export async function toggleUnorderedList(editor) {
editor.execCommand('toggleList', 'UL');
}
export async function toggleCheckList(editor) {
editor.execCommand('toggleList', 'CL');
}
export async function toggleBold() {
document.execCommand('bold');
// Wait for the timeout in the MutationObserver to happen.
return new Promise(resolve => setTimeout(() => resolve(), 200));
}
export async function createLink(editor, content) {
editor.execCommand('createLink', '#', content);
}
export async function insertText(editor, text) {
// Create and dispatch events to mock text insertion. Unfortunatly, the
// events will be flagged `isTrusted: false` by the browser, requiring
// the editor to detect them since they would not trigger the default
// browser behavior otherwise.
for (const char of text) {
// KeyDownEvent is required to trigger deleteRange.
triggerEvent(editor.editable, 'keydown', { key: char });
// KeyPressEvent is not required but is triggered like in the browser.
triggerEvent(editor.editable, 'keypress', { key: char });
// InputEvent is required to simulate the insert text.
triggerEvent(editor.editable, 'input', {
inputType: 'insertText',
data: char,
});
// KeyUpEvent is not required but is triggered like the browser would.
triggerEvent(editor.editable, 'keyup', { key: char });
}
}
export function undo(editor) {
editor.historyUndo();
}
export function redo(editor) {
editor.historyRedo();
}
/**
* The class exists because the original `InputEvent` does not allow to override
* its inputType property.
*/
class SimulatedInputEvent extends InputEvent {
constructor(type, eventInitDict) {
super(type, eventInitDict);
this.eventInitDict = eventInitDict;
}
get inputType() {
return this.eventInitDict.inputType;
}
}
function getEventConstructor(win, type) {
const eventTypes = {
'pointer': win.MouseEvent,
'contextmenu': win.MouseEvent,
'select': win.MouseEvent,
'wheel': win.MouseEvent,
'click': win.MouseEvent,
'dblclick': win.MouseEvent,
'mousedown': win.MouseEvent,
'mouseenter': win.MouseEvent,
'mouseleave': win.MouseEvent,
'mousemove': win.MouseEvent,
'mouseout': win.MouseEvent,
'mouseover': win.MouseEvent,
'mouseup': win.MouseEvent,
'compositionstart': win.CompositionEvent,
'compositionend': win.CompositionEvent,
'compositionupdate': win.CompositionEvent,
'input': SimulatedInputEvent,
'beforeinput': SimulatedInputEvent,
'keydown': win.KeyboardEvent,
'keypress': win.KeyboardEvent,
'keyup': win.KeyboardEvent,
'dragstart': win.DragEvent,
'dragend': win.DragEvent,
'drop': win.DragEvent,
'beforecut': win.ClipboardEvent,
'copy': win.ClipboardEvent,
'cut': win.ClipboardEvent,
'paste': win.ClipboardEvent,
'touchstart': win.TouchEvent,
'touchend': win.TouchEvent,
'selectionchange': win.Event,
};
if (!eventTypes[type]) {
throw new Error('The event "' + type + '" is not implemented for the tests.');
}
return eventTypes[type];
}
export async function triggerEvent(
el,
eventName,
options,
) {
const currentElement = closestElement(el);
options = Object.assign(
{
view: el.ownerDocument.defaultView,
bubbles: true,
composed: true,
cancelable: true,
isTrusted: true,
},
options,
);
const EventClass = getEventConstructor(el.ownerDocument.defaultView, eventName);
if (EventClass.name === 'ClipboardEvent' && !('clipboardData' in options)) {
throw new Error('ClipboardEvent must have clipboardData in options');
}
const ev = new EventClass(eventName, options);
currentElement.dispatchEvent(ev);
await nextTick();
return ev;
}
// Mock an paste event and send it to the editor.
async function pasteData (editor, text, type) {
var mockEvent = {
dataType: 'text/plain',
data: text,
clipboardData: {
getData: (datatype) => type === datatype ? text : null,
files: [],
items: [],
},
preventDefault: () => { },
};
await editor._onPaste(mockEvent);
};
export const pasteText = async (editor, text) => pasteData(editor, text, 'text/plain');
export const pasteHtml = async (editor, html) => pasteData(editor, html, 'text/html');
export const pasteOdooEditorHtml = async (editor, html) => pasteData(editor, html, 'text/odoo-editor');
const overridenDomClass = [
'HTMLBRElement',
'HTMLHeadingElement',
'HTMLParagraphElement',
'HTMLPreElement',
'HTMLQuoteElement',
'HTMLTableCellElement',
'Text',
];
export function patchEditorIframe(iframe) {
const iframeWindow = iframe.contentWindow;
for (const overridenClass of overridenDomClass) {
const windowClassPrototype = window[overridenClass].prototype;
const iframeWindowClassPrototype = iframeWindow[overridenClass].prototype;
const iframePrototypeMethodNames = Object.keys(iframeWindowClassPrototype);
for (const methodName of Object.keys(windowClassPrototype)) {
if (!iframePrototypeMethodNames.includes(methodName)) {
iframeWindowClassPrototype[methodName] = windowClassPrototype[methodName];
}
}
}
}
export class BasicEditor extends OdooEditor {}

View file

@ -0,0 +1,98 @@
/** @odoo-module **/
/**
* Transform a 2D point using a projective transformation matrix. Note that
* this method is only well behaved for points that don't map to infinity!
*
* @param {number[][]} matrix - A projective transformation matrix
* @param {number[]} point - A 2D point
* @returns The transformed 2D point
*/
export function transform([[a, b, c], [d, e, f], [g, h, i]], [x, y]) {
let z = g * x + h * y + i;
return [(a * x + b * y + c) / z, (d * x + e * y + f) / z];
}
/**
* Calculate the inverse of a 3x3 matrix assuming it is invertible.
*
* @param {number[][]} matrix - A 3x3 matrix
* @returns The resulting 3x3 matrix
*/
function invert([[a, b, c], [d, e, f], [g, h, i]]) {
const determinant = a * e * i - a * f * h - b * d * i + b * f * g + c * d * h - c * e * g;
return [
[(e * i - h * f) / determinant, (h * c - b * i) / determinant, (b * f - e * c) / determinant],
[(g * f - d * i) / determinant, (a * i - g * c) / determinant, (d * c - a * f) / determinant],
[(d * h - g * e) / determinant, (g * b - a * h) / determinant, (a * e - d * b) / determinant],
];
}
/**
* Multiply two 3x3 matrices.
*
* @param {number[][]} a - A 3x3 matrix
* @param {number[][]} b - A 3x3 matrix
* @returns The resulting 3x3 matrix
*/
function multiply(a, b) {
const [[a0, a1, a2], [a3, a4, a5], [a6, a7, a8]] = a;
const [[b0, b1, b2], [b3, b4, b5], [b6, b7, b8]] = b;
return [
[a0 * b0 + a1 * b3 + a2 * b6, a0 * b1 + a1 * b4 + a2 * b7, a0 * b2 + a1 * b5 + a2 * b8],
[a3 * b0 + a4 * b3 + a5 * b6, a3 * b1 + a4 * b4 + a5 * b7, a3 * b2 + a4 * b5 + a5 * b8],
[a6 * b0 + a7 * b3 + a8 * b6, a6 * b1 + a7 * b4 + a8 * b7, a6 * b2 + a7 * b5 + a8 * b8],
];
}
/**
* Find a projective transformation mapping a rectangular area at origin (0,0)
* with a given width and height to a certain quadrilateral.
*
* @param {number} width - The width of the rectangular area
* @param {number} height - The height of the rectangular area
* @param {number[][]} quadrilateral - The vertices of the quadrilateral
* @returns A projective transformation matrix
*/
export function getProjective(width, height, [[x0, y0], [x1, y1], [x2, y2], [x3, y3]]) {
// Calculate a set of homogeneous coordinates a, b, c of the first
// point using the other three points as basis vectors in the
// underlying vector space.
const denominator = x3 * (y1 - y2) + x1 * (y2 - y3) + x2 * (y3 - y1);
const a = (x0 * (y2 - y3) + x2 * (y3 - y0) + x3 * (y0 - y2)) / denominator;
const b = (x0 * (y3 - y1) + x3 * (y1 - y0) + x1 * (y0 - y3)) / denominator;
const c = (x0 * (y1 - y2) + x1 * (y2 - y0) + x2 * (y0 - y1)) / denominator;
// The reverse transformation maps the homogeneous coordinates of
// the last three corners of the original image onto the basis vectors
// while mapping the first corner onto (1, 1, 1). The forward
// transformation maps those basis vectors in addition to (1, 1, 1)
// onto homogeneous coordinates of the corresponding corners of the
// projective image. Combining these together yields the projective
// transformation we are looking for.
const reverse = invert([[width, -width, 0], [0, -height, height], [1, -1, 1]]);
const forward = [[a * x1, b * x2, c * x3], [a * y1, b * y2, c * y3], [a, b, c]];
return multiply(forward, reverse);
}
/**
* Find an affine transformation matrix that exactly maps the vertices of a
* triangle to their corresponding images of a projective transformation. The
* resulting transformation will be an approximation of the projective
* transformation for the area inside the triangle.
*
* @param {number[][]} projective - A projective transformation matrix
* @param {number[][]} triangle - The vertices of a triangle
* @returns - An affine transformation matrix
*/
export function getAffineApproximation(projective, [[x0, y0], [x1, y1], [x2, y2]]) {
const a = transform(projective, [x0, y0]);
const b = transform(projective, [x1, y1]);
const c = transform(projective, [x2, y2]);
return multiply(
[[a[0], b[0], c[0]], [a[1], b[1], c[1]], [1, 1, 1]],
invert([[x0, x1, x2], [y0, y1, y2], [1, 1, 1]]),
);
}

View file

@ -0,0 +1,20 @@
odoo.define('web_editor.toolbar', function (require) {
'use strict';
var Widget = require('web.Widget');
const Toolbar = Widget.extend({
/**
* @constructor
* @param {Widget} parent
* @param {string} contents
*/
init: function (parent, template = 'web_editor.toolbar') {
this._super.apply(this, arguments);
this.template = template;
},
});
return Toolbar;
});

View file

@ -0,0 +1,81 @@
odoo.define('web_editor.loader', function (require) {
'use strict';
const { getBundle, loadBundle } = require('@web/core/assets');
const exports = {};
async function loadWysiwyg(additionnalAssets=[]) {
const xmlids = ['web_editor.assets_wysiwyg', ...additionnalAssets];
for (const xmlid of xmlids) {
const assets = await getBundle(xmlid);
await loadBundle(assets);
}
}
exports.loadWysiwyg = loadWysiwyg;
/**
* Load the assets and create a wysiwyg.
*
* @param {Widget} parent The wysiwyg parent
* @param {object} options
* @param {object} options.wysiwygOptions The wysiwyg options
* @param {string} options.moduleName The wysiwyg module name
* @param {object} options.additionnalAssets The additional assets
*/
exports.createWysiwyg = async (parent, options = {}) => {
const Wysiwyg = await getWysiwygClass(options);
return new Wysiwyg(parent, options.wysiwygOptions);
};
async function getWysiwygClass({moduleName = 'web_editor.wysiwyg', additionnalAssets = []} = {}) {
if (!(await odoo.ready(moduleName))) {
await loadWysiwyg(additionnalAssets);
await odoo.ready(moduleName);
}
return odoo.__DEBUG__.services[moduleName];
}
exports.getWysiwygClass = getWysiwygClass;
exports.loadFromTextarea = async (parent, textarea, options) => {
var loading = textarea.nextElementSibling;
if (loading && !loading.classList.contains('o_wysiwyg_loading')) {
loading = null;
}
const $textarea = $(textarea);
const currentOptions = Object.assign({}, options);
currentOptions.value = currentOptions.value || $textarea.val() || '';
if (!currentOptions.value.trim()) {
currentOptions.value = '<p><br></p>';
}
const Wysiwyg = await getWysiwygClass();
const wysiwyg = new Wysiwyg(parent, currentOptions);
const $wysiwygWrapper = $textarea.closest('.o_wysiwyg_textarea_wrapper');
const $form = $textarea.closest('form');
// hide and append the $textarea in $form so it's value will be send
// through the form.
$textarea.hide();
$form.append($textarea);
$wysiwygWrapper.html('');
await wysiwyg.appendTo($wysiwygWrapper);
$form.find('.note-editable').data('wysiwyg', wysiwyg);
// o_we_selected_image has not always been removed when
// saving a post so we need the line below to remove it if it is present.
$form.find('.note-editable').find('img.o_we_selected_image').removeClass('o_we_selected_image');
$form.on('click', 'button[type=submit]', (e) => {
$form.find('.note-editable').find('img.o_we_selected_image').removeClass('o_we_selected_image');
// float-start class messes up the post layout OPW 769721
$form.find('.note-editable').find('img.float-start').removeClass('float-start');
$textarea.val(wysiwyg.getValue());
});
return wysiwyg;
};
return exports;
});

View file

@ -0,0 +1,33 @@
(function () {
'use strict';
/**
* This file makes sure textarea elements with a specific editor class are
* tweaked as soon as the DOM is ready so that they appear to be loading.
*
* They must then be loaded using standard Odoo modules system. In particular,
* @see web_editor.loader
*/
document.addEventListener('DOMContentLoaded', () => {
// Standard loop for better browser support
var textareaEls = document.querySelectorAll('textarea.o_wysiwyg_loader');
for (var i = 0; i < textareaEls.length; i++) {
var textarea = textareaEls[i];
var wrapper = document.createElement('div');
wrapper.classList.add('position-relative', 'o_wysiwyg_textarea_wrapper');
var loadingElement = document.createElement('div');
loadingElement.classList.add('o_wysiwyg_loading');
var loadingIcon = document.createElement('i');
loadingIcon.classList.add('text-600', 'text-center',
'fa', 'fa-circle-o-notch', 'fa-spin', 'fa-2x');
loadingElement.appendChild(loadingIcon);
wrapper.appendChild(loadingElement);
textarea.parentNode.insertBefore(wrapper, textarea);
wrapper.insertBefore(textarea, loadingElement);
}
});
})();

View file

@ -0,0 +1,678 @@
/** @odoo-module */
import { browser } from "@web/core/browser/browser";
const localStorage = browser.localStorage;
const urlParams = new URLSearchParams(window.location.search);
const collaborationDebug = urlParams.get('collaborationDebug');
const COLLABORATION_LOCALSTORAGE_KEY = 'odoo_editor_collaboration_debug';
if (typeof collaborationDebug === 'string') {
if (collaborationDebug === 'false') {
localStorage.removeItem(
COLLABORATION_LOCALSTORAGE_KEY,
urlParams.get('collaborationDebug'),
);
} else {
localStorage.setItem(COLLABORATION_LOCALSTORAGE_KEY, urlParams.get('collaborationDebug'));
}
}
const debugValue = localStorage.getItem(COLLABORATION_LOCALSTORAGE_KEY);
const debugShowLog = ['', 'true', 'all'].includes(debugValue);
const debugShowNotifications = debugValue === 'all';
const baseNotificationMethods = {
ptp_request: async function(notification) {
const { requestId, requestName, requestPayload, requestTransport } =
notification.notificationPayload;
this._onRequest(
notification.fromClientId,
requestId,
requestName,
requestPayload,
requestTransport,
);
},
ptp_request_result: function(notification) {
const { requestId, result } = notification.notificationPayload;
// If not in _pendingRequestResolver, it means it has timeout.
if (this._pendingRequestResolver[requestId]) {
clearTimeout(this._pendingRequestResolver[requestId].rejectTimeout);
this._pendingRequestResolver[requestId].resolve(result);
delete this._pendingRequestResolver[requestId];
}
},
ptp_join: async function (notification) {
const clientId = notification.fromClientId;
if (this.clientsInfos[clientId] && this.clientsInfos[clientId].peerConnection) {
return this.clientsInfos[clientId];
}
this._createClient(clientId);
},
rtc_signal_icecandidate: async function (notification) {
if (debugShowLog) console.log(`%creceive candidate`, 'background: darkgreen; color: white;');
const clientInfos = this.clientsInfos[notification.fromClientId];
if (
!clientInfos ||
!clientInfos.peerConnection ||
clientInfos.peerConnection.connectionState === 'closed'
) {
console.groupCollapsed('=== ERROR: Handle Ice Candidate from undefined|closed ===');
console.trace(clientInfos);
console.groupEnd();
return;
}
if (!clientInfos.peerConnection.remoteDescription) {
clientInfos.iceCandidateBuffer.push(notification.notificationPayload);
} else {
this._addIceCandidate(clientInfos, notification.notificationPayload);
}
},
rtc_signal_description: async function (notification) {
const description = notification.notificationPayload;
if (debugShowLog)
console.log(
`%cdescription received:`,
'background: blueviolet; color: white;',
description,
);
const clientInfos =
this.clientsInfos[notification.fromClientId] ||
this._createClient(notification.fromClientId);
const pc = clientInfos.peerConnection;
if (!pc || pc.connectionState === 'closed') {
if (debugShowLog) {
console.groupCollapsed('=== ERROR: handle offer ===');
console.log(
'An offer has been received for a non-existent peer connection - client: ' +
notification.fromClientId,
);
console.trace(pc && pc.connectionState);
console.groupEnd();
}
return;
}
// Skip if we already have an offer.
if (pc.signalingState === 'have-remote-offer') {
return;
}
// If there is a racing conditing with the signaling offer (two
// being sent at the same time). We need one client that abort by
// rollbacking to a stable signaling state where the other is
// continuing the process. The client that is polite is the one that
// will rollback.
const isPolite =
('' + notification.fromClientId).localeCompare('' + this._currentClientId) === 1;
if (debugShowLog)
console.log(
`%cisPolite: %c${isPolite}`,
'background: deepskyblue;',
`background:${isPolite ? 'green' : 'red'}`,
);
const isOfferRacing =
description.type === 'offer' &&
(clientInfos.makingOffer || pc.signalingState !== 'stable');
// If there is a racing conditing with the signaling offer and the
// client is impolite, we must not process this offer and wait for
// the answer for the signaling process to continue.
if (isOfferRacing && !isPolite) {
if (debugShowLog)
console.log(
`%creturn because isOfferRacing && !isPolite. pc.signalingState: ${pc.signalingState}`,
'background: red;',
);
return;
}
if (debugShowLog) console.log(`%cisOfferRacing: ${isOfferRacing}`, 'background: red;');
try {
if (isOfferRacing) {
if (debugShowLog)
console.log(`%c SETREMOTEDESCRIPTION 1`, 'background: navy; color:white;');
await Promise.all([
pc.setLocalDescription({ type: 'rollback' }),
pc.setRemoteDescription(description),
]);
} else {
if (debugShowLog)
console.log(`%c SETREMOTEDESCRIPTION 2`, 'background: navy; color:white;');
await pc.setRemoteDescription(description);
}
} catch (e) {
if (e instanceof DOMException && e.name === 'InvalidStateError') {
console.error(e);
return;
} else {
throw e;
}
}
if (clientInfos.iceCandidateBuffer.length) {
for (const candidate of clientInfos.iceCandidateBuffer) {
await this._addIceCandidate(clientInfos, candidate);
}
clientInfos.iceCandidateBuffer.splice(0);
}
if (description.type === 'offer') {
const answerDescription = await pc.createAnswer();
try {
await pc.setLocalDescription(answerDescription);
} catch (e) {
if (e instanceof DOMException && e.name === 'InvalidStateError') {
console.error(e);
return;
} else {
throw e;
}
}
this.notifyClient(
notification.fromClientId,
'rtc_signal_description',
pc.localDescription,
);
}
},
};
export class PeerToPeer {
constructor(options) {
this.options = options;
this._currentClientId = this.options.currentClientId;
if (debugShowLog)
console.log(
`%c currentClientId:${this._currentClientId}`,
'background: blue; color: white;',
);
// clientId -> ClientInfos
this.clientsInfos = {};
this._lastRequestId = -1;
this._pendingRequestResolver = {};
this._stopped = false;
}
stop() {
this.closeAllConnections();
this._stopped = true;
}
getConnectedClientIds() {
return Object.entries(this.clientsInfos)
.filter(
([id, infos]) =>
infos.peerConnection && infos.peerConnection.iceConnectionState === 'connected' &&
infos.dataChannel && infos.dataChannel.readyState === 'open',
)
.map(([id]) => id);
}
removeClient(clientId) {
if (debugShowLog) console.log(`%c REMOVE CLIENT ${clientId}`, 'background: chocolate;');
this.notifySelf('ptp_remove', clientId);
const clientInfos = this.clientsInfos[clientId];
if (!clientInfos) return;
clearTimeout(clientInfos.fallbackTimeout);
clearTimeout(clientInfos.zombieTimeout);
clientInfos.dataChannel && clientInfos.dataChannel.close();
clientInfos.peerConnection && clientInfos.peerConnection.close();
delete this.clientsInfos[clientId];
}
closeAllConnections() {
for (const clientId of Object.keys(this.clientsInfos)) {
this.notifyAllClients('ptp_disconnect');
this.removeClient(clientId);
}
}
async notifyAllClients(notificationName, notificationPayload, { transport = 'server' } = {}) {
if (this._stopped) {
return;
}
const transportPayload = {
fromClientId: this._currentClientId,
notificationName,
notificationPayload,
};
if (transport === 'server') {
await this.options.broadcastAll(transportPayload);
} else if (transport === 'rtc') {
for (const cliendId of Object.keys(this.clientsInfos)) {
this._channelNotify(cliendId, transportPayload);
}
} else {
throw new Error(
`Transport "${transport}" is not supported. Use "server" or "rtc" transport.`,
);
}
}
notifyClient(clientId, notificationName, notificationPayload, { transport = 'server' } = {}) {
if (this._stopped) {
return;
}
if (debugShowNotifications) {
if (notificationName === 'ptp_request_result') {
console.log(
`%c${Date.now()} - REQUEST RESULT SEND: %c${transport}:${
notificationPayload.requestId
}:${this._currentClientId.slice('-5')}:${clientId.slice('-5')}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
} else if (notificationName === 'ptp_request') {
console.log(
`%c${Date.now()} - REQUEST SEND: %c${transport}:${
notificationPayload.requestName
}|${notificationPayload.requestId}:${this._currentClientId.slice(
'-5',
)}:${clientId.slice('-5')}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
} else {
console.log(
`%c${Date.now()} - NOTIFICATION SEND: %c${transport}:${notificationName}:${this._currentClientId.slice(
'-5',
)}:${clientId.slice('-5')}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
}
}
const transportPayload = {
fromClientId: this._currentClientId,
toClientId: clientId,
notificationName,
notificationPayload,
};
if (transport === 'server') {
this.options.broadcastAll(transportPayload);
} else if (transport === 'rtc') {
this._channelNotify(clientId, transportPayload);
} else {
throw new Error(
`Transport "${transport}" is not supported. Use "server" or "rtc" transport.`,
);
}
}
notifySelf(notificationName, notificationPayload) {
if (this._stopped) {
return;
}
return this.handleNotification({ notificationName, notificationPayload });
}
handleNotification(notification) {
if (this._stopped) {
return;
}
const isInternalNotification =
typeof notification.fromClientId === 'undefined' &&
typeof notification.toClientId === 'undefined';
if (
isInternalNotification ||
(notification.fromClientId !== this._currentClientId && !notification.toClientId) ||
notification.toClientId === this._currentClientId
) {
if (debugShowNotifications) {
if (notification.notificationName === 'ptp_request_result') {
console.log(
`%c${Date.now()} - REQUEST RESULT RECEIVE: %c${
notification.notificationPayload.requestId
}:${notification.fromClientId.slice('-5')}:${notification.toClientId.slice(
'-5',
)}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
} else if (notification.notificationName === 'ptp_request') {
console.log(
`%c${Date.now()} - REQUEST RECEIVE: %c${
notification.notificationPayload.requestName
}|${
notification.notificationPayload.requestId
}:${notification.fromClientId.slice('-5')}:${notification.toClientId.slice(
'-5',
)}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
} else {
console.log(
`%c${Date.now()} - NOTIFICATION RECEIVE: %c${
notification.notificationName
}:${notification.fromClientId}:${notification.toClientId}`,
'color: #aaa;font-weight:bold;',
'color: #aaa;font-weight:normal',
);
}
}
const baseMethod = baseNotificationMethods[notification.notificationName];
if (baseMethod) {
return baseMethod.call(this, notification);
}
if (this.options.onNotification) {
return this.options.onNotification(notification);
}
}
}
requestClient(clientId, requestName, requestPayload, { transport = 'server' } = {}) {
if (this._stopped) {
return;
}
return new Promise((resolve, reject) => {
const requestId = this._getRequestId();
const abort = (reason) => {
clearTimeout(rejectTimeout);
delete this._pendingRequestResolver[requestId];
reject(new RequestError(reason || 'Request was aborted.'));
};
const rejectTimeout = setTimeout(
() => abort('Request took too long (more than 10 seconds).'),
10000
);
this._pendingRequestResolver[requestId] = {
resolve,
rejectTimeout,
abort,
};
this.notifyClient(
clientId,
'ptp_request',
{
requestId,
requestName,
requestPayload,
requestTransport: transport,
},
{ transport },
);
});
}
abortCurrentRequests() {
for (const { abort } of Object.values(this._pendingRequestResolver)) {
abort();
}
}
_createClient(clientId, { makeOffer = true } = {}) {
if (this._stopped) {
return;
}
if (debugShowLog) console.log('CREATE CONNECTION with client id:', clientId);
this.clientsInfos[clientId] = {
makingOffer: false,
iceCandidateBuffer: [],
backoffFactor: 0,
};
if (!navigator.onLine) {
return this.clientsInfos[clientId];
}
const pc = new RTCPeerConnection(this.options.peerConnectionConfig);
if (makeOffer) {
pc.onnegotiationneeded = async () => {
if (debugShowLog)
console.log(
`%c NEGONATION NEEDED: ${pc.connectionState}`,
'background: deeppink;',
);
try {
this.clientsInfos[clientId].makingOffer = true;
if (debugShowLog)
console.log(
`%ccreating and sending an offer`,
'background: darkmagenta; color: white;',
);
const offer = await pc.createOffer();
// Avoid race condition.
if (pc.signalingState !== 'stable') {
return;
}
await pc.setLocalDescription(offer);
this.notifyClient(clientId, 'rtc_signal_description', pc.localDescription);
} catch (err) {
console.error(err);
} finally {
this.clientsInfos[clientId].makingOffer = false;
}
};
}
pc.onicecandidate = async event => {
if (event.candidate) {
this.notifyClient(clientId, 'rtc_signal_icecandidate', event.candidate);
}
};
pc.oniceconnectionstatechange = async () => {
if (debugShowLog) console.log('ICE STATE UPDATE: ' + pc.iceConnectionState);
switch (pc.iceConnectionState) {
case 'failed':
case 'closed':
this.removeClient(clientId);
break;
case 'disconnected':
if (navigator.onLine) {
await this._recoverConnection(clientId, {
delay: 3000,
reason: 'ice connection disconnected',
});
}
break;
case 'connected':
this.clientsInfos[clientId].backoffFactor = 0;
break;
}
};
// This event does not work in FF. Let's try with oniceconnectionstatechange if it is sufficient.
pc.onconnectionstatechange = async () => {
if (debugShowLog) console.log('CONNECTION STATE UPDATE:' + pc.connectionState);
switch (pc.connectionState) {
case 'failed':
case 'closed':
this.removeClient(clientId);
break;
case 'disconnected':
if (navigator.onLine) {
await this._recoverConnection(clientId, {
delay: 3000,
reason: 'connection disconnected',
});
}
break;
case 'connected':
case 'completed':
this.clientsInfos[clientId].backoffFactor = 0;
break;
}
};
pc.onicecandidateerror = async error => {
if (debugShowLog) {
console.groupCollapsed('=== ERROR: onIceCandidate ===');
console.log(
'connectionState: ' +
pc.connectionState +
' - iceState: ' +
pc.iceConnectionState,
);
console.trace(error);
console.groupEnd();
}
this._recoverConnection(clientId, { delay: 3000, reason: 'ice candidate error' });
};
const dataChannel = pc.createDataChannel('notifications', { negotiated: true, id: 1 });
let message = [];
dataChannel.onmessage = event => {
if (event.data !== '-') {
message.push(event.data);
} else {
this.handleNotification(JSON.parse(message.join('')));
message = [];
}
};
dataChannel.onopen = event => {
this.notifySelf('rtc_data_channel_open', {
connectionClientId: clientId,
});
};
this.clientsInfos[clientId].peerConnection = pc;
this.clientsInfos[clientId].dataChannel = dataChannel;
return this.clientsInfos[clientId];
}
async _addIceCandidate(clientInfos, candidate) {
const rtcIceCandidate = new RTCIceCandidate(candidate);
try {
await clientInfos.peerConnection.addIceCandidate(rtcIceCandidate);
} catch (error) {
// Ignored.
console.groupCollapsed('=== ERROR: ADD ICE CANDIDATE ===');
console.trace(error);
console.groupEnd();
}
}
_channelNotify(clientId, transportPayload) {
if (this._stopped) {
return;
}
const clientInfo = this.clientsInfos[clientId];
const dataChannel = clientInfo && clientInfo.dataChannel;
if (!dataChannel || dataChannel.readyState !== 'open') {
if (clientInfo && !clientInfo.zombieTimeout) {
if (debugShowLog) console.warn(
`Impossible to communicate with client ${clientId}. The connection will be killed in 10 seconds if the datachannel state has not changed.`,
);
this._killPotentialZombie(clientId);
}
} else {
const str = JSON.stringify(transportPayload);
const size = str.length;
const maxStringLength = 5000;
let from = 0;
let to = maxStringLength;
while (from < size) {
dataChannel.send(str.slice(from, to));
from = to;
to = to += maxStringLength;
}
dataChannel.send('-');
}
}
_getRequestId() {
this._lastRequestId++;
return this._lastRequestId;
}
async _onRequest(fromClientId, requestId, requestName, requestPayload, requestTransport) {
if (this._stopped) {
return;
}
const requestFunction = this.options.onRequest && this.options.onRequest[requestName];
const result = await requestFunction({
fromClientId,
requestId,
requestName,
requestPayload,
});
this.notifyClient(
fromClientId,
'ptp_request_result',
{ requestId, result },
{ transport: requestTransport },
);
}
/**
* Attempts a connection recovery by updating the tracks, which will start a new transaction:
* negotiationneeded -> offer -> answer -> ...
*
* @private
* @param {Object} [param1]
* @param {number} [param1.delay] in ms
* @param {string} [param1.reason]
*/
_recoverConnection(clientId, { delay = 0, reason = '' } = {}) {
if (this._stopped) {
this.removeClient(clientId);
return;
}
const clientInfos = this.clientsInfos[clientId];
if (!clientInfos || clientInfos.fallbackTimeout) return;
const backoffFactor = this.clientsInfos[clientId].backoffFactor;
const backoffDelay = delay * Math.pow(2, backoffFactor);
// Stop trying to recover the connection after 10 attempts.
if (backoffFactor > 10) {
if (debugShowLog) {
console.log(
`%c STOP RTC RECOVERY: impossible to connect to client ${clientId}: ${reason}`,
'background: darkred; color: white;',
);
}
return;
}
clientInfos.fallbackTimeout = setTimeout(async () => {
clientInfos.fallbackTimeout = undefined;
const pc = clientInfos.peerConnection;
if (!pc || pc.iceConnectionState === 'connected') {
return;
}
if (['connected', 'closed'].includes(pc.connectionState)) {
return;
}
// hard reset: recreating a RTCPeerConnection
if (debugShowLog)
console.log(
`%c RTC RECOVERY: calling back client ${clientId} to salvage the connection ${pc.iceConnectionState} after ${backoffDelay}ms, reason: ${reason}`,
'background: darkorange; color: white;',
);
this.removeClient(clientId);
const newClientInfos = this._createClient(clientId);
newClientInfos.backoffFactor = backoffFactor + 1;
}, backoffDelay);
}
// todo: do we try to salvage the connection after killing the zombie ?
// Maybe the salvage should be done when the connection is dropped.
_killPotentialZombie(clientId) {
if (this._stopped) {
this.removeClient(clientId);
return;
}
const clientInfos = this.clientsInfos[clientId];
if (!clientInfos || clientInfos.zombieTimeout) {
return;
}
// If there is no connection after 10 seconds, terminate.
clientInfos.zombieTimeout = setTimeout(() => {
if (clientInfos && clientInfos.dataChannel && clientInfos.dataChannel.readyState !== 'open') {
if (debugShowLog) console.log(`%c KILL ZOMBIE ${clientId}`, 'background: red;');
this.removeClient(clientId);
} else {
if (debugShowLog) console.log(`%c NOT A ZOMBIE ${clientId}`, 'background: green;');
}
}, 10000);
}
}
export class RequestError extends Error {
constructor(message) {
super(message);
this.name = "RequestError";
}
}

View file

@ -0,0 +1,92 @@
odoo.define('wysiwyg.widgets.Dialog', function (require) {
'use strict';
var config = require('web.config');
var core = require('web.core');
var Dialog = require('web.Dialog');
var _t = core._t;
/**
* Extend Dialog class to handle save/cancel of edition components.
*/
var WysiwygDialog = Dialog.extend({
/**
* @constructor
*/
init: function (parent, options) {
this.options = options || {};
if (config.device.isMobile) {
options.fullscreen = true;
}
this._super(parent, _.extend({}, {
buttons: [{
text: this.options.save_text || _t("Save"),
classes: 'btn-primary',
click: this.save,
},
{
text: _t("Discard"),
close: true,
}
]
}, this.options));
this.destroyAction = 'cancel';
this.closeOnSave = true;
var self = this;
this.opened(function () {
const selector = options.focusField
? `input[name=${options.focusField}]`
: 'input:visible:first';
self.$(selector).focus();
self.$el.closest('.modal').addClass('o_web_editor_dialog');
self.$el.closest('.modal').on('hidden.bs.modal', self.options.onClose);
});
this.on('closed', this, function () {
self._toggleFullScreen();
if (this.destroyAction) {
this.trigger(this.destroyAction, this.final_data || null);
}
});
},
/**
* Only use on config.device.isMobile, it's used by mass mailing to allow the dialog opening on fullscreen
* @private
*/
_toggleFullScreen: function() {
if (config.device.isMobile && !this.hasFullScreen) {
$('#iframe_target[isMobile="true"] #web_editor-top-edit .o_fullscreen').click();
}
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Called when the dialog is saved.
* Optionally closes the dialog.
*/
save: function () {
if (this.closeOnSave) {
this.destroyAction = "save";
this.close();
} else {
this.destroyAction = null;
this.trigger('save', this.final_data || null);
}
},
/**
* @override
* @returns {*}
*/
open: function() {
this.hasFullScreen = $(window.top.document.body).hasClass('o_field_widgetTextHtml_fullscreen');
this._toggleFullScreen();
return this._super.apply(this, arguments);
},
});
return WysiwygDialog;
});

View file

@ -0,0 +1,98 @@
odoo.define('wysiwyg.fonts', function (require) {
'use strict';
return {
/**
* Retrieves all the CSS rules which match the given parser (Regex).
*
* @param {Regex} filter
* @returns {Object[]} Array of CSS rules descriptions (objects). A rule is
* defined by 3 values: 'selector', 'css' and 'names'. 'selector'
* is a string which contains the whole selector, 'css' is a string
* which contains the css properties and 'names' is an array of the
* first captured groups for each selector part. E.g.: if the
* filter is set to match .fa-* rules and capture the icon names,
* the rule:
* '.fa-alias1::before, .fa-alias2::before { hello: world; }'
* will be retrieved as
* {
* selector: '.fa-alias1::before, .fa-alias2::before',
* css: 'hello: world;',
* names: ['.fa-alias1', '.fa-alias2'],
* }
*/
cacheCssSelectors: {},
getCssSelectors: function (filter) {
if (this.cacheCssSelectors[filter]) {
return this.cacheCssSelectors[filter];
}
this.cacheCssSelectors[filter] = [];
var sheets = document.styleSheets;
for (var i = 0; i < sheets.length; i++) {
var rules;
try {
// try...catch because Firefox not able to enumerate
// document.styleSheets[].cssRules[] for cross-domain
// stylesheets.
rules = sheets[i].rules || sheets[i].cssRules;
} catch (_e) {
continue;
}
if (!rules) {
continue;
}
for (var r = 0 ; r < rules.length ; r++) {
var selectorText = rules[r].selectorText;
if (!selectorText) {
continue;
}
var selectors = selectorText.split(/\s*,\s*/);
var data = null;
for (var s = 0; s < selectors.length; s++) {
var match = selectors[s].trim().match(filter);
if (!match) {
continue;
}
if (!data) {
data = {
selector: match[0],
css: rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, ''),
names: [match[1]]
};
} else {
data.selector += (', ' + match[0]);
data.names.push(match[1]);
}
}
if (data) {
this.cacheCssSelectors[filter].push(data);
}
}
}
return this.cacheCssSelectors[filter];
},
/**
* List of font icons to load by editor. The icons are displayed in the media
* editor and identified like font and image (can be colored, spinned, resized
* with fa classes).
* To add font, push a new object {base, parser}
*
* - base: class who appear on all fonts
* - parser: regular expression used to select all font in css stylesheets
*
* @type Array
*/
fontIcons: [{base: 'fa', parser: /\.(fa-(?:\w|-)+)::?before/i}],
/**
* Searches the fonts described by the @see fontIcons variable.
*/
computeFonts: _.once(function () {
var self = this;
_.each(this.fontIcons, function (data) {
data.cssData = self.getCssSelectors(data.parser);
data.alias = _.flatten(_.map(data.cssData, _.property('names')));
});
}),
};
});

View file

@ -0,0 +1,54 @@
/** @odoo-module **/
import { registry } from '@web/core/registry'
import { HotkeyCommandItem } from '@web/core/commands/default_providers'
import Wysiwyg from 'web_editor.wysiwyg'
// The only way to know if an editor is under focus when the command palette
// open is to look if there in a selection within a wysiwyg editor in the page.
// As the selection changes after the command palette is open, we need to save
// the action (that have the range and editor in the closure) as well as the
// label to use.
let sessionActionLabel = [];
const commandProviderRegistry = registry.category("command_provider");
commandProviderRegistry.add("link dialog", {
async provide(env, { sessionId }) {
let [lastSessionId, action, label] = sessionActionLabel;
if (lastSessionId !== sessionId) {
const wysiwyg = [...Wysiwyg.activeWysiwygs].find((wysiwyg) => {
return wysiwyg.isSelectionInEditable();
});
const selection = wysiwyg && wysiwyg.odooEditor && wysiwyg.odooEditor.document.getSelection();
const range = selection && selection.rangeCount && selection.getRangeAt(0);
if (range) {
label = !wysiwyg.getInSelection('a') ? 'Create link' : 'Edit link';
action = () => {
const selection = wysiwyg.odooEditor.document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
wysiwyg.openLinkToolsFromSelection();
}
sessionActionLabel = [sessionId, action, label]
} else {
sessionActionLabel = [sessionId];
}
}
[lastSessionId, action, label] = sessionActionLabel;
if (action) {
return [
{
Component: HotkeyCommandItem,
action: action,
category: 'shortcut_conflict',
name: label,
props: { hotkey: 'control+k' },
}
]
} else {
return [];
}
},
});

View file

@ -0,0 +1,51 @@
odoo.define('wysiwyg.widgets.AltDialog', function (require) {
'use strict';
var core = require('web.core');
var Dialog = require('wysiwyg.widgets.Dialog');
var _t = core._t;
/**
* Let users change the alt & title of a media.
*/
var AltDialog = Dialog.extend({
template: 'wysiwyg.widgets.alt',
/**
* @constructor
*/
init: function (parent, options, media) {
options = options || {};
this._super(parent, _.extend({}, {
title: _t("Change media description and tooltip")
}, options));
this.media = media;
var allEscQuots = /&quot;/g;
this.alt = ($(this.media).attr('alt') || "").replace(allEscQuots, '"');
var title = $(this.media).attr('title') || $(this.media).data('original-title') || "";
this.tag_title = (title).replace(allEscQuots, '"');
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
save: function () {
var alt = this.$('#alt').val();
var title = this.$('#title').val();
var allNonEscQuots = /"/g;
$(this.media).attr('alt', alt ? alt.replace(allNonEscQuots, "&quot;") : null)
.attr('title', title ? title.replace(allNonEscQuots, "&quot;") : null);
$(this.media).trigger('content_changed');
this.final_data = this.media;
return this._super.apply(this, arguments);
},
});
return AltDialog;
});

View file

@ -0,0 +1,288 @@
odoo.define('wysiwyg.widgets.ImageCropWidget', function (require) {
'use strict';
const core = require('web.core');
const Widget = require('web.Widget');
const {applyModifications, cropperDataFields, activateCropper, loadImage, loadImageInfo} = require('web_editor.image_processing');
const { Markup } = require('web.utils');
const { scrollTo } = require("web.dom");
const _t = core._t;
const ImageCropWidget = Widget.extend({
template: ['wysiwyg.widgets.crop'],
events: {
'click.crop_options [data-action]': '_onCropOptionClick',
// zoom event is triggered by the cropperjs library when the user zooms.
'zoom': '_onCropZoom',
},
/**
* @constructor
*/
init(parent, media, options = {}) {
this._super(...arguments);
this.media = media;
this.parent = parent;
this.$media = $(media);
// Needed for editors in iframes.
this.document = media.ownerDocument;
// key: ratio identifier, label: displayed to user, value: used by cropper lib
this.aspectRatios = {
"0/0": {label: _t("Flexible"), value: 0},
"16/9": {label: "16:9", value: 16 / 9},
"4/3": {label: "4:3", value: 4 / 3},
"1/1": {label: "1:1", value: 1},
"2/3": {label: "2:3", value: 2 / 3},
};
const src = this.media.getAttribute('src');
const data = Object.assign({}, media.dataset);
this.initialSrc = src;
this.aspectRatio = data.aspectRatio || "0/0";
const mimetype = data.mimetype || src.endsWith('.png') ? 'image/png' : 'image/jpeg';
this.mimetype = options.mimetype || mimetype;
},
/**
* @override
*/
async willStart() {
await this._super.apply(this, arguments);
await loadImageInfo(this.media, this._rpc.bind(this));
const isIllustration = /^\/web_editor\/shape\/illustration\//.test(this.media.dataset.originalSrc);
await this._scrollToInvisibleImage();
if (this.media.dataset.originalSrc && !isIllustration) {
this.originalSrc = this.media.dataset.originalSrc;
this.originalId = this.media.dataset.originalId;
const sel = this.parent.odooEditor && this.parent.odooEditor.document.getSelection();
sel && sel.removeAllRanges();
return;
}
// Couldn't find an attachment: not croppable.
this.uncroppable = true;
},
/**
* @override
*/
async start() {
if (this.uncroppable) {
this.displayNotification({
type: 'warning',
title: _t("This image is an external image"),
message: Markup(_t("This type of image is not supported for cropping.<br/>If you want to crop it, please first download it from the original source and upload it in Odoo.")),
});
return this.destroy();
}
const _super = this._super.bind(this);
const $cropperWrapper = this.$('.o_we_cropper_wrapper');
// Replacing the src with the original's so that the layout is correct.
await loadImage(this.originalSrc, this.media);
this.$cropperImage = this.$('.o_we_cropper_img');
const cropperImage = this.$cropperImage[0];
[cropperImage.style.width, cropperImage.style.height] = [this.$media.width() + 'px', this.$media.height() + 'px'];
// Overlaying the cropper image over the real image
const offset = this.$media.offset();
offset.left += parseInt(this.$media.css('padding-left'));
offset.top += parseInt(this.$media.css('padding-right'));
$cropperWrapper.offset(offset);
await loadImage(this.originalSrc, cropperImage);
await activateCropper(cropperImage, this.aspectRatios[this.aspectRatio].value, this.media.dataset);
this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
this._onDocumentKeydown = this._onDocumentKeydown.bind(this);
// We use capture so that the handler is called before other editor handlers
// like save, such that we can restore the src before a save.
// We need to add event listeners to the owner document of the widget.
this.el.ownerDocument.addEventListener('mousedown', this._onDocumentMousedown, {capture: true});
this.el.ownerDocument.addEventListener('keydown', this._onDocumentKeydown, {capture: true});
return _super(...arguments);
},
/**
* @override
*/
destroy() {
if (this.$cropperImage) {
this.$cropperImage.cropper('destroy');
this.el.ownerDocument.removeEventListener('mousedown', this._onDocumentMousedown, {capture: true});
this.el.ownerDocument.removeEventListener('keydown', this._onDocumentKeydown, {capture: true});
}
this.media.setAttribute('src', this.initialSrc);
this.$media.trigger('image_cropper_destroyed');
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Resets the crop
*/
async reset() {
if (this.$cropperImage) {
this.$cropperImage.cropper('reset');
if (this.aspectRatio !== '0/0') {
this.aspectRatio = '0/0';
this.$cropperImage.cropper('setAspectRatio', this.aspectRatios[this.aspectRatio].value);
}
await this._save(false);
}
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Updates the DOM image with cropped data and associates required
* information for a potential future save (where required cropped data
* attachments will be created).
*
* @private
* @param {boolean} [cropped=true]
*/
async _save(cropped = true) {
// Mark the media for later creation of cropped attachment
this.media.classList.add('o_modified_image_to_save');
[...cropperDataFields, 'aspectRatio'].forEach(attr => {
delete this.media.dataset[attr];
const value = this._getAttributeValue(attr);
if (value) {
this.media.dataset[attr] = value;
}
});
delete this.media.dataset.resizeWidth;
this.initialSrc = await applyModifications(this.media, {forceModification: true, mimetype: this.mimetype});
this.media.classList.toggle('o_we_image_cropped', cropped);
this.$media.trigger('image_cropped');
this.destroy();
},
/**
* Returns an attribute's value for saving.
*
* @private
*/
_getAttributeValue(attr) {
if (cropperDataFields.includes(attr)) {
return this.$cropperImage.cropper('getData')[attr];
}
return this[attr];
},
/**
* Resets the crop box to prevent it going outside the image.
*
* @private
*/
_resetCropBox() {
this.$cropperImage.cropper('clear');
this.$cropperImage.cropper('crop');
},
/**
* Make sure the targeted image is in the visible viewport before crop.
*
* @private
*/
async _scrollToInvisibleImage() {
const rect = this.media.getBoundingClientRect();
const viewportTop = this.document.documentElement.scrollTop || 0;
const viewportBottom = viewportTop + window.innerHeight;
const closestScrollable = el => {
if (!el) {
return null;
}
if (el.scrollHeight > el.clientHeight) {
return $(el);
} else {
return closestScrollable(el.parentElement);
}
}
// Give priority to the closest scrollable element (e.g. for images in
// HTML fields, the element to scroll is different from the document's
// scrolling element).
const $scrollable = closestScrollable(this.media);
// The image must be in a position that allows access to it and its crop
// options buttons. Otherwise, the crop widget container can be scrolled
// to allow editing.
if (rect.top < viewportTop || viewportBottom - rect.bottom < 100) {
await scrollTo(this.media, {
easing: "linear",
duration: 500,
...($scrollable && {$scrollable}),
});
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Called when a crop option is clicked -> change the crop area accordingly.
*
* @private
* @param {MouseEvent} ev
*/
_onCropOptionClick(ev) {
const {action, value, scaleDirection} = ev.currentTarget.dataset;
switch (action) {
case 'ratio':
this.$cropperImage.cropper('reset');
this.aspectRatio = value;
this.$cropperImage.cropper('setAspectRatio', this.aspectRatios[this.aspectRatio].value);
break;
case 'zoom':
case 'reset':
this.$cropperImage.cropper(action, value);
break;
case 'rotate':
this.$cropperImage.cropper(action, value);
break;
case 'flip': {
const amount = this.$cropperImage.cropper('getData')[scaleDirection] * -1;
return this.$cropperImage.cropper(scaleDirection, amount);
}
case 'apply':
return this._save();
case 'discard':
return this.destroy();
}
},
/**
* Discards crop if the user clicks outside of the widget.
*
* @private
* @param {MouseEvent} ev
*/
_onDocumentMousedown(ev) {
if (this.el.ownerDocument.body.contains(ev.target) && this.$(ev.target).length === 0) {
return this.destroy();
}
},
/**
* Resets the cropbox on zoom to prevent crop box overflowing.
*
* @private
*/
async _onCropZoom() {
// Wait for the zoom event to be fully processed before reseting.
await new Promise(res => setTimeout(res, 0));
this._resetCropBox();
},
/**
* Save crop if user hits enter.
* @private
* @param {KeyboardEvent} ev
*/
_onDocumentKeydown(ev) {
if(ev.key === 'Enter') {
return this._save();
}
}
});
return ImageCropWidget;
});

View file

@ -0,0 +1,630 @@
odoo.define('wysiwyg.widgets.Link', function (require) {
'use strict';
const core = require('web.core');
const OdooEditorLib = require('@web_editor/js/editor/odoo-editor/src/OdooEditor');
const Widget = require('web.Widget');
const {isColorGradient} = require('web_editor.utils');
const getDeepRange = OdooEditorLib.getDeepRange;
const getInSelection = OdooEditorLib.getInSelection;
const EMAIL_REGEX = OdooEditorLib.EMAIL_REGEX;
const _t = core._t;
/**
* Allows to customize link content and style.
*/
const Link = Widget.extend({
events: {
'input': '_onAnyChange',
'change': '_onAnyChange',
'input input[name="url"]': '__onURLInput',
'change input[name="url"]': '_onURLInputChange',
},
/**
* @constructor
* @param {Boolean} data.isButton - whether if the target is a button element.
*/
init: function (parent, options, editable, data, $button, link) {
this.options = options || {};
this._super(parent, _.extend({
title: _t("Link to"),
}, this.options));
this._setLinkContent = true;
this.data = data || {};
this.isButton = this.data.isButton;
this.$button = $button;
this.noFocusUrl = this.options.noFocusUrl;
this.data.className = this.data.className || "";
this.data.iniClassName = this.data.iniClassName || "";
this.needLabel = this.data.needLabel || false;
// Using explicit type 'link' to preserve style when the target is <button class="...btn-link"/>.
this.colorsData = [
{type: this.isButton ? 'link' : '', label: _t("Link"), btnPreview: 'link'},
{type: 'primary', label: _t("Primary"), btnPreview: 'primary'},
{type: 'secondary', label: _t("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.
];
// The classes in the following array should not be in editable areas
// but as there are still some (e.g. in the "newsletter block" snippet)
// we make sure the options system works with them.
this.toleratedClasses = ['btn-link', 'btn-success'];
this.editable = editable;
this.$editable = $(editable);
if (link) {
const range = document.createRange();
range.selectNodeContents(link);
this.data.range = range;
this.$link = $(link);
this.linkEl = link;
}
if (this.data.range) {
this.$link = this.$link || $(OdooEditorLib.getInSelection(this.editable.ownerDocument, 'a'));
this.linkEl = this.$link[0];
this.data.iniClassName = this.$link.attr('class') || '';
this.colorCombinationClass = false;
let $node = this.$link;
while ($node.length && !$node.is('body')) {
const className = $node.attr('class') || '';
const m = className.match(/\b(o_cc\d+)\b/g);
if (m) {
this.colorCombinationClass = m[0];
break;
}
$node = $node.parent();
}
const linkNode = this.linkEl || this.data.range.cloneContents();
const linkText = linkNode.innerText.replaceAll("\u200B", "");
this.data.content = linkText.replace(/[ \t\r\n]+/g, ' ');
this.data.originalText = this.data.content;
if (linkNode instanceof DocumentFragment) {
this.data.originalHTML = $('<fakeEl>').append(linkNode).html();
} else {
this.data.originalHTML = linkNode.innerHTML;
}
const imgEl = linkNode.querySelector('IMG');
if (imgEl) {
this.data.isImage = true;
this.needLabel = false;
}
this.data.url = this.$link.attr('href') || '';
} else {
this.data.content = this.data.content ? this.data.content.replace(/[ \t\r\n]+/g, ' ') : '';
}
if (!this.data.url) {
const urls = this.data.content.match(OdooEditorLib.URL_REGEX_WITH_INFOS);
if (urls) {
this.data.url = urls[0];
}
}
if (this.linkEl) {
this.data.isNewWindow = this.data.isNewWindow || this.linkEl.target === '_blank';
}
const classesToKeep = [
'text-wrap', 'text-nowrap', 'text-start', 'text-center', 'text-end',
'text-truncate',
];
const keptClasses = this.data.iniClassName.split(' ').filter(className => classesToKeep.includes(className));
const allBtnColorPrefixes = /(^|\s+)(bg|text|border)((-[a-z0-9_-]*)|\b)/gi;
const allBtnClassSuffixes = /(^|\s+)btn((-[a-z0-9_-]*)|\b)/gi;
const allBtnShapes = /\s*(rounded-circle|flat)\s*/gi;
this.data.className = this.data.iniClassName
.replace(allBtnColorPrefixes, ' ')
.replace(allBtnClassSuffixes, ' ')
.replace(allBtnShapes, ' ');
this.data.className += ' ' + keptClasses.join(' ');
// 'o_submit' class will force anchor to be handled as a button in linkdialog.
if (/(?:s_website_form_send|o_submit)/.test(this.data.className)) {
this.isButton = true;
}
this.renderingPromise = new Promise(resolve => this._renderingResolver = resolve);
},
/**
* @override
*/
start: async function () {
for (const option of this._getLinkOptions()) {
const $option = $(option);
const value = $option.is('input') ? $option.val() : $option.data('value') || option.getAttribute('value');
let active = false;
if (value) {
const subValues = value.split(',');
let subActive = true;
for (let subValue of subValues) {
const classPrefix = new RegExp('(^|btn-| |btn-outline-|btn-fill-)' + subValue);
subActive = subActive && classPrefix.test(this.data.iniClassName);
}
active = subActive;
} else {
active = !this.data.iniClassName
|| this.toleratedClasses.some(val => this.data.iniClassName.split(' ').includes(val))
|| !this.data.iniClassName.includes('btn-');
}
this._setSelectOption($option, active);
}
const _super = this._super.bind(this);
this._updateOptionsUI();
if (this.data.url) {
this._updateUrlInput(this.data.url);
}
if (!this.noFocusUrl) {
this.focusUrl();
}
return _super(...arguments);
},
/**
* @private
*/
async _widgetRenderAndInsert() {
const res = await this._super(...arguments);
// TODO find a better solution than this during the upcoming refactoring
// of the link tools / link dialog.
if (this._renderingResolver) {
this._renderingResolver();
}
return res;
},
/**
* @override
*/
destroy () {
if (this._savedURLInputOnDestroy) {
this._adaptPreview();
}
this._super(...arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Apply the new link to the DOM (via `this.$link`).
*
* @param {object} data
*/
applyLinkToDom: function (data) {
// Some mass mailing template use <a class="btn btn-link"> instead of just a simple <a>.
// And we need to keep the classes because the a.btn.btn-link have some special css rules.
// Same thing for the "btn-success" class, this class cannot be added
// by the options but we still have to ensure that it is not removed if
// it exists in a template (e.g. "Newsletter Block" snippet).
if (!data.classes.split(' ').includes('btn')) {
for (const linkClass of this.toleratedClasses) {
if (this.data.iniClassName && this.data.iniClassName.split(' ').includes(linkClass)) {
data.classes += " btn " + linkClass;
}
}
}
if (['btn-custom', 'btn-outline-custom', 'btn-fill-custom'].some(className =>
data.classes.includes(className)
)) {
this.$link.css('color', data.classes.includes(data.customTextColor) ? '' : data.customTextColor);
this.$link.css('background-color', data.classes.includes(data.customFill) || isColorGradient(data.customFill) ? '' : data.customFill);
this.$link.css('background-image', isColorGradient(data.customFill) ? data.customFill : '');
this.$link.css('border-width', data.customBorderWidth);
this.$link.css('border-style', data.customBorderStyle);
this.$link.css('border-color', data.customBorder);
} else {
this.$link.css('color', '');
this.$link.css('background-color', '');
this.$link.css('background-image', '');
this.$link.css('border-width', '');
this.$link.css('border-style', '');
this.$link.css('border-color', '');
}
const attrs = Object.assign({}, this.data.oldAttributes, {
href: data.url,
target: data.isNewWindow ? '_blank' : '',
});
if (typeof data.classes === "string") {
data.classes = data.classes.replace(/o_default_snippet_text/, '');
attrs.class = `${data.classes}`;
}
if (data.rel) {
attrs.rel = `${data.rel}`;
}
this.$link.attr(attrs);
if (!this.$link.attr('target')) {
this.$link[0].removeAttribute('target');
}
this._updateLinkContent(this.$link, data);
},
/**
* Focuses the url input.
*/
focusUrl() {
const urlInput = this.el.querySelector('input[name="url"]');
urlInput.focus();
urlInput.select();
},
/**
* Return the link element to edit. Create one from selection if none was
* present in selection.
*
* @param {Node} [options.containerNode]
* @param {Node} [options.startNode]
* @returns {Object}
*/
getOrCreateLink (options) {
Link.getOrCreateLink(options);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Abstract method: adapt the link to changes.
*
* @abstract
* @private
*/
_adaptPreview: function () {},
/**
* @private
*/
_correctLink: function (url) {
if (url.indexOf('tel:') === 0) {
url = url.replace(/^tel:([0-9]+)$/, 'tel://$1');
} else if (url && !url.startsWith('mailto:') && url.indexOf('://') === -1
&& url[0] !== '/' && url[0] !== '#' && url.slice(0, 2) !== '${') {
url = 'http://' + url;
}
return url;
},
/**
* Abstract method: return true if the URL should be stripped of its domain.
*
* @abstract
* @private
* @returns {boolean}
*/
_doStripDomain: function () {},
/**
* Get the link's data (url, content and styles).
*
* @private
* @returns {Object} {content: String, url: String, classes: String, isNewWindow: Boolean}
*/
_getData: function () {
var $url = this.$('input[name="url"]');
var url = $url.val();
var content = this.$('input[name="label"]').val() || url;
if (!this.isButton && $url.prop('required') && (!url || !$url[0].checkValidity())) {
return null;
}
const type = this._getLinkType();
const customTextColor = this._getLinkCustomTextColor();
const customFill = this._getLinkCustomFill();
const customBorder = this._getLinkCustomBorder();
const customBorderWidth = this._getLinkCustomBorderWidth();
const customBorderStyle = this._getLinkCustomBorderStyle();
const customClasses = this._getLinkCustomClasses();
const size = this._getLinkSize();
const shape = this._getLinkShape();
const shapes = shape ? shape.split(',') : [];
const style = ['outline', 'fill'].includes(shapes[0]) ? `${shapes[0]}-` : '';
const shapeClasses = shapes.slice(style ? 1 : 0).join(' ');
const classes = (this.data.className || '') +
(type ? (` btn btn-${style}${type}`) : '') +
(type === 'custom' ? customClasses : '') +
(type && shapeClasses ? (` ${shapeClasses}`) : '') +
(type && size ? (' btn-' + size) : '');
var isNewWindow = this._isNewWindow(url);
var doStripDomain = this._doStripDomain();
const emailMatch = url.match(EMAIL_REGEX);
if (emailMatch) {
url = emailMatch[1] ? emailMatch[0] : 'mailto:' + emailMatch[0];
} else if (url.indexOf(location.origin) === 0 && doStripDomain) {
url = url.slice(location.origin.length);
}
var allWhitespace = /\s+/gi;
var allStartAndEndSpace = /^\s+|\s+$/gi;
return {
content: content,
url: this._correctLink(url),
classes: classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, ''),
customTextColor: customTextColor,
customFill: customFill,
customBorder: customBorder,
customBorderWidth: customBorderWidth,
customBorderStyle: customBorderStyle,
oldAttributes: this.data.oldAttributes,
isNewWindow: isNewWindow,
doStripDomain: doStripDomain,
};
},
/**
* Return a list of all the descendants of a given element.
*
* @private
* @param {Node} rootNode
* @returns {Node[]}
*/
_getDescendants: function (rootNode) {
const nodes = [];
for (const node of rootNode.childNodes) {
nodes.push(node);
nodes.push(...this._getDescendants(node));
}
return nodes;
},
/**
* Abstract method: return a JQuery object containing the UI elements
* holding the "Open in new window" option's row of the link.
*
* @abstract
* @private
* @returns {JQuery}
*/
_getIsNewWindowFormRow() {},
/**
* Abstract method: return a JQuery object containing the UI elements
* holding the styling options of the link (eg: color, size, shape).
*
* @abstract
* @private
* @returns {JQuery}
*/
_getLinkOptions: function () {},
/**
* Abstract method: return the shape(s) to apply to the link (eg:
* "outline", "rounded-circle", "outline,rounded-circle").
*
* @abstract
* @private
* @returns {string}
*/
_getLinkShape: function () {},
/**
* Abstract method: return the size to apply to the link (eg:
* "sm", "lg").
*
* @private
* @returns {string}
*/
_getLinkSize: function () {},
/**
* Abstract method: return the type to apply to the link (eg:
* "primary", "secondary").
*
* @private
* @returns {string}
*/
_getLinkType: function () {},
/**
* Returns the custom text color for custom type.
*
* @abstract
* @private
* @returns {string}
*/
_getLinkCustomTextColor: function () {},
/**
* Returns the custom border color for custom type.
*
* @abstract
* @private
* @returns {string}
*/
_getLinkCustomBorder: function () {},
/**
* Returns the custom border width for custom type.
*
* @abstract
* @private
* @returns {string}
*/
_getLinkCustomBorderWidth: function () {},
/**
* Returns the custom border style for custom type.
*
* @abstract
* @private
* @returns {string}
*/
_getLinkCustomBorderStyle: function () {},
/**
* Returns the custom fill color for custom type.
*
* @abstract
* @private
* @returns {string}
*/
_getLinkCustomFill: function () {},
/**
* Returns the custom text, fill and border color classes for custom type.
*
* @abstract
* @private
* @returns {string}
*/
_getLinkCustomClasses: function () {},
/**
* Abstract method: return true if the link should open in a new window.
*
* @abstract
* @private
* @returns {boolean}
*/
_isNewWindow: function (url) {},
/**
* Abstract method: mark one or several options as active or inactive.
*
* @abstract
* @private
* @param {JQuery} $option
* @param {boolean} [active]
*/
_setSelectOption: function ($option, active) {},
/**
* Update the link content.
*
* @private
* @param {JQuery} $link
* @param {object} linkInfos
* @param {boolean} force
*/
_updateLinkContent($link, linkInfos, { force = false } = {}) {
if (force || (this._setLinkContent && (linkInfos.content !== this.data.originalText || linkInfos.url !== this.data.url))) {
if (linkInfos.content === this.data.originalText || this.data.isImage) {
$link.html(this.data.originalHTML);
} else if (linkInfos.content && linkInfos.content.length) {
$link.text(linkInfos.content);
} else {
$link.text(linkInfos.url);
}
}
},
/**
* @abstract
* @private
*/
_updateOptionsUI: function () {},
/**
* @private
* @param {String} url
*/
_updateUrlInput: function (url) {
if (!this.el) {
return;
}
const match = /mailto:(.+)/.exec(url);
this.el.querySelector('input[name="url"]').value = match ? match[1] : url;
this._onURLInput();
this._savedURLInputOnDestroy = false;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onAnyChange: function (e) {
if (!e.target.closest('input[type="text"]')) {
this._adaptPreview();
}
},
/**
* @todo Adapt in master: in stable _onURLInput was both used as an event
* handler responding to url input events + a private method called at the
* widget lifecycle start. Originally both points were to update the link
* tools/dialog UI. It was later wanted to actually update the DOM... but
* should only be done in event handler part.
*
* This allows to differentiate the event handler part. In master, we should
* take the opportunity to also update the `_updatePreview` concept which
* updates the "preview" of the original link dialog but actually updates
* the real DOM for the "new" link tools.
*
* @private
*/
__onURLInput: function () {
this._onURLInput(...arguments);
},
/**
* @private
*/
_onURLInput: function () {
this._savedURLInputOnDestroy = true;
var $linkUrlInput = this.$('#o_link_dialog_url_input');
let value = $linkUrlInput.val();
let isLink = !EMAIL_REGEX.test(value);
this._getIsNewWindowFormRow().toggleClass('d-none', !isLink);
this.$('.o_strip_domain').toggleClass('d-none', value.indexOf(window.location.origin) !== 0);
},
/**
* @private
*/
_onURLInputChange: function () {
this._adaptPreview();
this._savedURLInputOnDestroy = false;
},
});
/**
* Return the link element to edit. Create one from selection if none was
* present in selection.
*
* @param {Node} [options.containerNode]
* @param {Node} [options.startNode]
* @returns {Object}
*/
Link.getOrCreateLink = ({ containerNode, startNode } = {}) => {
if (startNode) {
if ($(startNode).is('a')) {
return { link: startNode, needLabel: false };
} else {
$(startNode).wrap('<a href="#"/>');
return { link: startNode.parentElement, needLabel: false };
}
}
const doc = containerNode && containerNode.ownerDocument || document;
let needLabel = false;
let link = getInSelection(doc, 'a');
const $link = $(link);
const range = getDeepRange(containerNode, {splitText: true, select: true, correctTripleClick: true});
if (!range) {
return {};
}
const isContained = containerNode.contains(range.startContainer) && containerNode.contains(range.endContainer);
if (link && (!$link.has(range.startContainer).length || !$link.has(range.endContainer).length)) {
// Expand the current link to include the whole selection.
let before = link.previousSibling;
while (before !== null && range.intersectsNode(before)) {
link.insertBefore(before, link.firstChild);
before = link.previousSibling;
}
let after = link.nextSibling;
while (after !== null && range.intersectsNode(after)) {
link.appendChild(after);
after = link.nextSibling;
}
} else if (!link && isContained) {
link = document.createElement('a');
if (range.collapsed) {
range.insertNode(link);
needLabel = true;
} else {
link.appendChild(range.extractContents());
range.insertNode(link);
}
}
return { link, needLabel };
};
return Link;
});

View file

@ -0,0 +1,231 @@
odoo.define('wysiwyg.widgets.LinkDialog', function (require) {
'use strict';
const Dialog = require('wysiwyg.widgets.Dialog');
const Link = require('wysiwyg.widgets.Link');
// This widget is there only to extend Link and be instantiated by LinkDialog.
const _DialogLinkWidget = Link.extend({
template: 'wysiwyg.widgets.link',
events: _.extend({}, Link.prototype.events || {}, {
'change [name="link_style_color"]': '_onTypeChange',
'input input[name="label"]': '_adaptPreview',
}),
/**
* @override
*/
start: function () {
this.buttonOptsCollapseEl = this.el.querySelector('#o_link_dialog_button_opts_collapse');
this.$styleInputs = this.$('input.link-style');
this.$styleInputs.prop('checked', false).filter('[value=""]').prop('checked', true);
if (this.data.isNewWindow) {
this.$('we-button.o_we_checkbox_wrapper').toggleClass('active', true);
}
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
save: function () {
var data = this._getData();
if (data === null) {
var $url = this.$('input[name="url"]');
$url.closest('.o_url_input').addClass('o_has_error').find('.form-control, .form-select').addClass('is-invalid');
$url.focus();
return Promise.reject();
}
this.data.content = data.content;
this.data.url = data.url;
var allWhitespace = /\s+/gi;
var allStartAndEndSpace = /^\s+|\s+$/gi;
var allBtnTypes = /(^|[ ])(btn-secondary|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi;
this.data.classes = data.classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, '');
if (data.classes.replace(allBtnTypes, ' ')) {
this.data.style = {
'background-color': '',
'color': '',
};
}
this.data.isNewWindow = data.isNewWindow;
this.final_data = this.data;
return Promise.resolve();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_adaptPreview: function () {
var data = this._getData();
if (data === null) {
return;
}
const attrs = {
target: '_blank',
href: data.url && data.url.length ? data.url : '#',
class: `${data.classes.replace(/float-\w+/, '')} o_btn_preview`,
};
const $linkPreview = this.$("#link-preview");
$linkPreview.attr(attrs);
this._updateLinkContent($linkPreview, data, { force: true });
},
/**
* @override
*/
_doStripDomain: function () {
return this.$('#o_link_dialog_url_strip_domain').prop('checked');
},
/**
* @override
*/
_getIsNewWindowFormRow() {
return this.$('input[name="is_new_window"]').closest('.row');
},
/**
* @override
*/
_getLinkOptions: function () {
const options = [
'input[name="link_style_color"]',
'select[name="link_style_size"] > option',
'select[name="link_style_shape"] > option',
];
return this.$(options.join(','));
},
/**
* @override
*/
_getLinkShape: function () {
return this.$('select[name="link_style_shape"]').val() || '';
},
/**
* @override
*/
_getLinkSize: function () {
return this.$('select[name="link_style_size"]').val() || '';
},
/**
* @override
*/
_getLinkType: function () {
return this.$('input[name="link_style_color"]:checked').val() || '';
},
/**
* @private
*/
_isFromAnotherHostName: function (url) {
if (url.includes(window.location.hostname)) {
return false;
}
try {
const Url = URL || window.URL || window.webkitURL;
const urlObj = url.startsWith('/') ? new Url(url, window.location.origin) : new Url(url);
return (urlObj.origin !== window.location.origin);
} catch (_ignored) {
return true;
}
},
/**
* @override
*/
_isNewWindow: function (url) {
if (this.options.forceNewWindow) {
return this._isFromAnotherHostName(url);
} else {
return this.$('input[name="is_new_window"]').prop('checked');
}
},
/**
* @override
*/
_setSelectOption: function ($option, active) {
if ($option.is("input")) {
$option.prop("checked", active);
} else if (active) {
$option.parent().find('option').removeAttr('selected').removeProp('selected');
$option.parent().val($option.val());
$option.attr('selected', 'selected').prop('selected', 'selected');
}
},
/**
* @override
*/
_updateOptionsUI: function () {
const el = this.el.querySelector('[name="link_style_color"]:checked');
$(this.buttonOptsCollapseEl).collapse(el && el.value ? 'show' : 'hide');
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onTypeChange() {
this._updateOptionsUI();
},
/**
* @override
*/
_onURLInput: function () {
this._super(...arguments);
this.$('#o_link_dialog_url_input').closest('.o_url_input').removeClass('o_has_error').find('.form-control, .form-select').removeClass('is-invalid');
this._adaptPreview();
},
});
/**
* Allows to customize link content and style.
*/
const LinkDialog = Dialog.extend({
init: function (parent, ...args) {
this._super(...arguments);
this.linkWidget = this.getLinkWidget(...args);
},
start: async function () {
const res = await this._super(...arguments);
await this.linkWidget.appendTo(this.$el);
return res;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Returns an instance of the widget that will be attached to the body of the
* link dialog. One may overwrite this function and return an instance of
* another widget to change the default logic.
* @param {...any} args
*/
getLinkWidget: function (...args) {
return new _DialogLinkWidget(this, ...args);
},
/**
* @override
*/
save: function () {
const _super = this._super.bind(this);
const saveArguments = arguments;
return this.linkWidget.save().then(() => {
this.final_data = this.linkWidget.final_data;
return _super(...saveArguments);
});
},
});
return LinkDialog;
});

View file

@ -0,0 +1,339 @@
/** @odoo-module **/
import Widget from 'web.Widget';
import {_t} from 'web.core';
import {DropPrevious} from 'web.concurrency';
import { ancestors } from '@web_editor/js/common/wysiwyg_utils';
const LinkPopoverWidget = Widget.extend({
template: 'wysiwyg.widgets.link.edit.tooltip',
events: {
'click .o_we_remove_link': '_onRemoveLinkClick',
'click .o_we_edit_link': '_onEditLinkClick',
},
/**
* @constructor
* @param {Element} target: target Element for which we display a popover
* @param {Wysiwyg} [option.wysiwyg]: The wysiwyg editor
*/
init(parent, target, options) {
this._super(...arguments);
this.options = options;
this.target = target;
this.$target = $(target);
this.container = this.options.container || this.target.ownerDocument.body;
this.href = this.$target.attr('href'); // for template
this._dp = new DropPrevious();
},
/**
* @override
* @todo replace this hack in master. This is required to not listen to the
* DOM mutation of adding this widget inside the DOM (which is probably not
* even needed in the first place).
*/
_widgetRenderAndInsert(insertCallback, ...rest) {
const patchedInsertCallback = (...args) => {
this.options.wysiwyg.odooEditor.observerUnactive();
const res = insertCallback(...args);
this.options.wysiwyg.odooEditor.observerActive();
return res;
};
return this._super(patchedInsertCallback, ...rest);
},
/**
*
* @override
*/
start() {
this.$urlLink = this.$('.o_we_url_link');
this.$previewFaviconImg = this.$('.o_we_preview_favicon img');
this.$previewFaviconFa = this.$('.o_we_preview_favicon .fa');
this.$copyLink = this.$('.o_we_copy_link');
this.$fullUrl = this.$('.o_we_full_url');
// Use the right ClipboardJS with respect to the prototype of this.el
// since, starting with Firefox 109, a widget element prototype that is
// adopted by an iframe will not be instanceof its original constructor.
// See: https://github.com/webcompat/web-bugs/issues/118350
const ClipboardJS =
this.el instanceof HTMLElement
? window.ClipboardJS
: this.el.ownerDocument.defaultView.ClipboardJS;
// Copy onclick handler
// ClipboardJS uses "instanceof" to verify the elements passed to its
// constructor. Unfortunately, when the element is within an iframe,
// instanceof is not behaving the same across all browsers.
const containerWindow = this.container.ownerDocument.defaultView;
let _ClipboardJS = ClipboardJS;
if (this.$copyLink[0] instanceof containerWindow.HTMLElement) {
_ClipboardJS = containerWindow.ClipboardJS;
}
const clipboard = new _ClipboardJS(
this.$copyLink[0],
{text: () => this.target.href} // Absolute href
);
clipboard.on('success', () => {
this.$copyLink.tooltip('hide');
this.displayNotification({
type: 'success',
message: _t("Link copied to clipboard."),
});
this.popover.hide();
});
// Init popover -> it is moved out of the link (and the savable area)
const tooltips = [];
let popoverShown = true;
this.options.wysiwyg.odooEditor.observerUnactive();
this.$target.popover({
html: true,
content: this.$el,
placement: 'bottom',
// We need the popover to:
// 1. Open when the link is clicked or double clicked
// 2. Remain open when the link is clicked again (which `trigger: 'click'` is not doing)
// 3. Remain open when the popover content is clicked..
// 4. ..except if it the click was on a button of the popover content
// 5. Close when the user click somewhere on the page (not being the link or the popover content)
trigger: 'manual',
boundary: 'viewport',
container: this.container,
})
.on('show.bs.popover.link_popover', () => {
this._loadAsyncLinkPreview();
popoverShown = true;
})
.on('hide.bs.popover.link_popover', () => {
popoverShown = false;
})
.on('hidden.bs.popover.link_popover', () => {
for (const tooltip of tooltips) {
tooltip.hide();
}
})
.on('inserted.bs.popover.link_popover', () => {
const popover = Popover.getInstance(this.target);
popover.tip.classList.add('o_edit_menu_popover');
})
.popover('show');
// Init popover inner tooltips (note that probably no need of observer
// unactive during this since out of the editable area but
// `this.container` is customizable so, not guaranteed). TODO improve.
this.$('[data-bs-toggle="tooltip"]').tooltip({
delay: 0,
placement: 'bottom',
container: this.container,
});
for (const el of this.$('[data-bs-toggle="tooltip"]').toArray()) {
tooltips.push(Tooltip.getOrCreateInstance(el));
}
this.options.wysiwyg.odooEditor.observerActive();
this.popover = Popover.getInstance(this.target);
this.$target.on('mousedown.link_popover', (e) => {
if (!popoverShown) {
this.$target.popover('show');
}
});
this.$target.on('href_changed.link_popover', (e) => {
// Do not change shown/hidden state.
if (popoverShown) {
this._loadAsyncLinkPreview();
}
});
const onClickDocument = (e) => {
if (popoverShown) {
const hierarchy = [e.target, ...ancestors(e.target)];
if (
!(
hierarchy.includes(this.$target[0]) ||
(hierarchy.includes(this.$el[0]) &&
!hierarchy.some(x => x.tagName && x.tagName === 'A' && (x === this.$urlLink[0] || x === this.$fullUrl[0])))
)
) {
// Note: For buttons of the popover, their listeners should
// handle the hide themselves to avoid race conditions.
this.popover.hide();
}
}
};
$(document).on('mouseup.link_popover', onClickDocument);
if (document !== this.options.wysiwyg.odooEditor.document) {
$(this.options.wysiwyg.odooEditor.document).on('mouseup.link_popover', onClickDocument);
}
// Update popover's content and position upon changes
// on the link's label or href.
this._observer = new MutationObserver(records => {
if (!popoverShown) {
return;
}
if (records.some(record => record.type === 'attributes')) {
this._loadAsyncLinkPreview();
}
this.$target.popover('update');
});
this._observer.observe(this.target, {
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ['href'],
});
return this._super(...arguments);
},
/**
*
* @override
*/
destroy() {
// FIXME those are never destroyed, so this could be a cause of memory
// leak. However, it is only one leak per click on a link during edit
// mode so this should not be a huge problem.
this.$target.off('.link_popover');
$(document).off('.link_popover');
$(this.options.wysiwyg.odooEditor.document).off('.link_popover');
this.$target.popover('dispose');
this._observer.disconnect();
return this._super(...arguments);
},
/**
* Hide the popover.
*/
hide() {
this.$target.popover('hide');
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Fetches and gets the link preview data (title, description..).
* For external URL, only the favicon will be loaded.
*
* @private
*/
async _loadAsyncLinkPreview() {
let url;
if (this.target.href === '') {
this._resetPreview('');
this.$previewFaviconFa.removeClass('fa-globe').addClass('fa-question-circle-o');
return;
}
try {
url = new URL(this.target.href); // relative to absolute
} catch (_e) {
// Invalid URL, might happen with editor unsuported protocol. eg type
// `geo:37.786971,-122.399677`, become `http://geo:37.786971,-122.399677`
this.displayNotification({
type: 'danger',
message: _t("This URL is invalid. Preview couldn't be updated."),
});
return;
}
this._resetPreview(url);
const protocol = url.protocol;
if (!protocol.startsWith('http')) {
const faMap = {'mailto:': 'fa-envelope-o', 'tel:': 'fa-phone'};
const icon = faMap[protocol];
if (icon) {
this.$previewFaviconFa.toggleClass(`fa-globe ${icon}`);
}
} else if (window.location.hostname !== url.hostname) {
// 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.$previewFaviconImg.attr({
'src': `https://www.google.com/s2/favicons?sz=16&domain=${encodeURIComponent(url)}`
}).removeClass('d-none');
this.$previewFaviconFa.addClass('d-none');
} else {
await this._dp.add($.get(this.target.href)).then(content => {
const parser = new window.DOMParser();
const doc = parser.parseFromString(content, "text/html");
// Get
const favicon = doc.querySelector("link[rel~='icon']");
const ogTitle = doc.querySelector("[property='og:title']");
const title = doc.querySelector("title");
// Set
if (favicon) {
this.$previewFaviconImg.attr({'src': favicon.href}).removeClass('d-none');
this.$previewFaviconFa.addClass('d-none');
}
if (ogTitle || title) {
this.$urlLink.text(ogTitle ? ogTitle.getAttribute('content') : title.text.trim());
}
this.$fullUrl.removeClass('d-none').addClass('o_we_webkit_box');
this.$target.popover('update');
});
}
},
/**
* Resets the preview elements visibility. Particularly useful when changing
* the link url from an internal to an external one and vice versa.
*
* @private
* @param {string} url
*/
_resetPreview(url) {
this.$previewFaviconImg.addClass('d-none');
this.$previewFaviconFa.removeClass('d-none fa-question-circle-o fa-envelope-o fa-phone').addClass('fa-globe');
this.$urlLink.add(this.$fullUrl).text(url || _t('No URL specified')).attr('href', url || null);
this.$fullUrl.addClass('d-none').removeClass('o_we_webkit_box');
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Opens the Link Dialog.
*
* TODO The editor instance should be reached a proper way
*
* @private
* @param {Event} ev
*/
_onEditLinkClick(ev) {
ev.preventDefault();
this.options.wysiwyg.toggleLinkTools({
forceOpen: true,
link: this.$target[0],
});
ev.stopImmediatePropagation();
this.popover.hide();
},
/**
* Removes the link/anchor.
*
* @private
* @param {Event} ev
*/
_onRemoveLinkClick(ev) {
ev.preventDefault();
this.options.wysiwyg.removeLink();
ev.stopImmediatePropagation();
this.popover.hide();
},
});
LinkPopoverWidget.createFor = async function (parent, targetEl, options) {
const noLinkPopoverClass = ".o_no_link_popover, .carousel-control-prev, .carousel-control-next, .dropdown-toggle";
// Target might already have a popover, eg cart icon in navbar
const alreadyPopover = $(targetEl).data('bs.popover');
if (alreadyPopover || $(targetEl).is(noLinkPopoverClass) || !!$(targetEl).parents(noLinkPopoverClass).length) {
return null;
}
const popoverWidget = new this(parent, targetEl, options);
await popoverWidget.appendTo(targetEl);
return popoverWidget;
};
export default LinkPopoverWidget;

View file

@ -0,0 +1,522 @@
odoo.define('wysiwyg.widgets.LinkTools', function (require) {
'use strict';
const Link = require('wysiwyg.widgets.Link');
const {ColorPaletteWidget} = require('web_editor.ColorPalette');
const {ColorpickerWidget} = require('web.Colorpicker');
const {
computeColorClasses,
getCSSVariableValue,
getColorClass,
getNumericAndUnit,
isColorGradient,
} = require('web_editor.utils');
/**
* Allows to customize link content and style.
*/
const LinkTools = Link.extend({
template: 'wysiwyg.widgets.linkTools',
events: _.extend({}, Link.prototype.events, {
'click we-select we-button': '_onPickSelectOption',
'click we-checkbox': '_onClickCheckbox',
'change .link-custom-color-border input': '_onChangeCustomBorderWidth',
'keypress .link-custom-color-border input': '_onKeyPressCustomBorderWidth',
'click we-select [name="link_border_style"] we-button': '_onBorderStyleSelectOption',
}),
/**
* @override
*/
init: function (parent, options, editable, data, $button, link) {
this._link = link;
this._observer = new MutationObserver(records => {
let hrefChanged = false;
for (const record of records) {
if (record.type === 'attributes') {
hrefChanged = true;
} else {
this._setLinkContent = false;
}
}
if (hrefChanged) {
this._updateUrlInput(this._link.getAttribute('href') || '');
}
});
this._observerOptions = {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ['href'],
};
this._observer.observe(this._link, this._observerOptions);
this._super(parent, options, editable, data, $button, this._link);
// Keep track of each selected custom color and colorpicker.
this.customColors = {};
this.colorpickers = {};
// TODO remove me in master: we still save the promises indicating when
// each colorpicker is fully instantiated but we now make sure to never
// use them while they are not, without this.
this.colorpickersPromises = {};
this.COLORPICKER_CSS_PROPERTIES = ['color', 'background-color', 'border-color'];
this.PREFIXES = {
'color': 'text-',
'background-color': 'bg-',
};
},
/**
* @override
*/
willStart: async function () {
const _super = this._super.bind(this);
await Promise.all(this.COLORPICKER_CSS_PROPERTIES.map(cssProperty => this._addColorPicker(cssProperty)));
return _super(...arguments);
},
/**
* @override
*/
start: async function () {
this._addHintClasses();
const ret = await this._super(...arguments);
const link = this.$link[0];
const colorpickerLocations = {
'color': '.link-custom-color-text .dropdown-menu',
'background-color': '.link-custom-color-fill .dropdown-menu',
'border-color': '.link-custom-color-border .o_we_so_color_palette .dropdown-menu',
};
for (const cssProperty of this.COLORPICKER_CSS_PROPERTIES) {
// Colorpickers were created into fragments before any UI or event
// handler of this main widget was built. This just moves those
// colorpickers at their rightful position, synchronously.
const locationEl = this.el.querySelector(colorpickerLocations[cssProperty]);
this.colorpickers[cssProperty].$el.appendTo(locationEl);
}
const customStyleProps = ['color', 'background-color', 'background-image', 'border-width', 'border-style', 'border-color'];
if (customStyleProps.some(s => link.style[s])) {
// Force custom style if style exists on the link.
const customOption = this.el.querySelector('[name="link_style_color"] we-button[data-value="custom"]');
this._setSelectOption($(customOption), true);
this._updateOptionsUI();
}
if (!link.href && this.data.url) {
// Link URL was deduced from label. Apply changes to DOM.
this.__onURLInput();
}
return ret;
},
destroy: function () {
if (!this.el) {
return this._super(...arguments);
}
const $contents = this.$link.contents();
if (this.shouldUnlink()) {
$contents.unwrap();
}
this._observer.disconnect();
this._super(...arguments);
this._removeHintClasses();
},
shouldUnlink: function () {
return !this.$link.attr('href') && !this.colorCombinationClass
},
applyLinkToDom() {
this._observer.disconnect();
this._removeHintClasses();
this._super(...arguments);
this.options.wysiwyg.odooEditor.historyStep();
this._addHintClasses();
this._observer.observe(this._link, this._observerOptions);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
focusUrl() {
this.el.scrollIntoView();
this._super();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_adaptPreview: function () {
var data = this._getData();
if (data === null) {
return;
}
this.applyLinkToDom(data);
},
/**
* @override
*/
_doStripDomain: function () {
return this.$('we-checkbox[name="do_strip_domain"]').closest('we-button.o_we_checkbox_wrapper').hasClass('active');
},
/**
* @override
*/
_getIsNewWindowFormRow() {
return this.$('we-checkbox[name="is_new_window"]').closest('we-row');
},
/**
* @override
*/
_getLinkOptions: function () {
const options = [
'we-selection-items[name="link_style_color"] > we-button',
'we-selection-items[name="link_style_size"] > we-button',
'we-selection-items[name="link_style_shape"] > we-button',
];
return this.$(options.join(','));
},
/**
* @override
*/
_getLinkShape: function () {
return this.$('we-selection-items[name="link_style_shape"] we-button.active').data('value') || '';
},
/**
* @override
*/
_getLinkSize: function () {
return this.$('we-selection-items[name="link_style_size"] we-button.active').data('value') || '';
},
/**
* @override
*/
_getLinkType: function () {
return this.$('we-selection-items[name="link_style_color"] we-button.active').data('value') || '';
},
/**
* @override
*/
_getLinkCustomTextColor: function () {
return this.customColors['color'];
},
/**
* @override
*/
_getLinkCustomBorder: function () {
return this.customColors['border-color'];
},
/**
* @override
*/
_getLinkCustomBorderWidth: function () {
return this.$('.link-custom-color-border input').val() || '';
},
/**
* @override
*/
_getLinkCustomBorderStyle: function () {
return this.$('.link-custom-color-border we-button.active').data('value') || '';
},
/**
* @override
*/
_getLinkCustomFill: function () {
return this.customColors['background-color'];
},
/**
* @override
*/
_getLinkCustomClasses: function () {
let textClass = this.customColors['color'];
const colorPickerFg = this.colorpickers['color'];
if (
!textClass ||
!colorPickerFg ||
!computeColorClasses(colorPickerFg.getColorNames(), 'text-').includes(textClass)
) {
textClass = '';
}
let fillClass = this.customColors['background-color'];
const colorPickerBg = this.colorpickers['background-color'];
if (
!fillClass ||
!colorPickerBg ||
!computeColorClasses(colorPickerBg.getColorNames(), 'bg-').includes(fillClass)
) {
fillClass = '';
}
return ` ${textClass} ${fillClass}`;
},
/**
* @override
*/
_isNewWindow: function () {
return this.$('we-checkbox[name="is_new_window"]').closest('we-button.o_we_checkbox_wrapper').hasClass('active');
},
/**
* @override
*/
_setSelectOption: function ($option, active) {
$option.toggleClass('active', active);
if (active) {
$option.closest('we-select').find('we-toggler').text($option.text());
// ensure only one option is active in the dropdown
$option.siblings('we-button').removeClass("active");
}
},
/**
* @override
*/
_updateOptionsUI: function () {
const el = this.el.querySelector('[name="link_style_color"] we-button.active');
if (el) {
this.colorCombinationClass = el.dataset.value;
// Hide the size and shape options if the link is an unstyled anchor.
this.$('.link-size-row, .link-shape-row').toggleClass('d-none', !this.colorCombinationClass);
// Show custom colors only for Custom style.
this.$('.link-custom-color').toggleClass('d-none', el.dataset.value !== 'custom');
// Note: the _updateColorpicker method is supposedly async but can
// be used synchronously given the fact that _addColorPicker was
// called during this widget initialization.
this._updateColorpicker('color');
this._updateColorpicker('background-color');
this._updateColorpicker('border-color');
const borderWidth = this.linkEl.style['border-width'];
const numberAndUnit = getNumericAndUnit(borderWidth);
this.$('.link-custom-color-border input').val(numberAndUnit ? numberAndUnit[0] : "1");
let borderStyle = this.linkEl.style['border-style'];
if (!borderStyle || borderStyle === 'none') {
borderStyle = 'solid';
}
const $activeBorderStyleButton = this.$(`.link-custom-color-border [name="link_border_style"] we-button[data-value="${borderStyle}"]`);
$activeBorderStyleButton.addClass('active');
$activeBorderStyleButton.siblings('we-button').removeClass("active");
const $activeBorderStyleToggler = $activeBorderStyleButton.closest('we-select').find('we-toggler');
$activeBorderStyleToggler.empty();
$activeBorderStyleButton.find('div').clone().appendTo($activeBorderStyleToggler);
}
},
/**
* Updates the colorpicker associated to a given property - updated with its selected color.
*
* @private
* @param {string} cssProperty
*/
_updateColorpicker: async function (cssProperty) {
const prefix = this.PREFIXES[cssProperty];
let colorpicker = this.colorpickers[cssProperty];
if (!colorpicker) {
// As a fix, we made it possible to use this method always
// synchronously. This is just kept as compatibility.
// TODO Remove in master.
colorpicker = await this._addColorPicker(cssProperty);
}
// Update selected color.
const colorNames = colorpicker.getColorNames();
let color = this.linkEl.style[cssProperty];
const colorClasses = prefix ? computeColorClasses(colorNames, prefix) : [];
const colorClass = prefix && getColorClass(this.linkEl, colorNames, prefix);
const isColorClass = colorClasses.includes(colorClass);
if (isColorClass) {
color = colorClass;
} else if (cssProperty === 'background-color') {
const gradientColor = this.linkEl.style['background-image'];
if (isColorGradient(gradientColor)) {
color = gradientColor;
}
}
this.customColors[cssProperty] = color;
if (cssProperty === 'border-color') {
// Highlight matching named color if any.
const colorName = colorpicker.colorToColorNames[ColorpickerWidget.normalizeCSSColor(color)];
colorpicker.setSelectedColor(null, colorName || color, false);
} else {
colorpicker.setSelectedColor(null, isColorClass ? color.replace(prefix, '') : color, false);
}
// Update preview.
const $colorPreview = this.$('.link-custom-color-' + (cssProperty === 'border-color' ? 'border' : cssProperty === 'color' ? 'text' : 'fill') + ' .o_we_color_preview');
const previewClasses = computeColorClasses(colorNames, 'bg-');
$colorPreview[0].classList.remove(...previewClasses);
if (isColorClass) {
$colorPreview.css('background-color', `var(--we-cp-${color.replace(prefix, '')}`);
$colorPreview.css('background-image', '');
} else {
$colorPreview.css('background-color', isColorGradient(color) ? 'rgba(0, 0, 0, 0)' : color);
$colorPreview.css('background-image', isColorGradient(color) ? color : '');
}
},
/**
* @private
* @param {string} cssProperty
*/
async _addColorPicker(cssProperty) {
const prefix = this.PREFIXES[cssProperty];
const colorpicker = new ColorPaletteWidget(this, {
excluded: ['transparent_grayscale'],
// TODO remove me in master: editable is just a duplicate of
// $editable, should be reviewed with OWL later anyway.
editable: this.options.wysiwyg.odooEditor.editable,
$editable: $(this.options.wysiwyg.odooEditor.editable),
withGradients: cssProperty === 'background-color',
});
this.colorpickers[cssProperty] = colorpicker;
this.colorpickersPromises[cssProperty] = colorpicker.appendTo(document.createDocumentFragment());
await this.colorpickersPromises[cssProperty];
colorpicker.on('custom_color_picked color_picked color_hover color_leave', this, (ev) => {
// Reset color styles in link content to make sure new color is not hidden.
// Only done when applied to avoid losing state during preview.
if (['custom_color_picked', 'color_picked'].includes(ev.name)) {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(this.linkEl);
selection.removeAllRanges();
selection.addRange(range);
this.options.wysiwyg.odooEditor.execCommand('applyColor', '', 'color');
this.options.wysiwyg.odooEditor.execCommand('applyColor', '', 'backgroundColor');
}
let color = ev.data.color;
const colorNames = colorpicker.getColorNames();
const colorClasses = prefix ? computeColorClasses(colorNames, prefix) : [];
const colorClass = `${prefix}${color}`;
if (colorClasses.includes(colorClass)) {
color = colorClass;
} else if (colorNames.includes(color)) {
// Store as color value.
color = getCSSVariableValue(color);
}
this.customColors[cssProperty] = color;
this.applyLinkToDom(this._getData());
if (['custom_color_picked', 'color_picked'].includes(ev.name)) {
this.options.wysiwyg.odooEditor.historyStep();
this._updateOptionsUI();
}
});
return colorpicker;
},
/**
* Add hint to the classes of the link and button.
*/
_addHintClasses () {
this.$link.addClass('oe_edited_link');
this.$button.addClass('active');
},
/**
* Remove hint to the classes of the link and button.
*/
_removeHintClasses () {
$(this.options.wysiwyg.odooEditor.document).find('.oe_edited_link').removeClass('oe_edited_link');
this.$button.removeClass('active');
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
_onClickCheckbox: function (ev) {
const $target = $(ev.target);
$target.closest('we-button.o_we_checkbox_wrapper').toggleClass('active');
this._adaptPreview();
},
_onPickSelectOption: function (ev) {
const $target = $(ev.target);
if ($target.closest('[name="link_border_style"]').length) {
return;
}
const $select = $target.closest('we-select');
$select.find('we-selection-items we-button').toggleClass('active', false);
this._setSelectOption($target, true);
this._updateOptionsUI();
this._adaptPreview();
},
/**
* Sets the border width on the link.
*
* @private
* @param {Event} ev
*/
_onChangeCustomBorderWidth: function (ev) {
const value = ev.target.value;
if (parseInt(value) >= 0) {
this.$link.css('border-width', value + 'px');
}
},
/**
* Sets the border width on the link when enter is pressed.
*
* @private
* @param {Event} ev
*/
_onKeyPressCustomBorderWidth: function (ev) {
if (ev.keyCode === $.ui.keyCode.ENTER) {
this._onChangeCustomBorderWidth(ev);
}
},
/**
* Sets the border style on the link.
*
* @private
* @param {Event} ev
*/
_onBorderStyleSelectOption: function (ev) {
const value = ev.currentTarget.dataset.value;
if (value) {
this.$link.css('border-style', value);
const $target = $(ev.currentTarget);
const $activeBorderStyleToggler = $target.closest('we-select').find('we-toggler');
$activeBorderStyleToggler.empty();
$target.find('div').clone().appendTo($activeBorderStyleToggler);
// Ensure only one option is active in the dropdown.
$target.addClass('active');
$target.siblings('we-button').removeClass("active");
this.options.wysiwyg.odooEditor.historyStep();
}
},
/**
* @override
*/
__onURLInput() {
this._super(...arguments);
this.options.wysiwyg.odooEditor.historyPauseSteps('_onURLInput');
this._syncContent();
this._adaptPreview();
this.options.wysiwyg.odooEditor.historyUnpauseSteps('_onURLInput');
},
/**
* If content is equal to previous URL, update it to match current URL.
*
* @private
*/
_syncContent() {
const previousUrl = this._link.getAttribute('href');
if (!previousUrl) {
return;
}
const protocolLessPrevUrl = previousUrl.replace(/^https?:\/\/|^mailto:/i, '');
const content = this._link.innerText.trim().replaceAll('\u200B', '');
if (content === previousUrl || content === protocolLessPrevUrl) {
const newUrl = this.el.querySelector('input[name="url"]').value;
const protocolLessNewUrl = newUrl.replace(/^https?:\/\/|^mailto:/i, '')
const newContent = content.replace(protocolLessPrevUrl, protocolLessNewUrl);
this._observer.disconnect();
// Update link content with `force: true` otherwise it would fail if
// new content matches `originalText`. The `url` parameter is set to
// an empty string so that the link's content is set to the empty
// string if `newContent` has no length.
this._updateLinkContent(this.$link, { content: newContent, url: '' }, { force: true });
this._setLinkContent = false;
this._observer.observe(this._link, this._observerOptions);
}
},
});
return LinkTools;
});

View file

@ -0,0 +1,21 @@
odoo.define('wysiwyg.widgets', function (require) {
'use strict';
var Dialog = require('wysiwyg.widgets.Dialog');
var AltDialog = require('wysiwyg.widgets.AltDialog');
var LinkDialog = require('wysiwyg.widgets.LinkDialog');
var LinkTools = require('wysiwyg.widgets.LinkTools');
var ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget');
const LinkPopoverWidget = require('@web_editor/js/wysiwyg/widgets/link_popover_widget')[Symbol.for("default")];
const {ColorpickerDialog} = require('web.Colorpicker');
return {
Dialog: Dialog,
AltDialog: AltDialog,
LinkDialog: LinkDialog,
LinkTools: LinkTools,
ImageCropWidget: ImageCropWidget,
LinkPopoverWidget: LinkPopoverWidget,
ColorpickerDialog: ColorpickerDialog,
};
});

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