Merge pull request #1466 from SergeyMosin/compact-composer-plugin

compact-composer plugin v1.0.0
This commit is contained in:
Maarten 2024-02-28 20:10:12 +01:00 committed by GitHub
commit e880d0ce86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 5829 additions and 0 deletions

View file

@ -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;
}

View file

@ -0,0 +1,22 @@
<?php
class CompactComposerPlugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Compact Composer',
AUTHOR = 'Sergey Mosin',
VERSION = '1.0.0',
RELEASE = '2024-02-23',
REQUIRED = '2.34.0',
LICENSE = 'AGPL v3',
DESCRIPTION = 'WYSIWYG editor with a compact toolbar';
public function Init(): void
{
$this->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');
}
}

View file

@ -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(/^(<div>\s*<br\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(/<span[^>]*>\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.<string,number>}
*/
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 <select>
sizeSelectedIndex = fontSizeAction.items.indexOf(fontSize);
}
fontSizeAction.element.selectedIndex = sizeSelectedIndex;
ignoreNextSelectEvent = true;
});
squire.addEventListener('select', e => {
if (ignoreNextSelectEvent) {
ignoreNextSelectEvent = false;
return;
}
if (e.detail.range.collapsed) {
return;
}
this.indicators.forEach((indicator) => {
indicator.element.classList.toggle('active', indicator.selectors.some(selector => squire.hasFormat(selector)));
});
});
/*
squire.addEventListener('cursor', e => {
console.dir({cursor:e.range});
});
squire.addEventListener('select', e => {
console.dir({select:e.range});
});
*/
}
/**
* @param {Squire} squire
* @param {HTMLDivElement} toolbar
* @returns {Array}
*/
#makeActions(squire, toolbar) {
const clr = this.#makeClr();
const doClr = name => input => {
// https://github.com/the-djmaze/snappymail/issues/826
clr.style.left = (input.offsetLeft + input.parentNode.offsetLeft) + 'px';
clr.style.width = input.offsetWidth + 'px';
clr.value = '';
clr.onchange = () => {
switch (name) {
case 'color':
squire.setTextColor(clr.value);
break;
case 'backgroundColor':
squire.setHighlightColor(clr.value);
break;
default:
console.error('invalid name:', name);
}
};
// Chrome 110+ https://github.com/the-djmaze/snappymail/issues/1199
// clr.oninput = () => squire.setStyle({[name]:clr.value});
setTimeout(() => clr.click(), 1);
};
toolbar.append(clr);
const browseImage = createElement('input');
browseImage.type = 'file';
browseImage.accept = 'image/*';
browseImage.style.display = 'none';
browseImage.onchange = () => {
if (browseImage.files.length) {
let reader = new FileReader();
reader.readAsDataURL(browseImage.files[0]);
reader.onloadend = () => reader.result && squire.insertImage(reader.result);
}
};
const actions = [
{
type: 'group',
items: [
{
type: 'select',
label: 'Font',
cmd: s => squire.setFontFace(s.value),
prop: 'fontFamily',
items: {
'sans-serif': {
Arial: '\'Nimbus Sans L\', \'Liberation sans\', \'Arial Unicode MS\', Arial, Helvetica, Garuda, Utkal, FreeSans, sans-serif',
Tahoma: '\'Luxi Sans\', Tahoma, Loma, Geneva, Meera, sans-serif',
Trebuchet: '\'DejaVu Sans Condensed\', Trebuchet, \'Trebuchet MS\', sans-serif',
Lucida: '\'Lucida Sans Unicode\', \'Lucida Sans\', \'DejaVu Sans\', \'Bitstream Vera Sans\', \'DejaVu LGC Sans\', sans-serif',
Verdana: '\'DejaVu Sans\', Verdana, Geneva, \'Bitstream Vera Sans\', \'DejaVu LGC Sans\', sans-serif'
},
monospace: {
Courier: '\'Liberation Mono\', \'Courier New\', FreeMono, Courier, monospace',
Lucida: '\'DejaVu Sans Mono\', \'DejaVu LGC Sans Mono\', \'Bitstream Vera Sans Mono\', \'Lucida Console\', Monaco, monospace'
},
sans: {
Times: '\'Nimbus Roman No9 L\', \'Times New Roman\', Times, FreeSerif, serif',
Palatino: '\'Bitstream Charter\', \'Palatino Linotype\', Palatino, Palladio, \'URW Palladio L\', \'Book Antiqua\', Times, serif',
Georgia: '\'URW Palladio L\', Georgia, Times, serif'
}
}
},
{
type: 'select',
label: 'Font size',
cmd: s => squire.setFontSize(s.value),
prop: 'fontSize',
items: ['11px', '13px', '16px', '20px', '24px', '30px'],
defaultValueIndex: 2
}
]
},
{
type: 'menu',
label: 'Colors',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m8 7.75c0.713 0 1.25-0.559 1.25-1.25 0-0.691-0.54-1.25-1.25-1.25-0.71 0-1.25 0.556-1.25 1.25 0 0.694 0.537 1.25 1.25 1.25zm6.5 3c0.713 0 1.25-0.559 1.25-1.25 0-0.691-0.54-1.25-1.25-1.25s-1.25 0.556-1.25 1.25c0 0.694 0.537 1.25 1.25 1.25zm-9 0c0.713 0 1.25-0.559 1.25-1.25 0-0.691-0.54-1.25-1.25-1.25s-1.25 0.556-1.25 1.25c0 0.694 0.537 1.25 1.25 1.25zm4.5 7.25c-4.47 0-8-3.63-8-8 0-4.81 3.97-8 8.17-8 4.12 0 7.83 3.02 7.83 7.21 0 2.83-2.2 4.79-4.79 4.79h-1.42c-0.277 0-0.417 0.2-0.417 0.375 0 0.208 0.104 0.382 0.312 0.521 0.208 0.139 0.312 0.507 0.312 1.1 0 1.09-0.858 2-2 2zm2-10.2c0.713 0 1.25-0.559 1.25-1.25s-0.54-1.25-1.25-1.25-1.25 0.556-1.25 1.25 0.537 1.25 1.25 1.25zm-2 8.75c0.477 0 0.737-0.739 0.188-1.08-0.226-0.142-0.312-0.514-0.312-1.04 0-1.18 0.934-1.88 1.9-1.88h1.44c2.09-0.032 3.27-1.65 3.29-3.29 0-3.19-2.79-5.71-6.33-5.71-3.74 0-6.67 2.89-6.67 6.5 0 3.49 2.68 6.45 6.5 6.5z"/></svg>',
items: [
{
type: 'menu_item',
label: 'Text Color',
cmd: doClr('color'),
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m3 18v-3h14v3zm2.35-5 3.75-10h1.79l3.75 10h-1.73l-0.896-2.56h-4.02l-0.917 2.56zm3.15-4h3l-1.46-4.04h-0.0833z"/></svg>'
},
{
type: 'menu_item',
label: 'Background Color',
cmd: doClr('backgroundColor'),
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m7.22 16.6-4.87-4.85q-0.166-0.166-0.26-0.364-0.0936-0.198-0.0936-0.427t0.0936-0.447q0.0936-0.218 0.26-0.385l4.6-4.6-2.52-2.52 1.06-1.06 8.18 8.18q0.166 0.166 0.25 0.375 0.0832 0.208 0.0832 0.437 0 0.229-0.0832 0.437-0.0832 0.208-0.25 0.375l-4.85 4.85q-0.166 0.166-0.375 0.26-0.208 0.0936-0.437 0.0936-0.229-0.0208-0.427-0.104-0.198-0.0832-0.364-0.25zm0.791-10-4.35 4.35v-0.0208 0.0208h8.7v-0.0208 0.0208zm8.18 10.3q-0.77 0-1.3-0.52-0.531-0.52-0.531-1.29 0-0.499 0.229-0.967 0.229-0.468 0.541-0.884l1.06-1.33 1.04 1.33q0.291 0.416 0.531 0.884 0.239 0.468 0.239 0.967 0 0.77-0.531 1.29-0.531 0.52-1.28 0.52z"/></svg>'
}
]
},
{
type: 'group',
items: [
{
type: 'button',
label: 'Bold',
cmd: () => this.doAction('bold', 'B'),
key: 'B',
matches: 'B,STRONT',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m5.53 16v-12h4.75q1.4 0 2.57 0.861 1.18 0.861 1.18 2.39 0 1.06-0.469 1.66t-0.885 0.865q0.542 0.249 1.17 0.895 0.625 0.646 0.625 1.9 0 1.9-1.4 2.67-1.4 0.771-2.62 0.771zm2.65-2.46h2.18q1.01 0 1.21-0.51t0.208-0.74q0-0.229-0.219-0.74-0.219-0.51-1.28-0.51h-2.1zm0-4.83h1.94q0.688 0 1.01-0.365 0.323-0.365 0.323-0.781 0-0.5-0.356-0.812-0.356-0.312-0.923-0.312h-1.99z"/></svg>'
},
{
type: 'button',
label: 'Italic',
cmd: () => this.doAction('italic', 'I'),
key: 'I',
matches: 'I,EM',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m4.5 16v-2h3.33l2.58-8h-3.42v-2h8.5v2h-3.08l-2.58 8h3.17v2z"/></svg>'
},
{
type: 'button',
label: 'Underline',
cmd: () => this.doAction('underline', 'U'),
key: 'U',
matches: 'U',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m5 17v-1.5h10v1.5zm5-3q-2 0-3.09-1.24t-1.09-3.28v-6.48h2.03v6.61q0 1.1 0.551 1.79 0.551 0.688 1.61 0.688 1.06 0 1.61-0.688 0.55-0.688 0.55-1.79v-6.61h2.02v6.48q0 2.04-1.09 3.28t-3.09 1.24z"/></svg>'
}
]
},
{
type: 'group',
items: [
{
type: 'button',
label: 'Ordered List',
cmd: () => this.doList('OL'),
key: 'Shift + 8',
matches: 'OL',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m3 17v-1h2v-0.5h-1v-1h1v-0.5h-2v-1h2.5q0.212 0 0.356 0.144t0.144 0.356v1q0 0.212-0.144 0.356t-0.356 0.144q0.212 0 0.356 0.144t0.144 0.356v1q0 0.212-0.144 0.356t-0.356 0.144zm0-5v-2q0-0.212 0.144-0.356t0.356-0.144h1.5v-0.5h-2v-1h2.5q0.212 0 0.356 0.144t0.144 0.356v1.5q0 0.212-0.144 0.356t-0.356 0.144h-1.5v0.5h2v1zm1-5v-3h-1v-1h2v4zm3.5 8v-1.5h9.5v1.5zm0-4.25v-1.5h9.5v1.5zm0-4.25v-1.5h9.5v1.5z"/></svg>'
},
{
type: 'button',
label: 'List',
cmd: () => this.doList('UL'),
key: 'Shift + 9',
matches: 'UL',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m7.5 15v-1.5h9.5v1.5zm0-4.25v-1.5h9.5v1.5zm0-4.25v-1.5h9.5v1.5zm-3 9.25q-0.621 0-1.06-0.442-0.438-0.442-0.438-1.06 0-0.621 0.442-1.06 0.442-0.438 1.06-0.438 0.621 0 1.06 0.442 0.438 0.442 0.438 1.06 0 0.621-0.442 1.06-0.442 0.438-1.06 0.438zm0-4.25q-0.621 0-1.06-0.442-0.438-0.442-0.438-1.06 0-0.621 0.442-1.06 0.442-0.438 1.06-0.438 0.621 0 1.06 0.442 0.438 0.442 0.438 1.06 0 0.621-0.442 1.06-0.442 0.438-1.06 0.438zm0-4.25q-0.621 0-1.06-0.442-0.438-0.442-0.438-1.06 0-0.621 0.442-1.06 0.442-0.438 1.06-0.438 0.621 0 1.06 0.442 0.438 0.442 0.438 1.06 0 0.621-0.442 1.06-0.442 0.438-1.06 0.438z"/></svg>'
},
{
type: 'button',
label: 'Decrease Indent',
cmd: () => this.changeLevel('decrease'),
key: ']',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m3 17v-1.5h14v1.5zm6-3.12v-1.5h8v1.5zm0-3.12v-1.5h8v1.5zm0-3.12v-1.5h8v1.5zm-6-3.12v-1.5h14v1.5zm4 8.5v-6l-4 3z"/></svg>'
},
{
type: 'button',
label: 'Increase Indent',
cmd: () => this.changeLevel('increase'),
key: '[',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m3 17v-1.5h14v1.5zm6-3.12v-1.5h8v1.5zm0-3.12v-1.5h8v1.5zm0-3.12v-1.5h8v1.5zm-6-3.12v-1.5h14v1.5zm0 8.5v-6l4 3z"/></svg>'
}
]
},
{
type: 'menu',
rightEdge: true,
label: 'Insert Image',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m15 1v2h-2v2h2v2h2v-2h2v-2h-2v-2zm-12 2c-1.09 0-2 0.909-2 2v10c0 1.09 0.909 2 2 2h14c1.09 0 2-0.909 2-2v-5h-1.75v5.25h-14.5v-10.5h7.25v-1.75zm9 6-3 4-2-3-3 4h12z"/></svg>',
items: [
{
type: 'menu_item',
label: 'Image File',
cmd: () => browseImage.click(),
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m7 11h8l-2.62-3.5-1.88 2.5-1.37-1.83zm-4.5 6q-0.604 0-1.05-0.448t-0.448-1.05v-10h1.5v10h13.5v1.5zm3-3q-0.604 0-1.05-0.448-0.448-0.448-0.448-1.05v-9q0-0.619 0.448-1.06t1.05-0.441h3.52l2 2h5.48q0.619 0 1.06 0.441 0.441 0.441 0.441 1.06v7q0 0.604-0.441 1.05-0.441 0.448-1.06 0.448zm0-1.5h11v-7h-6.08l-2-2h-2.92zm0 0v-9z"/></svg>'
},
{
type: 'menu_item',
label: 'From URL',
cmd: () => {
//TODO: check is if an IMG node is in range already
const src = prompt('Image', 'https://');
if (src) {
this.squire.insertImage(src);
}
},
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.9 8h-1.88c-0.692 0-1.28-0.244-1.77-0.732-0.488-0.488-0.731-1.08-0.731-1.77s0.244-1.28 0.731-1.77 1.08-0.729 1.77-0.729h1.88v0.938h-1.88c-0.434 0-0.803 0.152-1.11 0.456s-0.456 0.673-0.456 1.11 0.152 0.803 0.456 1.11 0.607 0.456 1.11 0.456h1.88zm-1.25-2.03v-0.938h3.75v0.938zm2.5 2.03v-0.938h1.88c0.434 0 0.803-0.152 1.11-0.456s0.456-0.673 0.456-1.11-0.152-0.803-0.456-1.11-0.673-0.456-1.11-0.456h-1.88v-0.938h1.88c0.692 0 1.28 0.244 1.77 0.732 0.488 0.488 0.731 1.08 0.731 1.77 0 0.692-0.244 1.28-0.731 1.77-0.488 0.486-1.08 0.729-1.77 0.729zm3.38 2v5.5c0 0.403-0.147 0.753-0.441 1.05s-0.647 0.448-1.06 0.448h-11c-0.412 0-0.766-0.149-1.06-0.448s-0.441-0.649-0.441-1.05v-9.25c0-0.403 0.147-0.753 0.441-1.05s0.649-0.407 1.06-0.448h1.5v1.5h-1.5v9.25h11v-5.5zm-11.5 4h9l-3-4-2.25 3-1.5-2z"/></svg>'
}
]
},
{
// this is a special case: we move the "attach" button group to the toolbar
// TODO: there is probably a better way of doing this in the template
// TODO: move Encrypt/Sign button group ?
type: 'move_parent',
label: 'Attach File',
id: 'composeUploadButton',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m12.5 9.5v-4.5h1.5v4.5zm-3.5 5.44c-1.02-0.272-1.5-1.15-1.5-2.02v-7.92h1.5zm0.5 3.06c-2.59 0-4.5-2.11-4.5-4.65v-8.1c0-1.95 1.55-3.25 3.25-3.25 1.85 0 3.25 1.51 3.25 3.4v6.35h-1.5v-6.5c0-1.09-0.883-1.75-1.75-1.75-1.06 0-1.75 0.906-1.75 1.79v8.21c0 2.63 3.33 4.15 5.25 1.96v1.94c-0.706 0.428-1.56 0.604-2.25 0.604zm3.75-1v-2.25h-2.25v-1.5h2.25v-2.25h1.5v2.25h2.25v1.5h-2.25v2.25z"/></svg>'
},
{
type: 'menu_more',
label: 'More',
rightEdge: true,
showInPlainMode: true,
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m2 4v2h16v-2zm0 5v2h16v-2zm0 5v2h16v-2z"/></svg>',
items: [
{
type: 'menu_item',
label: 'Undo',
cmd: () => squire.undo(),
key: 'Z',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m4.82 9.3c1.45-1.26 3.32-2.03 5.4-2.03 3.64 0 6.71 2.37 7.79 5.65l-1.85 0.61c-0.821-2.49-3.17-4.3-5.94-4.3-1.52 0-2.92 0.563-4 1.47l2.83 2.83h-7.04v-7.04z"/></svg>'
},
{
type: 'menu_item',
label: 'Redo',
cmd: () => squire.redo(),
key: 'Y',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m15.2 9.3c-1.45-1.26-3.32-2.03-5.4-2.03-3.64 0-6.71 2.37-7.79 5.65l1.85 0.61c0.821-2.49 3.17-4.3 5.94-4.3 1.52 0 2.92 0.563 4 1.47l-2.83 2.83h7.04v-7.04z"/></svg>'
},
{
type: 'menu_item',
label: 'Blockquote',
cmd: () => {
if (!['UL', 'OL', 'BLOCKQUOTE'].some(listTag => this.squire.hasFormat(listTag))) {
this.changeLevel('increase');
}
},
matches: 'BLOCKQUOTE',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m4 3c-0.554 0-1 0.446-1 1s0.446 1 1 1h12c0.554 0 1-0.446 1-1s-0.446-1-1-1h-12zm0 6c-0.554 0-1 0.446-1 1v6c0 0.554 0.446 1 1 1s1-0.446 1-1v-6c0-0.554-0.446-1-1-1zm5 0c-0.554 0-1 0.446-1 1s0.446 1 1 1h7c0.554 0 1-0.446 1-1s-0.446-1-1-1h-7zm0 6c-0.554 0-1 0.446-1 1s0.446 1 1 1h7c0.554 0 1-0.446 1-1s-0.446-1-1-1h-7z"/></svg>'
},
{
type: 'menu_item',
label: 'Link',
cmd: () => {
/** @type {Range} range */
const range = this.squire.getSelection();
let linkNode;
if (range.collapsed || range.startContainer.parentNode === range.endContainer.parentNode) {
const root = this.squire.getRoot();
for (let node = range.startContainer; node !== root; node = node.parentNode) {
if (node.tagName === 'A') {
range.selectNode(node);
linkNode = node;
break;
}
}
}
const url = prompt('Link', linkNode?.href || 'https://');
if (url != null) {
if (url.length) {
if (range.collapsed === false) {
// squire breaks the wrapping node, so if we have a <b>
// inside the selection it will create something like this:
// <a>t</a><b><a>ex</a></b><a>t</a> and we don't want that
// TODO: this could be more elegant
this.squire.removeAllFormatting(range);
}
this.squire.makeLink(url);
} else if (linkNode) {
this.squire.removeLink();
}
}
},
matches: 'A',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m15.4 16.7c-0.509 0.521-1.13 0.781-1.87 0.781-0.735 0-1.36-0.256-1.87-0.771l-1.91-1.91c-0.515-0.515-0.771-1.14-0.771-1.87 0-0.743 0.267-1.38 0.801-1.9l-0.809-0.809c-0.525 0.533-1.16 0.801-1.91 0.801-0.735 0-1.36-0.255-1.87-0.763l-1.9-1.89c-0.521-0.509-0.781-1.13-0.781-1.87-6e-7 -0.735 0.255-1.36 0.763-1.87l1.34-1.35c0.509-0.521 1.13-0.781 1.87-0.781 0.735 1.6e-4 1.36 0.256 1.87 0.771l1.91 1.91c0.515 0.515 0.772 1.14 0.772 1.87 0 0.739-0.265 1.37-0.792 1.9l0.809 0.809c0.524-0.527 1.16-0.792 1.9-0.792 0.735 0 1.36 0.255 1.87 0.763l1.9 1.89c0.521 0.509 0.781 1.13 0.781 1.87 0 0.735-0.255 1.36-0.763 1.87zm-1.25-1.24 1.34-1.35c0.165-0.178 0.248-0.385 0.248-0.624 0-0.245-0.0862-0.455-0.258-0.626l-1.9-1.89c-0.172-0.172-0.38-0.257-0.625-0.257-0.249-3.1e-5 -0.466 0.0966-0.653 0.286l0.577 0.577c0.353 0.353 0.353 0.922 0 1.28-0.353 0.353-0.922 0.353-1.28 0l-0.577-0.577c-0.184 0.182-0.278 0.401-0.278 0.654 0 0.251 0.0824 0.459 0.248 0.624l1.91 1.91c0.172 0.171 0.38 0.257 0.625 0.257 0.239 0 0.444-0.0849 0.615-0.257zm-6.43-6.5-0.577-0.577c-0.353-0.353-0.353-0.922 0-1.28 0.353-0.353 0.922-0.353 1.28 0l0.575 0.575c0.184-0.18 0.279-0.394 0.279-0.643-6e-7 -0.245-0.0849-0.454-0.257-0.625l-1.91-1.91c-0.172-0.172-0.38-0.257-0.625-0.257-0.239 0-0.444 0.0852-0.615 0.257l-1.34 1.35c-0.159 0.172-0.238 0.379-0.238 0.624 2e-7 0.251 0.0811 0.46 0.247 0.625l1.9 1.89c0.172 0.172 0.38 0.257 0.625 0.257 0.253-9e-7 0.473-0.0991 0.661-0.295z"/></svg>'
},
{
type: 'menu_item',
label: 'Strikethrough',
cmd: () => this.doAction('strikethrough', 'S'),
key: 'Shift + 7',
matches: 'S',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m10.1 15.9q-1.5 0-2.62-0.875t-1.5-2.31l1.69-0.688q0.333 1.06 0.969 1.6 0.635 0.542 1.51 0.542 0.937 0 1.49-0.448 0.552-0.448 0.552-1.2 0-0.333-0.125-0.615t-0.375-0.51h2.15q0.104 0.229 0.135 0.49t0.0312 0.615q0 1.5-1.08 2.45-1.08 0.948-2.81 0.948zm-8.12-6v-1.5h16v1.5zm8-6q1.33 0 2.21 0.562t1.42 1.79l-1.62 0.708q-0.229-0.625-0.76-1.01-0.531-0.385-1.2-0.385-0.771 0-1.28 0.375-0.51 0.375-0.552 0.958h-1.81q0.0417-1.31 1.06-2.16 1.02-0.844 2.54-0.844z"/></svg>'
},
{
type: 'menu_item',
label: 'Superscript',
cmd: () => this.doAction('superscript', 'SUP'),
key: 'Shift + 6',
matches: 'SUP',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m13.4 8v-1.99q0-0.424 0.288-0.715 0.288-0.291 0.712-0.292h1v-1h-2v-1h2q0.425 0 0.712 0.287 0.288 0.286 0.288 0.71v0.997q0 0.424-0.288 0.715-0.288 0.292-0.712 0.292h-1v1h2v1zm-9.38 8 3.31-5.21-3.08-4.79h1.89l2.21 3.56h0.0833l2.21-3.56h1.9l-3.1 4.79 3.33 5.21h-1.9l-2.44-3.88h-0.0833l-2.44 3.88z"/></svg>'
},
{
type: 'menu_item',
label: 'Subscript',
cmd: () => this.doAction('subscript', 'SUB'),
key: 'Shift + 5',
matches: 'SUB',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m13.4 18v-1.99q0-0.424 0.288-0.715 0.288-0.292 0.712-0.292h1v-1h-2v-1h2q0.425 0 0.712 0.287 0.288 0.286 0.288 0.71v0.997q0 0.424-0.288 0.715-0.288 0.292-0.712 0.292h-1v1h2v1zm-9.38-3 3.31-5.21-3.08-4.79h1.89l2.21 3.56h0.0833l2.21-3.56h1.9l-3.1 4.79 3.33 5.21h-1.9l-2.44-3.88h-0.0833l-2.44 3.88z"/></svg>'
},
{
type: 'menu_item',
label: 'Left to Right',
cmd: () => squire.setTextDirection('ltr'),
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m8 12v-4q-1.25 0-2.12-0.875-0.875-0.875-0.875-2.12t0.875-2.12q0.875-0.875 2.11-0.875h6.01v1.5h-1.5v8.5h-1.5v-8.5h-1.5v8.5zm6 6-1.06-1.06 1.19-1.19h-11.1v-1.5h11.1l-1.19-1.19 1.06-1.06 3 3zm-6-11.5v-3q-0.625 0-1.06 0.442-0.438 0.442-0.438 1.06 0 0.621 0.441 1.06 0.441 0.437 1.06 0.437z"/></svg>'
},
{
type: 'menu_item',
label: 'Right to Left',
cmd: () => squire.setTextDirection('rtl'),
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m6 18-3-3 3-3 1.06 1.06-1.19 1.19h11.1v1.5h-11.1l1.19 1.19zm2-6v-4q-1.25 0-2.12-0.875-0.875-0.875-0.875-2.12t0.875-2.12q0.875-0.875 2.11-0.875h6.01v1.5h-1.5v8.5h-1.5v-8.5h-1.5v8.5zm0-5.5v-3q-0.625 0-1.06 0.442-0.438 0.442-0.438 1.06 0 0.621 0.441 1.06 0.441 0.437 1.06 0.437z"/></svg>'
},
{
type: 'menu_item',
label: 'HTML Mode',
id: 'menu-item-mode-wysiwyg',
cmd: () => this.setMode('wysiwyg'),
showInPlainMode: true,
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m2 2v3h1v-2h2v-1zm13 0v1h2v2h1v-3zm-9 3v10h2v-4h4v4h2v-10h-2v4h-4v-4zm-4 10v3h3v-1h-2v-2zm15 0v2h-2v1h3v-3z"/></svg>'
},
{
type: 'menu_item',
label: 'Edit Source',
id: 'menu-item-mode-source',
cmd: () => this.setMode('source'),
showInPlainMode: true,
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m12 2.83c-0.478-0.138-0.976 0.141-1.11 0.619l-3.6 12.6c-0.138 0.478 0.141 0.976 0.619 1.11 0.478 0.138 0.976-0.141 1.11-0.619l3.6-12.6c0.138-0.478-0.141-0.976-0.619-1.11zm2.27 4.65 2.51 2.51-2.51 2.51c-0.352 0.352-0.352 0.923 0 1.27 0.352 0.352 0.923 0.352 1.27 0l3.15-3.15c0.352-0.352 0.352-0.923 0-1.27l-3.15-3.15c-0.352-0.352-0.923-0.352-1.27-0.00141-0.35 0.35-0.35 0.921 0.00141 1.27zm-8.63-1.27c-0.352-0.352-0.923-0.352-1.27 0l-3.15 3.15c-0.352 0.352-0.352 0.923 0 1.27l3.15 3.15c0.352 0.352 0.923 0.352 1.27 0 0.352-0.352 0.352-0.923 0-1.27l-2.51-2.51 2.51-2.51c0.352-0.352 0.352-0.923 0-1.27z"/></svg>'
},
{
type: 'menu_item',
label: 'Plain Text Mode',
id: 'menu-item-mode-plain',
cmd: () => this.setMode('plain'),
showInPlainMode: true,
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m2 2v3h1v-2h2v-1zm13 0v1h2v2h1v-3zm-9 3v2h3v8h2v-8h3v-2zm-4 10v3h3v-1h-2v-2zm15 0v2h-2v1h3v-3z"/></svg>'
}
]
}
];
// // clear: {
// // removeStyle: {
// // html: '⎚',
// // cmd: () => squire.setStyle()
// // }
// // }
dispatchEvent(new CustomEvent('squire2-toolbar', {
detail: {
squire: this,
actions: actions
}
}));
this.indicators = this.#addActionsToParent(actions, toolbar);
return actions;
}
#makeClr() {
/**@type {HTMLInputElement} clr*/
const clr = createElement('input');
clr.type = 'color';
// Chrome https://github.com/the-djmaze/snappymail/issues/1199
let clrid = 'squire-colors',
colorlist = doc.getElementById(clrid),
add = hex => colorlist.append(new Option(hex));
if (!colorlist) {
colorlist = createElement('datalist');
colorlist.id = clrid;
// Color blind safe Tableau 10 by Maureen Stone
add('#4E79A7');
add('#F28E2B');
add('#E15759');
add('#76B7B2');
add('#59A14F');
add('#EDC948');
add('#B07AA1');
add('#FF9DA7');
add('#9C755F');
add('#BAB0AC');
doc.body.append(colorlist);
}
clr.setAttribute('list', clrid);
return clr;
}
/**
* @param {Array} items
* @param {HTMLElement} parent
*/
#addActionsToParent(items, parent) {
const indicators = [];
items.forEach(item => {
let element, event;
switch (item.type) {
case 'group':
const group = createElement('div');
group.className = 'btn-group';
if (!item.showInPlainMode) {
group.className += ' squire-html-mode-item';
}
if (item.items) {
indicators.push(...this.#addActionsToParent(item.items, group));
}
parent.append(group);
return indicators;
case 'menu':
case 'menu_more':
const menuWrap = document.createElement('div');
menuWrap.className = 'btn-group dropdown squire-toolbar-menu-wrap';
menuWrap.title = item.label;
if (!item.showInPlainMode) {
menuWrap.className += ' squire-html-mode-item';
}
const menuBtn = document.createElement('a');
menuBtn.className = 'btn dropdown-toggle';
if (item.icon !== '') {
menuBtn.innerHTML = item.icon;
menuBtn.firstElementChild.setAttribute('class', 'squire-toolbar-svg-icon');
} else {
menuBtn.className += ' fontastic';
menuBtn.textContent = '☰';
}
menuWrap.appendChild(menuBtn);
const menu = document.createElement('ul');
menu.className = 'dropdown-menu squire-toolbar-menu';
if (item.rightEdge) {
menu.className += ' right-edge';
}
menu.setAttribute('role', 'menu');
if (item.items) {
indicators.push(...this.#addActionsToParent(item.items, menu));
}
menuWrap.appendChild(menu);
parent.append(menuWrap);
ko.applyBindingAccessorsToNode(menuWrap, { registerBootstrapDropdown: true });
item.element = menuWrap;
return indicators;
case 'move_parent':
// we only move into main composer not the signature composer
if (this.container.className.indexOf('e-signature-place') === -1) {
element = doc.getElementById(item.id);
if (element) {
element.className = 'btn';
if (item.icon) {
element.innerHTML = item.icon;
element.firstElementChild.setAttribute('class', 'squire-toolbar-svg-icon');
}
if (item.label) {
element.title = item.label;
}
element.parentElement.className += ' ' + item.id + '-parent';
parent.append(element.parentElement);
}
}
return [];
case 'button':
element = createElement('button');
element.type = 'button';
element.className = 'btn';
element.innerHTML = item.icon;
element.firstElementChild.setAttribute('class', 'squire-toolbar-svg-icon');
event = 'click';
break;
case 'select':
element = createElement('select');
element.className = 'btn';
element.innerHTML = item.icon;
event = 'input';
if (Array.isArray(item.items)) {
item.items.forEach(value => {
value = Array.isArray(value) ? value : [value, value];
const option = new Option(value[0], value[1]);
option.style[item.prop] = value[1];
element.append(option);
});
} else {
Object.entries(item.items).forEach(([label, options]) => {
const optgroup = createElement('optgroup');
optgroup.label = label;
Object.entries(options).forEach(([text, value]) => {
const option = new Option(text, value);
option.style[item.prop] = value;
optgroup.append(option);
});
element.append(optgroup);
});
}
if (item.defaultValueIndex) {
element.selectedIndex = item.defaultValueIndex;
}
item.element = element;
break;
case 'menu_item':
element = createElement('li');
element.className = 'squire-toolbar-menu-item';
if (!item.showInPlainMode) {
element.className += ' squire-html-mode-item';
}
element.innerHTML = item.icon + '<span>' + item.label + '</span>';
element.firstElementChild.setAttribute('class', 'squire-toolbar-svg-icon squire-toolbar-menu-item-icon');
event = 'click';
break;
}
element.title = item.label + (item.key ? ' (' + ctrlKey + item.key + ')' : '');
element.tabIndex = -1;
element.addEventListener(event, () => item.cmd(element));
if (item.id) {
element.id = item.id;
}
if (item.matches) {
indicators.push({
element: element,
selectors: item.matches.split(',')
});
}
parent.append(element);
});
return indicators;
}
/**
* Plugins might add their own pathChange listeners therefore they should
* use this utility function. @see example below
* @param {Object} eventDetail detail of pathChange event
* @returns {Map<any, any>}
*/
buildTokensMap(eventDetail) {
if (!eventDetail.tokensMap) {
const tokensMap = new Map();
if (eventDetail.path !== '(selection)') {
window.parsel.tokenize(eventDetail.path).forEach(token => {
if (token.type === 'type') {
// token.name is a tag like B, I, UL, etc...
tokensMap.set(token.name, '1');
} else if (token.name === 'fontFamily') {
// token.value can be a string like '"LucidaSansUnicode","DejaVuSans","BitstreamVeraSans",sans-serif'
tokensMap.set('__font_family__', token.value);
} else if (token.name === 'fontSize') {
// token.value can be a string like '24px' or 'Large'
tokensMap.set('__font_size__', token.value);
}
});
} else {
tokensMap.set('__selection__', '1');
}
eventDetail.tokensMap = tokensMap;
}
return eventDetail.tokensMap;
}
doAction(name, tag) {
if (tag && this.squire.hasFormat(tag)) {
// ex: bold -> removeBold
name = 'remove' + name.charAt(0).toUpperCase() + name.slice(1);
}
this.squire[name]();
}
doList(type) {
if (this.squire.hasFormat(type)) {
this.squire.removeList();
return;
}
if (type === 'UL') {
this.squire.makeUnorderedList();
} else if (type === 'OL') {
this.squire.makeOrderedList();
}
}
changeLevel(incDec) {
const type = ['UL', 'OL'].some(listTag => this.squire.hasFormat(listTag))
? 'List'
: 'Quote';
this.squire[incDec + type + 'Level']();
}
/*
testPresenceinSelection(format, validation) {
return validation.test(this.squire.getPath()) || this.squire.hasFormat(format);
}
*/
setMode(mode) {
if (this.mode !== mode) {
let cl = this.container.classList,
source = 'source' === this.mode;
cl.remove('squire2-mode-' + this.mode);
if ('plain' === mode) {
this.plain.value = htmlToPlain(source ? this.plain.value : this.squire.getHTML(), true);
this.toolbar.classList.add('mode-plain');
} else if ('source' === mode) {
this.plain.value = this.squire.getHTML();
this.toolbar.classList.add('mode-plain');
} else {
this.setData(source ? this.plain.value : plainToHtml(this.plain.value, true));
mode = 'wysiwyg';
this.toolbar.classList.remove('mode-plain');
}
doc.getElementById('menu-item-mode-' + this.mode)?.classList.remove('active');
doc.getElementById('menu-item-mode-' + mode).classList.add('active');
this.mode = mode;
cl.add('squire2-mode-' + mode);
this.onModeChange?.();
setTimeout(() => this.focus(), 1);
}
}
on(type, fn) {
if ('mode' === type) {
this.onModeChange = fn;
} else {
this.squire.addEventListener(type, fn);
this.plain.addEventListener(type, fn);
}
}
execCommand(cmd, cfg) {
if ('insertSignature' === cmd) {
cfg = Object.assign({
clearCache: false,
isHtml: false,
insertBefore: false,
signature: ''
}, cfg);
if (cfg.clearCache) {
this._prev_txt_sig = null;
} else try {
const signature = cfg.isHtml ? htmlToPlain(cfg.signature) : cfg.signature;
if ('plain' === this.mode) {
let
text = this.plain.value,
prevSignature = this._prev_txt_sig;
if (prevSignature) {
text = text.replace(prevSignature, '').trim();
}
this.plain.value = cfg.insertBefore ? '\n\n' + signature + '\n\n' + text : text + '\n\n' + signature;
} else {
const squire = this.squire,
root = squire.getRoot(),
div = createElement('div');
div.className = 'rl-signature';
div.innerHTML = cfg.isHtml ? cfg.signature : plainToHtml(cfg.signature);
root.querySelectorAll('div.rl-signature').forEach(node => node.remove());
cfg.insertBefore ? root.prepend(div) : root.append(div);
// Move cursor above signature
for (let i = 0; i < 2; i++) {
const divbr = createElement('div');
divbr.append(createElement('br'));
div.before(divbr);
}
}
this._prev_txt_sig = signature;
} catch (e) {
console.error(e);
}
}
}
getData() {
return 'source' === this.mode ? this.plain.value : trimLines(this.squire.getHTML());
}
setData(html) {
// this.plain.value = html;
const squire = this.squire;
squire.setHTML(trimLines(html));
const node = squire.getRoot(),
range = squire.getSelection();
range.setStart(node, 0);
range.setEnd(node, 0);
squire.setSelection(range);
}
getPlainData() {
return this.plain.value;
}
setPlainData(text) {
this.plain.value = text;
}
blur() {
this.squire.blur();
}
focus() {
if ('wysiwyg' === this.mode) {
this.squire.focus();
} else {
this.plain.focus();
this.plain.setSelectionRange(0, 0);
}
}
}
if (rl) {
rl.registerWYSIWYG('CompactComposer', (owner, container, onReady) => {
const editor = new CompactComposer(container);
onReady(editor);
});
}
})(window);

