/* eslint max-len: 0 */ (win => { const rl = win.rl; if (!rl) { return; } rl.registerWYSIWYG('CompactComposer', (owner, container, onReady) => { const editor = new CompactComposer(container); onReady(editor); }); const doc = win.document; addEventListener('rl-view-model', e => { const vm = e.detail; if ('PopupsCompose' === vm.viewModelTemplateID && rl.settings.get('editorWysiwyg') === 'CompactComposer') { // add visible binding to the label const bodyLabel = vm.querySelector('.tabs label[for="tab-body"]'); bodyLabel.dataset.bind = 'visible: canMailvelope'; // re-apply the binding const labelNext = bodyLabel.nextElementSibling; ko.removeNode(bodyLabel); labelNext.parentElement.insertBefore(bodyLabel, labelNext); ko.applyBindingAccessorsToNode(bodyLabel, null, vm); // Now move the attachments tab to the bottom of the screen const area = vm.querySelector('.tabs .attachmentAreaParent'); vm.querySelector('.tabs input[value="attachments"]').remove(); vm.querySelector('.tabs label[for="tab-attachments"]').remove(); area.querySelector('.no-attachments-desc').remove(); area.classList.add('compact'); vm.viewModelDom.append(area); // Add and re-apply the bindings for the attachment-place const place = area.querySelector('.b-attachment-place'); ko.removeNode(place); area.insertBefore(place, area.firstElementChild); place.dataset.bind = 'visible: addAttachmentEnabled(), css: {dragAndDropOver: dragAndDropVisible}'; ko.applyBindingAccessorsToNode(place, null, vm); // There is a better way to do this probably, // but we need this for drag and drop to work e.detail.attachmentsArea = e.detail.bodyArea; } }); const // TODO: labels translations i18n = (str, def) => rl.i18n(str) || def, ctrlKey = shortcuts.getMetaKey() + ' + ', createElement = name => doc.createElement(name), tpl = createElement('template'), trimLines = html => html.trim().replace(/^(
\s*\s*<\/div>)+/, '').trim(), htmlToPlain = html => rl.Utils.htmlToPlain(html).trim(), plainToHtml = text => rl.Utils.plainToHtml(text), getFragmentOfChildren = parent => { let frag = doc.createDocumentFragment(); frag.append(...parent.childNodes); return frag; }, /** * @param {Array} data * @param {String} prop */ getByProp = (data, prop) => { for (let i = 0; i < data.length; i++) { const outer = data[i]; if (outer.hasOwnProperty(prop)) { return outer; } if (outer.items && Array.isArray(outer.items)) { const item = outer.items.find(item => item.prop === prop); if (item) { return item; } } } throw new Error('item with prop ' + prop + ' not found'); }, SquireDefaultConfig = { /* addLinks: true // allow_smart_html_links */ sanitizeToDOMFragment: (html) => { tpl.innerHTML = (html || '') .replace(/<\/?(BODY|HTML)[^>]*>/gi, '') .replace(//g, '') .replace(/]*>\s*<\/span>/gi, '') .trim(); tpl.querySelectorAll('a:empty,span:empty').forEach(el => el.remove()); return tpl.content; } }, pasteImageHandler = (e, squire) => { const items = [...e.detail.clipboardData.items]; const imageItems = items.filter((item) => /image/.test(item.type)); if (!imageItems.length) { return false; } let reader = new FileReader(); reader.onload = (loadEvent) => { squire.insertImage(loadEvent.target.result); }; reader.readAsDataURL(imageItems[0].getAsFile()); }; class CompactComposer { constructor(container) { const plain = createElement('textarea'), wysiwyg = createElement('div'), toolbar = createElement('div'), squire = new win.Squire2(wysiwyg, SquireDefaultConfig); this.container = container; container.classList.add('CompactComposer'); plain.className = 'squire-plain'; wysiwyg.className = 'squire-wysiwyg'; wysiwyg.dir = 'auto'; this.mode = ''; // 'plain' | 'wysiwyg' this.squire = squire; this.plain = plain; this.wysiwyg = wysiwyg; this.toolbar = toolbar; toolbar.className = 'squire-toolbar btn-toolbar'; const actions = this.makeActions(squire, toolbar); this.squire.addEventListener('willPaste', (event) => { // https://github.com/fastmail/Squire?tab=readme-ov-file#addeventlistener // The content that will be inserted is available as either the fragment property, or the text property for plain text, on the detail property of the event. You can modify this text/fragment in your event handler to change what will be pasted tpl.innerHTML = rl.Utils.cleanHtml(event.detail.html).html; event.detail.fragment = tpl.content; }); this.squire.addEventListener('pasteImage', (e) => { pasteImageHandler(e, squire); }); wysiwyg.addEventListener('focus', () => { const range = this.squire.getSelection(); if (range.collapsed && range.startContainer === wysiwyg) { // when the caret is directly in the wysiwyg a bunch of stuff // (like lists, blockquotes, etc...) do not work, // so we need to place it inside the nearest element if (wysiwyg.children[range.startOffset] !== undefined) { const newRange = document.createRange(); newRange.setStart(wysiwyg.children[range.startOffset], 0); this.squire.setSelection(newRange); } } }); // squire.addEventListener('focus', () => shortcuts.off()); // squire.addEventListener('blur', () => shortcuts.on()); container.append(toolbar, wysiwyg, plain); const fontFamilySelect = getByProp(actions, 'fontFamily').element; const fontSizeAction = getByProp(actions, 'fontSize'); /** * @param {string} fontName * @return {string} */ const normalizeFontName = (fontName) => fontName.trim().replace(/(^["']*|["']*$)/g, '').trim().toLowerCase(); /** @type {string[]} - lower cased array of available font families*/ const fontFamiliesLowerCase = Object.values(fontFamilySelect.options).map(option => option.value.toLowerCase()); /** * A theme might have CSS like div.squire-wysiwyg[contenteditable="true"] { * font-family: 'Times New Roman', Times, serif; } * so let's find the best match squire.getRoot()'s font * it will also help to properly handle generic font names like 'sans-serif' * @type {number} */ let defaultFontFamilyIndex = 0; const squireRootFonts = getComputedStyle(squire.getRoot()).fontFamily.split(',').map(normalizeFontName); fontFamiliesLowerCase.some((family, index) => { const matchFound = family.split(',').some(availableFontName => { const normalizedFontName = normalizeFontName(availableFontName); return squireRootFonts.some(squireFontName => squireFontName === normalizedFontName); }); if (matchFound) { defaultFontFamilyIndex = index; } return matchFound; }); /** * Instead of comparing whole 'font-family' strings, * we are going to look for individual font names, because we might be * editing a Draft started in another email client for example * * @type {Object.} */ const fontNamesMap = {}; /** * @param {string} fontFamily * @param {number} index */ const processFontFamilyString = (fontFamily, index) => { fontFamily.split(',').forEach(fontName => { const key = normalizeFontName(fontName); if (fontNamesMap[key] === undefined) { fontNamesMap[key] = index; } }); }; // first deal with the default font family processFontFamilyString(fontFamiliesLowerCase[defaultFontFamilyIndex], defaultFontFamilyIndex); // and now with the rest of the font families fontFamiliesLowerCase.forEach((fontFamily, index) => { if (index !== defaultFontFamilyIndex) { processFontFamilyString(fontFamily, index); } }); // ----- let ignoreNextSelectEvent = false; squire.addEventListener('pathChange', e => { const tokensMap = this.buildTokensMap(e.detail); if (tokensMap.has('__selection__')) { ignoreNextSelectEvent = false; return; } this.indicators.forEach((indicator) => { indicator.element.classList.toggle('active', indicator.selectors.some(selector => tokensMap.has(selector))); }); let familySelectedIndex = defaultFontFamilyIndex; const fontFamily = tokensMap.get('__font_family__'); if (fontFamily) { familySelectedIndex = -1; // show empty select if we don't know the font const fontNames = fontFamily.split(','); for (let i = 0; i < fontNames.length; i++) { const index = fontNamesMap[normalizeFontName(fontNames[i])]; if (index !== undefined) { familySelectedIndex = index; break; } } } fontFamilySelect.selectedIndex = familySelectedIndex; let sizeSelectedIndex = fontSizeAction.defaultValueIndex; const fontSize = tokensMap.get('__font_size__'); if (fontSize) { // -1 is ok because it will just show a blank