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