From 57003ab4b8b09003fe022250613ac172ddaca888 Mon Sep 17 00:00:00 2001 From: Sergey Mosin Date: Wed, 28 Feb 2024 09:47:39 -0500 Subject: [PATCH] compact-composer plugin v1.0.0 --- plugins/compact-composer/css/composer.css | 87 + plugins/compact-composer/index.php | 22 + .../compact-composer/js/CompactComposer.js | 967 ++++ plugins/compact-composer/js/parsel.js | 413 ++ plugins/compact-composer/js/squire-raw.js | 4102 +++++++++++++++++ .../screenshots/Screenshot1.png | Bin 0 -> 90564 bytes .../screenshots/Screenshot2.png | Bin 0 -> 62154 bytes .../screenshots/Screenshot3.png | Bin 0 -> 63201 bytes .../screenshots/Screenshot4.png | Bin 0 -> 60321 bytes .../screenshots/Screenshot5.png | Bin 0 -> 61356 bytes .../screenshots/Screenshot6.png | Bin 0 -> 93133 bytes .../templates/PopupsCompose.html | 238 + 12 files changed, 5829 insertions(+) create mode 100644 plugins/compact-composer/css/composer.css create mode 100644 plugins/compact-composer/index.php create mode 100644 plugins/compact-composer/js/CompactComposer.js create mode 100644 plugins/compact-composer/js/parsel.js create mode 100644 plugins/compact-composer/js/squire-raw.js create mode 100644 plugins/compact-composer/screenshots/Screenshot1.png create mode 100644 plugins/compact-composer/screenshots/Screenshot2.png create mode 100644 plugins/compact-composer/screenshots/Screenshot3.png create mode 100644 plugins/compact-composer/screenshots/Screenshot4.png create mode 100644 plugins/compact-composer/screenshots/Screenshot5.png create mode 100644 plugins/compact-composer/screenshots/Screenshot6.png create mode 100644 plugins/compact-composer/templates/PopupsCompose.html diff --git a/plugins/compact-composer/css/composer.css b/plugins/compact-composer/css/composer.css new file mode 100644 index 000000000..3b7cb6f55 --- /dev/null +++ b/plugins/compact-composer/css/composer.css @@ -0,0 +1,87 @@ +.squire-toolbar { + padding-top: 4px; + padding-bottom: 0; + overflow: visible; + z-index: 200; + white-space: normal; + min-height: auto; +} + +.squire-toolbar > .btn-group { + margin-bottom: 4px; +} + +.squire-toolbar > .btn-group > a.btn, +.squire-toolbar button.btn, +.squire-toolbar select.btn { + line-height: 20px; + padding-top: 4px; + padding-bottom: 4px; + min-height: 24px; +} + +.squire-toolbar-menu-item { + display: flex; + align-items: center; + gap: .25em; + margin: .1em !important; + cursor: pointer; + padding: .25em; +} + +.squire-toolbar-menu-item.active { + background-color: rgba(128, 128, 128, .1); +} + +.squire-toolbar-menu-item:hover { + background-color: rgba(128, 128, 128, .2); +} + +.squire-toolbar-svg-icon { + display: block; +} + +.squire2-mode-wysiwyg .squire-plain, +.squire2-mode-source .squire-wysiwyg, +.squire2-mode-plain .squire-wysiwyg { + display: none; +} + +.squire2-mode-source .squire-plain, +.squire2-mode-plain .squire-plain { + display: block; +} + +.squire-toolbar > .squire-toolbar-menu-wrap:last-child { + float: right; +} + +.squire-toolbar.mode-plain .squire-html-mode-item { + display: none; +} + +#V-PopupsCompose .attachmentAreaParent.compact { + height: auto; + min-height: auto; + padding: 0; + overflow: auto; + flex: 1 0 auto; + max-height: 12em; + margin: 0; +} + +#V-PopupsCompose .compact > .b-attachment-place { + position: static; + display: none; + margin: .375em; + line-height: 4em; +} + +#V-PopupsCompose .compact > .b-attachment-place.dragAndDropOver { + display: block; +} + +#V-PopupsCompose .compact .attachmentList { + margin: 0; + padding: 0; +} \ No newline at end of file diff --git a/plugins/compact-composer/index.php b/plugins/compact-composer/index.php new file mode 100644 index 000000000..6568791ed --- /dev/null +++ b/plugins/compact-composer/index.php @@ -0,0 +1,22 @@ +addTemplate('templates/PopupsCompose.html'); + $this->addCss('css/composer.css'); + $this->addJs('js/squire-raw.js'); + $this->addJs('js/parsel.js'); + $this->addJs('js/CompactComposer.js'); + } +} diff --git a/plugins/compact-composer/js/CompactComposer.js b/plugins/compact-composer/js/CompactComposer.js new file mode 100644 index 000000000..503d00c9e --- /dev/null +++ b/plugins/compact-composer/js/CompactComposer.js @@ -0,0 +1,967 @@ +/* eslint max-len: 0 */ +(win => { + + addEventListener('rl-view-model.create', e => { + if (e.detail.viewModelTemplateID === 'PopupsCompose') { + // 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 doc = win.document; + const rl = win.rl; + + const + removeElements = 'HEAD,LINK,META,NOSCRIPT,SCRIPT,TEMPLATE,TITLE', + allowedElements = 'A,B,BLOCKQUOTE,BR,DIV,EM,FONT,H1,H2,H3,H4,H5,H6,HR,I,IMG,LI,OL,P,SPAN,STRONG,TABLE,TD,TH,TR,U,UL', + allowedAttributes = 'abbr,align,background,bgcolor,border,cellpadding,cellspacing,class,color,colspan,dir,face,frame,height,href,hspace,id,lang,rowspan,rules,scope,size,src,style,target,type,usemap,valign,vspace,width'.split(','), + + // 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; + } + }, + + pasteSanitizer = (event) => { + const frag = event.detail.fragment; + frag.querySelectorAll('a:empty,span:empty').forEach(el => el.remove()); + frag.querySelectorAll(removeElements).forEach(el => el.remove()); + frag.querySelectorAll('*').forEach(el => { + if (!el.matches(allowedElements)) { + el.replaceWith(getFragmentOfChildren(el)); + } else if (el.hasAttributes()) { + [...el.attributes].forEach(attr => { + let name = attr.name.toLowerCase(); + if (!allowedAttributes.includes(name)) { + el.removeAttribute(name); + } + }); + } + }); + }, + + 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; + + toolbar.className = 'squire-toolbar btn-toolbar'; + const actions = this.#makeActions(squire, toolbar); + + 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; + + this.squire.addEventListener('willPaste', pasteSanitizer); + this.squire.addEventListener('pasteImage', (e) => { + pasteImageHandler(e, squire); + }); + +// 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + +