Initial commit: Web packages
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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&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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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``;
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>`;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
.o_upload_progress_toast {
|
||||
font-size: 16px;
|
||||
|
||||
.o_we_progressbar:last-child {
|
||||
hr {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1144 1280">
|
||||
<g transform="translate(0,1280) scale(0.1,-0.1)" fill="#000000" stroke="none">
|
||||
<path d="M7166 11004 c-603 -901 -1094 -1639 -1092 -1641 1 -2 369 42 817 97
|
||||
448 55 825 101 838 103 30 3 26 14 86 -278 131 -634 218 -1317 255 -2010 13
|
||||
-246 13 -898 0 -1135 -71 -1262 -367 -2292 -883 -3065 -162 -242 -308 -418
|
||||
-541 -650 -661 -658 -1465 -1094 -2581 -1400 -1069 -293 -2283 -471 -3660
|
||||
-536 -132 -6 -242 -13 -244 -15 -6 -5 16 -190 30 -255 9 -43 13 -47 47 -53
|
||||
108 -17 1148 -2 1627 24 2760 152 4778 866 6094 2155 506 496 887 1018 1216
|
||||
1670 444 877 715 1860 819 2970 35 365 41 510 41 1000 0 581 -17 880 -76 1355
|
||||
-19 158 -65 452 -74 477 -5 14 -54 7 730 104 363 44 662 83 665 85 2 3 0 8 -6
|
||||
12 -6 4 -682 594 -1503 1312 -822 718 -1497 1306 -1501 1308 -4 2 -501 -734
|
||||
-1104 -1634z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 853 B |
|
|
@ -0,0 +1,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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const commandCategoryRegistry = registry.category("command_categories");
|
||||
commandCategoryRegistry.add("shortcut_conflict", {}, { sequence: 5 });
|
||||
|
|
@ -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(' ').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;
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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)];
|
||||
}
|
||||
|
|
@ -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']
|
||||
];
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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'});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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}}&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}}&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}}&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>
|
||||
`;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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+)?(?:$| )/;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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("xss")"></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>',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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="{"1":2,"2":"Italic then also BOLD"}"
|
||||
data-sheets-textstyleruns="{"1":0,"2":{"3":"Arial"}}{"1":17,"2":{"3":"Arial","5":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="{"1":2,"2":"Italic strike"}">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="{"1":2,"2":"Just bold Just italic"}"
|
||||
data-sheets-textstyleruns="{"1":0,"2":{"3":"Arial"}}{"1":10,"2":{"3":"Arial","5":0,"6":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="{"1":2,"2":"Bold underline"}">Bold underline</td>
|
||||
</tr>
|
||||
<tr style="height:21px;">
|
||||
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;"
|
||||
data-sheets-value="{"1":2,"2":"Color text"}"><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="{"1":2,"2":"Color strike and underline"}">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="{"1":2,"2":"Color background"}">Color background
|
||||
</td>
|
||||
<td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;background-color:#ffff00;color:#ff0000;"
|
||||
data-sheets-value="{"1":2,"2":"Color text on color background"}">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="{"1":2,"2":"14pt MONO TEXT"}">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>`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
$
|
||||
<span class="oe_currency_value">[]</span>
|
||||
</span>
|
||||
</p>
|
||||
`);
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: content,
|
||||
stepFunction: (editor) => editor.execCommand('oDeleteBackward'),
|
||||
contentAfter: content,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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]]),
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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')));
|
||||
});
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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 = /"/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, """) : null)
|
||||
.attr('title', title ? title.replace(allNonEscQuots, """) : null);
|
||||
$(this.media).trigger('content_changed');
|
||||
this.final_data = this.media;
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return AltDialog;
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||