View file

@ -0,0 +1,413 @@
var parsel = (function (exports) {
'use strict';
const TOKENS = {
attribute: /\[\s*(?:(?<namespace>\*|[-\w\P{ASCII}]*)\|)?(?<name>[-\w\P{ASCII}]+)\s*(?:(?<operator>\W?=)\s*(?<value>.+?)\s*(\s(?<caseSensitive>[iIsS]))?\s*)?\]/gu,
id: /#(?<name>[-\w\P{ASCII}]+)/gu,
class: /\.(?<name>[-\w\P{ASCII}]+)/gu,
comma: /\s*,\s*/g,
combinator: /\s*[\s>+~]\s*/g,
'pseudo-element': /::(?<name>[-\w\P{ASCII}]+)(?:\((?<argument>¶*)\))?/gu,
'pseudo-class': /:(?<name>[-\w\P{ASCII}]+)(?:\((?<argument>¶*)\))?/gu,
universal: /(?:(?<namespace>\*|[-\w\P{ASCII}]*)\|)?\*/gu,
type: /(?:(?<namespace>\*|[-\w\P{ASCII}]*)\|)?(?<name>[-\w\P{ASCII}]+)/gu, // this must be last
};
const TRIM_TOKENS = new Set(['combinator', 'comma']);
const RECURSIVE_PSEUDO_CLASSES = new Set([
'not',
'is',
'where',
'has',
'matches',
'-moz-any',
'-webkit-any',
'nth-child',
'nth-last-child',
]);
const nthChildRegExp = /(?<index>[\dn+-]+)\s+of\s+(?<subtree>.+)/;
const RECURSIVE_PSEUDO_CLASSES_ARGS = {
'nth-child': nthChildRegExp,
'nth-last-child': nthChildRegExp,
};
const getArgumentPatternByType = (type) => {
switch (type) {
case 'pseudo-element':
case 'pseudo-class':
return new RegExp(TOKENS[type].source.replace('(?<argument>¶*)', '(?<argument>.*)'), 'gu');
default:
return TOKENS[type];
}
};
function gobbleParens(text, offset) {
let nesting = 0;
let result = '';
for (; offset < text.length; offset++) {
const char = text[offset];
switch (char) {
case '(':
++nesting;
break;
case ')':
--nesting;
break;
}
result += char;
if (nesting === 0) {
return result;
}
}
return result;
}
function tokenizeBy(text, grammar = TOKENS) {
if (!text) {
return [];
}
const tokens = [text];
for (const [type, pattern] of Object.entries(grammar)) {
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (typeof token !== 'string') {
continue;
}
pattern.lastIndex = 0;
const match = pattern.exec(token);
if (!match) {
continue;
}
const from = match.index - 1;
const args = [];
const content = match[0];
const before = token.slice(0, from + 1);
if (before) {
args.push(before);
}
args.push({
...match.groups,
type,
content,
});
const after = token.slice(from + content.length + 1);
if (after) {
args.push(after);
}
tokens.splice(i, 1, ...args);
}
}
let offset = 0;
for (const token of tokens) {
switch (typeof token) {
case 'string':
throw new Error(`Unexpected sequence ${token} found at index ${offset}`);
case 'object':
offset += token.content.length;
token.pos = [offset - token.content.length, offset];
if (TRIM_TOKENS.has(token.type)) {
token.content = token.content.trim() || ' ';
}
break;
}
}
return tokens;
}
const STRING_PATTERN = /(['"])([^\\\n]+?)\1/g;
const ESCAPE_PATTERN = /\\./g;
function tokenize(selector, grammar = TOKENS) {
// Prevent leading/trailing whitespaces from being interpreted as combinators
selector = selector.trim();
if (selector === '') {
return [];
}
const replacements = [];
// Replace escapes with placeholders.
selector = selector.replace(ESCAPE_PATTERN, (value, offset) => {
replacements.push({ value, offset });
return '\uE000'.repeat(value.length);
});
// Replace strings with placeholders.
selector = selector.replace(STRING_PATTERN, (value, quote, content, offset) => {
replacements.push({ value, offset });
return `${quote}${'\uE001'.repeat(content.length)}${quote}`;
});
// Replace parentheses with placeholders.
{
let pos = 0;
let offset;
while ((offset = selector.indexOf('(', pos)) > -1) {
const value = gobbleParens(selector, offset);
replacements.push({ value, offset });
selector = `${selector.substring(0, offset)}(${'¶'.repeat(value.length - 2)})${selector.substring(offset + value.length)}`;
pos = offset + value.length;
}
}
// Now we have no nested structures and we can parse with regexes
const tokens = tokenizeBy(selector, grammar);
// Replace placeholders in reverse order.
const changedTokens = new Set();
for (const replacement of replacements.reverse()) {
for (const token of tokens) {
const { offset, value } = replacement;
if (!(token.pos[0] <= offset &&
offset + value.length <= token.pos[1])) {
continue;
}
const { content } = token;
const tokenOffset = offset - token.pos[0];
token.content =
content.slice(0, tokenOffset) +
value +
content.slice(tokenOffset + value.length);
if (token.content !== content) {
changedTokens.add(token);
}
}
}
// Update changed tokens.
for (const token of changedTokens) {
const pattern = getArgumentPatternByType(token.type);
if (!pattern) {
throw new Error(`Unknown token type: ${token.type}`);
}
pattern.lastIndex = 0;
const match = pattern.exec(token.content);
if (!match) {
throw new Error(`Unable to parse content for ${token.type}: ${token.content}`);
}
Object.assign(token, match.groups);
}
return tokens;
}
/**
* Convert a flat list of tokens into a tree of complex & compound selectors
*/
function nestTokens(tokens, { list = true } = {}) {
if (list && tokens.find((t) => t.type === 'comma')) {
const selectors = [];
const temp = [];
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type === 'comma') {
if (temp.length === 0) {
throw new Error('Incorrect comma at ' + i);
}
selectors.push(nestTokens(temp, { list: false }));
temp.length = 0;
}
else {
temp.push(tokens[i]);
}
}
if (temp.length === 0) {
throw new Error('Trailing comma');
}
else {
selectors.push(nestTokens(temp, { list: false }));
}
return { type: 'list', list: selectors };
}
for (let i = tokens.length - 1; i >= 0; i--) {
let token = tokens[i];
if (token.type === 'combinator') {
let left = tokens.slice(0, i);
let right = tokens.slice(i + 1);
return {
type: 'complex',
combinator: token.content,
left: nestTokens(left),
right: nestTokens(right),
};
}
}
switch (tokens.length) {
case 0:
throw new Error('Could not build AST.');
case 1:
// If we're here, there are no combinators, so it's just a list.
return tokens[0];
default:
return {
type: 'compound',
list: [...tokens], // clone to avoid pointers messing up the AST
};
}
}
/**
* Traverse an AST in depth-first order
*/
function* flatten(node,
/**
* @internal
*/
parent) {
switch (node.type) {
case 'list':
for (let child of node.list) {
yield* flatten(child, node);
}
break;
case 'complex':
yield* flatten(node.left, node);
yield* flatten(node.right, node);
break;
case 'compound':
yield* node.list.map((token) => [token, node]);
break;
default:
yield [node, parent];
}
}
/**
* Traverse an AST (or part thereof), in depth-first order
*/
function walk(node, visit,
/**
* @internal
*/
parent) {
if (!node) {
return;
}
for (const [token, ast] of flatten(node, parent)) {
visit(token, ast);
}
}
/**
* Parse a CSS selector
*
* @param selector - The selector to parse
* @param options.recursive - Whether to parse the arguments of pseudo-classes like :is(), :has() etc. Defaults to true.
* @param options.list - Whether this can be a selector list (A, B, C etc). Defaults to true.
*/
function parse(selector, { recursive = true, list = true } = {}) {
const tokens = tokenize(selector);
if (!tokens) {
return;
}
const ast = nestTokens(tokens, { list });
if (!recursive) {
return ast;
}
for (const [token] of flatten(ast)) {
if (token.type !== 'pseudo-class' || !token.argument) {
continue;
}
if (!RECURSIVE_PSEUDO_CLASSES.has(token.name)) {
continue;
}
let argument = token.argument;
const childArg = RECURSIVE_PSEUDO_CLASSES_ARGS[token.name];
if (childArg) {
const match = childArg.exec(argument);
if (!match) {
continue;
}
Object.assign(token, match.groups);
argument = match.groups['subtree'];
}
if (!argument) {
continue;
}
Object.assign(token, {
subtree: parse(argument, {
recursive: true,
list: true,
}),
});
}
return ast;
}
/**
* Converts the given list or (sub)tree to a string.
*/
function stringify(listOrNode) {
let tokens;
if (Array.isArray(listOrNode)) {
tokens = listOrNode;
}
else {
tokens = [...flatten(listOrNode)].map(([token]) => token);
}
return tokens.map(token => token.content).join('');
}
/**
* To convert the specificity array to a number
*/
function specificityToNumber(specificity, base) {
base = base || Math.max(...specificity) + 1;
return (specificity[0] * (base << 1) + specificity[1] * base + specificity[2]);
}
/**
* Calculate specificity of a selector.
*
* If the selector is a list, the max specificity is returned.
*/
function specificity(selector) {
let ast = selector;
if (typeof ast === 'string') {
ast = parse(ast, { recursive: true });
}
if (!ast) {
return [];
}
if (ast.type === 'list' && 'list' in ast) {
let base = 10;
const specificities = ast.list.map((ast) => {
const sp = specificity(ast);
base = Math.max(base, ...specificity(ast));
return sp;
});
const numbers = specificities.map((ast) => specificityToNumber(ast, base));
return specificities[numbers.indexOf(Math.max(...numbers))];
}
const ret = [0, 0, 0];
for (const [token] of flatten(ast)) {
switch (token.type) {
case 'id':
ret[0]++;
break;
case 'class':
case 'attribute':
ret[1]++;
break;
case 'pseudo-element':
case 'type':
ret[2]++;
break;
case 'pseudo-class':
if (token.name === 'where') {
break;
}
if (!RECURSIVE_PSEUDO_CLASSES.has(token.name) ||
!token.subtree) {
ret[1]++;
break;
}
const sub = specificity(token.subtree);
sub.forEach((s, i) => (ret[i] += s));
// :nth-child() & :nth-last-child() add (0, 1, 0) to the specificity of their most complex selector
if (token.name === 'nth-child' ||
token.name === 'nth-last-child') {
ret[1]++;
}
}
}
return ret;
}
exports.RECURSIVE_PSEUDO_CLASSES = RECURSIVE_PSEUDO_CLASSES;
exports.RECURSIVE_PSEUDO_CLASSES_ARGS = RECURSIVE_PSEUDO_CLASSES_ARGS;
exports.TOKENS = TOKENS;
exports.TRIM_TOKENS = TRIM_TOKENS;
exports.flatten = flatten;
exports.gobbleParens = gobbleParens;
exports.parse = parse;
exports.specificity = specificity;
exports.specificityToNumber = specificityToNumber;
exports.stringify = stringify;
exports.tokenize = tokenize;
exports.tokenizeBy = tokenizeBy;
exports.walk = walk;
Object.defineProperty(exports, '__esModule', { value: true });
return exports;
}({}));

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -0,0 +1,238 @@
<header class="g-ui-user-select-none" data-bind="css: {loading: saving() || sending()}">
<a class="btn" data-bind="command: sendCommand, tooltipErrorTip: sendErrorDesc, css: {'btn-success': sendButtonSuccess, 'btn-danger': sendError, 'btn-warning': sendSuccessButSaveError }">
<i data-bind="css: {'icon-paper-plane': !sending(), 'icon-spinner': sending()}"></i>
<span class="hide-mobile" data-i18n="COMPOSE/BUTTON_SEND"></span>
</a>
<a class="btn button-save" data-bind="command: saveCommand, tooltipErrorTip: savedErrorDesc, css: {'btn-danger': savedError }">
<i class="fontastic" data-bind="css: {'icon-spinner': saving()}">💾</i>
<span class="hide-mobile" data-i18n="GLOBAL/SAVE"></span>
</a>
<a class="btn btn-danger button-delete fontastic" data-bind="command: deleteCommand">🗑</a>
<span class="saved-text hide-mobile" data-bind="text: savedTimeText"></span>
<div class="pull-right">
<a class="btn hide-mobile" data-i18n="GLOBAL/BCC" data-bind="visible: !showBcc(), toggle: showBcc"></a>
<a class="btn hide-mobile" data-i18n="GLOBAL/CC" data-bind="visible: !showCc(), toggle: showCc"></a>
<a class="btn fontastic" data-bind="visible: allowContacts, command: contactsCommand" data-i18n="[title]GLOBAL/CONTACTS">📇</a>
<div class="btn-group dropdown" data-bind="registerBootstrapDropdown: true" style="display:inline-block;vertical-align:top">
<a class="btn dropdown-toggle fontastic"></a>
<menu class="dropdown-menu right-edge" role="menu">
<li data-bind="toggle: showBcc">
<a>
<i class="fontastic" data-bind="text: showBcc() ? '☑' : '☐'"></i>
<span data-i18n="GLOBAL/BCC"></span>
</a>
</li>
<li data-bind="toggle: showCc">
<a>
<i class="fontastic" data-bind="text: showCc() ? '☑' : '☐'"></i>
<span data-i18n="GLOBAL/CC"></span>
</a>
</li>
<li data-bind="toggle: showReplyTo">
<a>
<i class="fontastic" data-bind="text: showReplyTo() ? '☑' : '☐'"></i>
<span data-i18n="GLOBAL/REPLY_TO"></span>
</a>
</li>
<li data-bind="toggle: requestReadReceipt">
<a>
<i class="fontastic" data-bind="text: requestReadReceipt() ? '☑' : '☐'"></i>
<span data-i18n="COMPOSE/BUTTON_REQUEST_READ_RECEIPT"></span>
</a>
</li>
<li data-bind="toggle: requestDsn">
<a>
<i class="fontastic" data-bind="text: requestDsn() ? '☑' : '☐'"></i>
<span data-i18n="COMPOSE/BUTTON_REQUEST_DSN"></span>
</a>
</li>
<li data-bind="toggle: requireTLS">
<a>
<i class="fontastic" data-bind="text: requireTLS() ? '☑' : '☐'"></i>
<span data-i18n="COMPOSE/BUTTON_REQUIRE_TLS"></span>
</a>
</li>
<li data-bind="toggle: markAsImportant">
<a>
<i class="fontastic" data-bind="text: markAsImportant() ? '☑' : '☐'"></i>
<span data-i18n="COMPOSE/BUTTON_MARK_AS_IMPORTANT"></span>
</a>
</li>
<li data-bind="toggle: doSign, visible: canSign">
<a>
<i class="fontastic" data-bind="text: doSign() ? '☑' : '☐'"></i>
<span data-icon="✍" data-i18n="CRYPTO/SIGN"></span>
</a>
</li>
<li data-bind="toggle: doEncrypt, visible: canEncrypt">
<a>
<i class="fontastic" data-bind="text: doEncrypt() || 'mailvelope' == viewArea() ? '☑' : '☐'"></i>
<span data-icon="🔒" data-i18n="CRYPTO/ENCRYPT"></span>
</a>
</li>
</menu>
</div>
<a class="minimize-custom" data-bind="click: skipCommand" data-i18n="[title]COMPOSE/BUTTON_MINIMIZE"></a>
<a class="close" data-bind="click: tryToClose" data-i18n="[title]GLOBAL/CANCEL">×</a>
</div>
</header>
<div class="modal-body">
<div class="b-header g-ui-user-select-none">
<table>
<tr>
<td data-i18n="GLOBAL/FROM"></td>
<td>
<!-- ko if: allowIdentities -->
<input type="text" data-bind="textInput: from" style="width:calc(100% - 20px)">
<!-- /ko -->
<span class="e-identity" data-bind="hidden: allowIdentities, text: from"></span>
<!-- ko if: 1 < identitiesOptions().length -->
<div class="dropdown" style="display:inline-block" data-bind="registerBootstrapDropdown: true, initDom: identitiesMenu">
<a class="dropdown-toggle" href="#" tabindex="-1" id="identity-toggle" role="button"></a>
<menu class="dropdown-menu right-edge" role="menu" aria-labelledby="identity-toggle" data-bind="foreach: identitiesOptions">
<li role="presentation">
<a tabindex="-1" href="#" data-bind="click: function (oIdentity) { $root.selectIdentity(oIdentity); return true; }, text: optText"></a>
</li>
</menu>
</div>
<!-- /ko -->
</td>
</tr>
<tr>
<td>
<label data-bind="css: {'error-to': emptyToError}, tooltipErrorTip: emptyToErrorTooltip"
data-i18n="GLOBAL/TO"></label>
</td>
<td>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-bind="emailsTags: to, autoCompleteSource: emailsSource">
</td>
</tr>
<tr class="cc-row" data-bind="visible: showCc">
<td data-i18n="GLOBAL/CC"></td>
<td>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-bind="emailsTags: cc, autoCompleteSource: emailsSource">
</td>
</tr>
<tr class="bcc-row" data-bind="visible: showBcc">
<td data-i18n="GLOBAL/BCC"></td>
<td>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-bind="emailsTags: bcc, autoCompleteSource: emailsSource">
</td>
</tr>
<tr class="reply-to-row" data-bind="visible: showReplyTo">
<td data-i18n="GLOBAL/REPLY_TO"></td>
<td>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-bind="emailsTags: replyTo">
</td>
</tr>
<tr>
<td data-i18n="GLOBAL/SUBJECT"></td>
<td>
<input type="text" name="subject" autocomplete="off" data-bind="textInput: subject, attr:{spellcheck:allowSpellcheck()?'true':'false'}">
</td>
</tr>
</table>
<div style="display:flex">
<div class="btn-group" style="flex-grow:1"></div>
<div class="btn-group">
<a class="btn fontastic" data-bind="toggle: doSign, visible: canSign, css: {'btn-success': doSign()}" data-i18n="[title]CRYPTO/SIGN">
</a>
<a class="btn fontastic" data-bind="toggle: doEncrypt, visible: canEncrypt, css: {'btn-success': doEncrypt() || 'mailvelope' == viewArea()}" data-i18n="[title]CRYPTO/ENCRYPT">
🔒
</a>
</div>
<div class="btn-group">
<a class="btn fontastic" style="padding-left: 10px; padding-right: 10px;" id="composeUploadButton"
data-bind="visible: addAttachmentEnabled()" data-i18n="[title]COMPOSE/ATTACH_FILES">
⁺📎
</a>
</div>
</div>
</div>
<div class="tabs">
<input type="radio" name="tabs" value="body" id="tab-body" data-bind="checked: viewArea">
<label for="tab-body"
role="tab"
aria-selected="true"
aria-controls="panel1"
data-bind="visible: canMailvelope"
tabindex="0">
<i class="icon-file-text"></i>
<span data-i18n="GLOBAL/TEXT"></span>
</label>
<div class="tab-content" role="tabpanel" aria-hidden="false" style="grid-column-end:3">
<div class="textAreaParent" data-bind="initDom: editorArea, attr:{spellcheck:allowSpellcheck()?'true':'false'}"></div>
</div>
<!-- <input type="radio" name="tabs" value="attachments" id="tab-attachments" data-bind="checked: viewArea">-->
<!-- <label for="tab-attachments"-->
<!-- role="tab"-->
<!-- aria-selected="false"-->
<!-- aria-controls="panel2"-->
<!-- tabindex="0"-->
<!-- data-bind="-->
<!-- css: { 'btn-danger': attachmentsInErrorCount() },-->
<!-- tooltipErrorTip: attachmentsErrorTooltip">-->
<!-- <b data-bind="visible: attachmentsCount, text: attachmentsCount"></b>-->
<!-- <i data-bind="css: { 'icon-attachment': !attachmentsInProcessCount(), 'icon-spinner': attachmentsInProcessCount()}"></i>-->
<!-- <span data-i18n="GLOBAL/ATTACHMENTS"></span>-->
<!-- </label>-->
<!-- <div class="tab-content attachmentAreaParent" role="tabpanel" aria-hidden="true" style="grid-column-end:4">-->
<!-- <div class="b-attachment-place" data-bind="visible: addAttachmentEnabled() && dragAndDropVisible(), css: {dragAndDropOver: dragAndDropOver}"-->
<!-- data-i18n="COMPOSE/ATTACH_DROP_FILES_DESC"></div>-->
<!-- <ul class="attachmentList" data-bind="foreach: attachments">-->
<!-- <li class="attachmentItem" data-bind="attr: { title: title }, css: { waiting: waiting, error: '' !== error() }">-->
<!-- <div class="attachmentIcon">-->
<!-- <i class="iconMain" data-bind="css: iconClass, visible: !uploading() || 0 === progress()"></i>-->
<!-- <div class="iconProgress" data-bind="attr: { style: progressStyle }, visible: uploading"></div>-->
<!-- <div class="iconBG" data-bind="text: progressText, visible: uploading"></div>-->
<!-- </div>-->
<!-- <div class="attachmentNameParent">-->
<!-- <a href="#" class="close pull-right" style="margin-top:-4px;" data-bind="click: cancel">×</a>-->
<!-- <div class="attachmentName" data-bind="text: fileName"></div>-->
<!-- <span class="attachmentSize" data-bind="text: friendlySize"></span>-->
<!-- </div>-->
<!-- </li>-->
<!-- </ul>-->
<!-- <div class="no-attachments-desc" data-bind="visible: 0 === attachments().length"-->
<!-- data-i18n="COMPOSE/NO_ATTACHMENTS_HERE_DESC"></div>-->
<!-- </div>-->
<input type="radio" name="tabs" value="mailvelope" id="tab-mailvelope" data-bind="checked: viewArea">
<label for="tab-mailvelope"
role="tab"
aria-selected="false"
aria-controls="panel3"
tabindex="0"
data-bind="visible: canMailvelope">
<i class="mailvelope-icon"></i>
<span>Mailvelope</span>
</label>
<div class="tab-content textAreaParent" id="mailvelope-editor" role="tabpanel" aria-hidden="true" style="grid-column-end:3" data-bind="visible: canMailvelope"></div>
</div>
</div>
<div class="attachmentAreaParent compact">
<div class="b-attachment-place" data-bind="visible: addAttachmentEnabled(), css: {dragAndDropOver: dragAndDropVisible}"
data-i18n="COMPOSE/ATTACH_DROP_FILES_DESC"></div>
<!-- <div class="b-attachment-place dragAndDropOver"-->
<!-- data-i18n="COMPOSE/ATTACH_DROP_FILES_DESC"></div>-->
<ul class="attachmentList" data-bind="foreach: attachments">
<li class="attachmentItem" data-bind="attr: { title: title }, css: { waiting: waiting, error: '' !== error() }">
<div class="attachmentIcon">
<i class="iconMain" data-bind="css: iconClass, visible: !uploading() || 0 === progress()"></i>
<div class="iconProgress" data-bind="attr: { style: progressStyle }, visible: uploading"></div>
<div class="iconBG" data-bind="text: progressText, visible: uploading"></div>
</div>
<div class="attachmentNameParent">
<a href="#" class="close pull-right" style="margin-top:-4px;" data-bind="click: cancel">×</a>
<div class="attachmentName" data-bind="text: fileName"></div>
<span class="attachmentSize" data-bind="text: friendlySize"></span>
</div>
</li>
</ul>
</div>