mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-07-09 06:28:28 +03:00
Merge pull request #1466 from SergeyMosin/compact-composer-plugin
compact-composer plugin v1.0.0
This commit is contained in:
commit
e880d0ce86
12 changed files with 5829 additions and 0 deletions
87
plugins/compact-composer/css/composer.css
Normal file
87
plugins/compact-composer/css/composer.css
Normal 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;
|
||||
}
|
||||
22
plugins/compact-composer/index.php
Normal file
22
plugins/compact-composer/index.php
Normal 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');
|
||||
}
|
||||
}
|
||||
967
plugins/compact-composer/js/CompactComposer.js
Normal file
967
plugins/compact-composer/js/CompactComposer.js
Normal 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);
|
||||
413
plugins/compact-composer/js/parsel.js
Normal file
413
plugins/compact-composer/js/parsel.js
Normal 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;
|
||||
|
||||
}({}));
|
||||
4102
plugins/compact-composer/js/squire-raw.js
Normal file
4102
plugins/compact-composer/js/squire-raw.js
Normal file
File diff suppressed because it is too large
Load diff
BIN
plugins/compact-composer/screenshots/Screenshot1.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
plugins/compact-composer/screenshots/Screenshot2.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
plugins/compact-composer/screenshots/Screenshot3.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
plugins/compact-composer/screenshots/Screenshot4.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
plugins/compact-composer/screenshots/Screenshot5.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
plugins/compact-composer/screenshots/Screenshot6.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
238
plugins/compact-composer/templates/PopupsCompose.html
Normal file
238
plugins/compact-composer/templates/PopupsCompose.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue