mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-12-27 02:23:28 +08:00
Add plaintext mail display and composition via new setting / Alt key #52
Most draft features are disabled in plaintext mode because I don’t think it’s worth trying to make them work unless this gains traction. Mostly doing this so we can add GPG / PGP / Keybase in the future.
This commit is contained in:
parent
6d78a90102
commit
ea37c75596
45 changed files with 781 additions and 255 deletions
|
@ -130,6 +130,9 @@ export default class SignatureComposerDropdown extends React.Component<
|
|||
}
|
||||
|
||||
render() {
|
||||
if (this.props.draft.plaintext) {
|
||||
return <span />;
|
||||
}
|
||||
return (
|
||||
<div className="signature-button-dropdown">
|
||||
<ButtonDropdown
|
||||
|
|
|
@ -3,8 +3,12 @@ import { applySignature } from './signature-utils';
|
|||
|
||||
export default class SignatureComposerExtension extends ComposerExtension {
|
||||
static prepareNewDraft = ({ draft }) => {
|
||||
const signatureObj =
|
||||
draft.from && draft.from[0] ? SignatureStore.signatureForEmail(draft.from[0].email) : null;
|
||||
if (draft.plaintext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = draft.from && draft.from[0];
|
||||
const signatureObj = from ? SignatureStore.signatureForEmail(from.email) : null;
|
||||
if (!signatureObj) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -67,7 +67,7 @@
|
|||
overflow: hidden;
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
|
||||
|
||||
&.empty {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
|
@ -91,12 +91,12 @@
|
|||
.section-header {
|
||||
margin-top: 10px;
|
||||
&:first-child {
|
||||
margin-top:0;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.raw-html {
|
||||
font-family: monospace;
|
||||
font-family: @font-family-monospace;
|
||||
font-size: 0.9em;
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
|
@ -129,10 +129,13 @@
|
|||
justify-content: center;
|
||||
white-space: initial;
|
||||
flex-shrink: 0;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
|
||||
|
||||
.preview {
|
||||
pointer-events: none;
|
||||
transform: scale(0.25);
|
||||
|
@ -162,7 +165,7 @@
|
|||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: @padding-base-horizontal 0;
|
||||
|
||||
|
||||
.field.photo-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
@ -178,7 +181,8 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover, &.dropping {
|
||||
&:hover,
|
||||
&.dropping {
|
||||
border: 1px solid @accent-primary-dark;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint jsx-a11y/tabindex-no-positive: 0 */
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { localized, PropTypes, Actions } from 'mailspring-exports';
|
||||
import { localized, PropTypes, Actions, Message } from 'mailspring-exports';
|
||||
import { Menu, RetinaImg } from 'mailspring-component-kit';
|
||||
import TemplateStore from './template-store';
|
||||
|
||||
|
@ -101,7 +101,10 @@ class TemplatePopover extends React.Component<{ headerMessageId: string }> {
|
|||
}
|
||||
}
|
||||
|
||||
class TemplatePicker extends React.Component<{ headerMessageId: string }> {
|
||||
class TemplatePicker extends React.Component<{
|
||||
headerMessageId: string;
|
||||
draft: Message;
|
||||
}> {
|
||||
static displayName = 'TemplatePicker';
|
||||
|
||||
static propTypes = {
|
||||
|
@ -117,6 +120,9 @@ class TemplatePicker extends React.Component<{ headerMessageId: string }> {
|
|||
};
|
||||
|
||||
render() {
|
||||
if (this.props.draft.plaintext) {
|
||||
return <span />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
|
|
|
@ -95,7 +95,7 @@
|
|||
}
|
||||
|
||||
textarea.raw-html {
|
||||
font-family: monospace;
|
||||
font-family: @font-family-monospace;
|
||||
font-size: 0.9em;
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
KeyCommandsRegion,
|
||||
InjectedComponentSet,
|
||||
ComposerEditor,
|
||||
ComposerEditorPlaintext,
|
||||
ComposerSupport,
|
||||
} from 'mailspring-component-kit';
|
||||
import { ComposerHeader } from './composer-header';
|
||||
|
@ -155,38 +156,54 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
|
|||
onMouseDown={this._onMouseDownComposerBody}
|
||||
>
|
||||
<div className="composer-body-wrap">
|
||||
<ComposerEditor
|
||||
ref={this.editor}
|
||||
value={draft.bodyEditorState}
|
||||
className={quotedTextHidden && 'hiding-quoted-text'}
|
||||
propsForPlugins={{ draft, session }}
|
||||
onFileReceived={this._onFileReceived}
|
||||
onUpdatedSlateEditor={editor => session.setMountedEditor(editor)}
|
||||
onDrop={e => this.dropzone.current._onDrop(e)}
|
||||
onChange={change => {
|
||||
// We minimize thrashing and support editors in multiple windows by ensuring
|
||||
// non-value changes (eg focus) to the editorState don't trigger database saves
|
||||
const skipSaving =
|
||||
change.operations.size &&
|
||||
change.operations.every(
|
||||
op =>
|
||||
op.type === 'set_selection' ||
|
||||
(op.type === 'set_value' &&
|
||||
Object.keys(op.properties).every(k => k === 'decorations'))
|
||||
);
|
||||
session.changes.add({ bodyEditorState: change.value }, { skipSaving });
|
||||
}}
|
||||
/>
|
||||
<QuotedTextControl
|
||||
quotedTextHidden={quotedTextHidden}
|
||||
quotedTextPresent={quotedTextPresent}
|
||||
onUnhide={() => this.setState({ quotedTextHidden: false })}
|
||||
onRemove={() => {
|
||||
this.setState({ quotedTextHidden: false }, () =>
|
||||
this.editor.current.removeQuotedText()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{draft.plaintext ? (
|
||||
<ComposerEditorPlaintext
|
||||
ref={this.editor}
|
||||
value={draft.body}
|
||||
propsForPlugins={{ draft, session }}
|
||||
onFileReceived={this._onFileReceived}
|
||||
onDrop={e => this.dropzone.current._onDrop(e)}
|
||||
onChange={body => {
|
||||
session.changes.add({ body });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ComposerEditor
|
||||
ref={this.editor}
|
||||
value={draft.bodyEditorState}
|
||||
className={quotedTextHidden && 'hiding-quoted-text'}
|
||||
propsForPlugins={{ draft, session }}
|
||||
onFileReceived={this._onFileReceived}
|
||||
onUpdatedSlateEditor={editor => session.setMountedEditor(editor)}
|
||||
onDrop={e => this.dropzone.current._onDrop(e)}
|
||||
onChange={change => {
|
||||
// We minimize thrashing and support editors in multiple windows by ensuring
|
||||
// non-value changes (eg focus) to the editorState don't trigger database saves
|
||||
const skipSaving =
|
||||
change.operations.size &&
|
||||
change.operations.every(
|
||||
op =>
|
||||
op.type === 'set_selection' ||
|
||||
(op.type === 'set_value' &&
|
||||
Object.keys(op.properties).every(k => k === 'decorations'))
|
||||
);
|
||||
session.changes.add({ bodyEditorState: change.value }, { skipSaving });
|
||||
}}
|
||||
/>
|
||||
<QuotedTextControl
|
||||
quotedTextHidden={quotedTextHidden}
|
||||
quotedTextPresent={quotedTextPresent}
|
||||
onUnhide={() => this.setState({ quotedTextHidden: false })}
|
||||
onRemove={() => {
|
||||
this.setState({ quotedTextHidden: false }, () =>
|
||||
this.editor.current.removeQuotedText()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AttachmentsArea draft={draft} />
|
||||
</div>
|
||||
<div className="composer-footer-region">
|
||||
|
@ -304,6 +321,8 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
|
|||
headerMessageId: this.props.draft.headerMessageId,
|
||||
onCreated: file => {
|
||||
if (!this._mounted) return;
|
||||
if (this.props.draft.plaintext) return;
|
||||
|
||||
if (Utils.shouldDisplayAsImage(file)) {
|
||||
const { draft, session } = this.props;
|
||||
const match = draft.files.find(f => f.id === file.id);
|
||||
|
|
|
@ -11,6 +11,23 @@
|
|||
|
||||
// Basic Rich Editor
|
||||
|
||||
.composer-editor-plaintext {
|
||||
textarea {
|
||||
padding: 13px 22px;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
min-height: @compose-min-height;
|
||||
color: @text-color;
|
||||
outline: none;
|
||||
font-family: @font-family-monospace;
|
||||
&:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.RichEditor-root {
|
||||
padding: 0 22px 0 22px;
|
||||
min-height: @compose-min-height;
|
||||
|
|
|
@ -46,6 +46,10 @@ export default class LinkTrackingButton extends React.Component<{
|
|||
}
|
||||
|
||||
render() {
|
||||
if (this.props.draft.plaintext) {
|
||||
return <span />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MetadataComposerToggleButton
|
||||
iconName="icon-composer-linktracking.png"
|
||||
|
|
|
@ -35,10 +35,13 @@ function forEachATagInBody(draftBodyRootNode, callback) {
|
|||
*/
|
||||
export default class LinkTrackingComposerExtension extends ComposerExtension {
|
||||
static needsPerRecipientBodies(draft) {
|
||||
return !!draft.metadataForPluginId(PLUGIN_ID);
|
||||
return !draft.plaintext && !!draft.metadataForPluginId(PLUGIN_ID);
|
||||
}
|
||||
|
||||
static applyTransformsForSending({ draftBodyRootNode, draft, recipient }) {
|
||||
if (draft.plaintext) {
|
||||
return;
|
||||
}
|
||||
const metadata = draft.metadataForPluginId(PLUGIN_ID);
|
||||
if (!metadata) {
|
||||
return;
|
||||
|
@ -52,9 +55,7 @@ export default class LinkTrackingComposerExtension extends ComposerExtension {
|
|||
return;
|
||||
}
|
||||
const encoded = encodeURIComponent(url);
|
||||
const redirectUrl = `${PLUGIN_URL}/link/${draft.headerMessageId}/${
|
||||
links.length
|
||||
}?redirect=${encoded}`;
|
||||
const redirectUrl = `${PLUGIN_URL}/link/${draft.headerMessageId}/${links.length}?redirect=${encoded}`;
|
||||
|
||||
links.push({
|
||||
url,
|
||||
|
|
|
@ -65,16 +65,6 @@ export default class EmailFrame extends React.Component<EmailFrameProps> {
|
|||
if (this._unlisten) this._unlisten();
|
||||
}
|
||||
|
||||
_emailContent = () => {
|
||||
// When showing quoted text, always return the pure content
|
||||
if (this.props.showQuotedText) {
|
||||
return this.props.content;
|
||||
}
|
||||
return QuotedHTMLTransformer.removeQuotedHTML(this.props.content, {
|
||||
keepIfWholeBodyIsQuote: true,
|
||||
});
|
||||
};
|
||||
|
||||
_writeContent = () => {
|
||||
if (this._iframeDocObserver) this._iframeDocObserver.disconnect();
|
||||
|
||||
|
@ -87,16 +77,35 @@ export default class EmailFrame extends React.Component<EmailFrameProps> {
|
|||
// message bodies. This is particularly felt with <table> elements use
|
||||
// the `border-collapse: collapse` css property while setting a
|
||||
// `padding`.
|
||||
const { message, showQuotedText } = this.props;
|
||||
const styles = EmailFrameStylesStore.styles();
|
||||
const restrictWidth = AppEnv.config.get('core.reading.restrictMaxWidth');
|
||||
|
||||
let content = this.props.content;
|
||||
if (!showQuotedText && !message.plaintext) {
|
||||
content = QuotedHTMLTransformer.removeQuotedHTML(content, {
|
||||
keepIfWholeBodyIsQuote: true,
|
||||
});
|
||||
}
|
||||
|
||||
doc.open();
|
||||
doc.write(
|
||||
`<!DOCTYPE html>` +
|
||||
(styles ? `<style>${styles}</style>` : '') +
|
||||
`<div id='inbox-html-wrapper' class="${process.platform}">${this._emailContent()}</div>`
|
||||
);
|
||||
doc.close();
|
||||
|
||||
if (message.plaintext) {
|
||||
doc.write(
|
||||
`<!DOCTYPE html>` +
|
||||
(styles ? `<style>${styles}</style>` : '') +
|
||||
`<div id='inbox-plain-wrapper' class="${process.platform}"></div>`
|
||||
);
|
||||
doc.close();
|
||||
doc.getElementById('inbox-plain-wrapper').innerText = content;
|
||||
} else {
|
||||
doc.write(
|
||||
`<!DOCTYPE html>` +
|
||||
(styles ? `<style>${styles}</style>` : '') +
|
||||
`<div id='inbox-html-wrapper' class="${process.platform}">${content}</div>`
|
||||
);
|
||||
doc.close();
|
||||
}
|
||||
|
||||
if (doc.body && restrictWidth) {
|
||||
doc.body.classList.add('restrict-width');
|
||||
|
@ -119,7 +128,7 @@ export default class EmailFrame extends React.Component<EmailFrameProps> {
|
|||
try {
|
||||
extension.renderedMessageBodyIntoDocument({
|
||||
document: doc,
|
||||
message: this.props.message,
|
||||
message: message,
|
||||
iframe: iframeEl,
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
|
@ -134,29 +134,31 @@ export default class MessageItemBody extends React.Component<
|
|||
let merged = body;
|
||||
|
||||
// Replace cid: references with the paths to downloaded files
|
||||
this.props.message.files.filter(f => f.contentId).forEach(file => {
|
||||
const download = this.props.downloads[file.id];
|
||||
const safeContentId = Utils.escapeRegExp(file.contentId);
|
||||
this.props.message.files
|
||||
.filter(f => f.contentId)
|
||||
.forEach(file => {
|
||||
const download = this.props.downloads[file.id];
|
||||
const safeContentId = Utils.escapeRegExp(file.contentId);
|
||||
|
||||
// Note: I don't like doing this with RegExp before the body is inserted into
|
||||
// the DOM, but we want to avoid "could not load cid://" in the console.
|
||||
// Note: I don't like doing this with RegExp before the body is inserted into
|
||||
// the DOM, but we want to avoid "could not load cid://" in the console.
|
||||
|
||||
if (download && download.state !== 'finished') {
|
||||
const inlineImgRegexp = new RegExp(
|
||||
`<\\s*img.*src=['"]cid:${safeContentId}['"][^>]*>`,
|
||||
'gi'
|
||||
);
|
||||
// Render a spinner
|
||||
merged = merged.replace(
|
||||
inlineImgRegexp,
|
||||
() =>
|
||||
'<img alt="spinner.gif" src="mailspring://message-list/assets/spinner.gif" style="-webkit-user-drag: none;">'
|
||||
);
|
||||
} else {
|
||||
const cidRegexp = new RegExp(`cid:${safeContentId}(@[^'"]+)?`, 'gi');
|
||||
merged = merged.replace(cidRegexp, `file://${AttachmentStore.pathForFile(file)}`);
|
||||
}
|
||||
});
|
||||
if (download && download.state !== 'finished') {
|
||||
const inlineImgRegexp = new RegExp(
|
||||
`<\\s*img.*src=['"]cid:${safeContentId}['"][^>]*>`,
|
||||
'gi'
|
||||
);
|
||||
// Render a spinner
|
||||
merged = merged.replace(
|
||||
inlineImgRegexp,
|
||||
() =>
|
||||
'<img alt="spinner.gif" src="mailspring://message-list/assets/spinner.gif" style="-webkit-user-drag: none;">'
|
||||
);
|
||||
} else {
|
||||
const cidRegexp = new RegExp(`cid:${safeContentId}(@[^'"]+)?`, 'gi');
|
||||
merged = merged.replace(cidRegexp, `file://${AttachmentStore.pathForFile(file)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Replace remaining cid: references - we will not display them since they'll
|
||||
// throw "unknown ERR_UNKNOWN_URL_SCHEME". Show a transparent pixel so that there's
|
||||
|
|
|
@ -218,7 +218,13 @@ class MessageList extends React.Component<{}, MessageListState> {
|
|||
);
|
||||
|
||||
if (isBeforeReplyArea) {
|
||||
elements.push(this._renderReplyArea());
|
||||
elements.push(
|
||||
<MessageListReplyArea
|
||||
key="reply-area"
|
||||
onClick={this._onClickReplyArea}
|
||||
replyType={this._replyType()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -365,17 +371,6 @@ class MessageList extends React.Component<{}, MessageListState> {
|
|||
);
|
||||
}
|
||||
|
||||
_renderReplyArea() {
|
||||
return (
|
||||
<div className="footer-reply-area-wrap" onClick={this._onClickReplyArea} key="reply-area">
|
||||
<div className="footer-reply-area">
|
||||
<RetinaImg name={`${this._replyType()}-footer.png`} mode={RetinaImg.Mode.ContentIsMask} />
|
||||
<span className="reply-text">{localized('Write a reply…')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
_renderMinifiedBundle(bundle) {
|
||||
const BUNDLE_HEIGHT = 36;
|
||||
const lines = bundle.messages.slice(0, 10);
|
||||
|
@ -450,4 +445,38 @@ class MessageList extends React.Component<{}, MessageListState> {
|
|||
}
|
||||
}
|
||||
|
||||
class MessageListReplyArea extends React.Component<{ onClick: () => void; replyType: string }> {
|
||||
state = {
|
||||
forcePlaintext: AppEnv.keymaps.getIsAltKeyDown(),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener(AppEnv.keymaps.EVENT_ALT_KEY_STATE_CHANGE, this.onAltStateChange);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener(AppEnv.keymaps.EVENT_ALT_KEY_STATE_CHANGE, this.onAltStateChange);
|
||||
}
|
||||
|
||||
onAltStateChange = () => {
|
||||
this.setState({ forcePlaintext: AppEnv.keymaps.getIsAltKeyDown() });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="footer-reply-area-wrap" onClick={this.props.onClick}>
|
||||
<div className="footer-reply-area">
|
||||
<RetinaImg
|
||||
name={`${this.props.replyType}-footer.png`}
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
<span className="reply-text">
|
||||
{this.state.forcePlaintext
|
||||
? localized('Write a plain text reply…')
|
||||
: localized('Write a reply…')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default SearchableComponentMaker.extend(MessageList);
|
||||
|
|
|
@ -35,8 +35,8 @@ window, but it uses a hot window which makes that difficult now. */
|
|||
|
||||
@text-color-heading: #434648;
|
||||
@font-family-sans-serif: 'Nylas-Pro', 'Helvetica', sans-serif;
|
||||
@font-family-serif: Georgia, "Times New Roman", Times, serif;
|
||||
@font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
@font-family-serif: Georgia, 'Times New Roman', Times, serif;
|
||||
@font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
|
||||
@font-family: @font-family-sans-serif;
|
||||
@font-family-heading: @font-family-sans-serif;
|
||||
|
|
|
@ -46,6 +46,9 @@ export default class OpenTrackingButton extends React.Component<{
|
|||
}
|
||||
|
||||
render() {
|
||||
if (this.props.draft.plaintext) {
|
||||
return <span />;
|
||||
}
|
||||
const enabledValue = {
|
||||
open_count: 0,
|
||||
open_data: [],
|
||||
|
|
|
@ -6,7 +6,7 @@ import { OpenTrackingMetadata } from './types';
|
|||
|
||||
export default class OpenTrackingComposerExtension extends ComposerExtension {
|
||||
static needsPerRecipientBodies(draft) {
|
||||
return !!draft.metadataForPluginId(PLUGIN_ID);
|
||||
return !draft.plaintext && !!draft.metadataForPluginId(PLUGIN_ID);
|
||||
}
|
||||
|
||||
static applyTransformsForSending({
|
||||
|
@ -18,6 +18,10 @@ export default class OpenTrackingComposerExtension extends ComposerExtension {
|
|||
draft: Message;
|
||||
recipient?: Contact;
|
||||
}) {
|
||||
if (draft.plaintext) {
|
||||
return;
|
||||
}
|
||||
|
||||
// grab message metadata, if any
|
||||
const messageUid = draft.headerMessageId;
|
||||
const metadata = draft.metadataForPluginId(PLUGIN_ID) as OpenTrackingMetadata;
|
||||
|
|
|
@ -58,12 +58,13 @@ class PreferencesGeneral extends React.Component<{
|
|||
render() {
|
||||
return (
|
||||
<div className="container-general">
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div style={{ width: '50%', paddingRight: 15 }}>
|
||||
<div className="two-columns-flexbox">
|
||||
<div style={{ flex: 1 }}>
|
||||
<WorkspaceSection config={this.props.config} configSchema={this.props.configSchema} />
|
||||
<LanguageSection config={this.props.config} configSchema={this.props.configSchema} />
|
||||
</div>
|
||||
<div style={{ width: '50%', paddingLeft: 15 }}>
|
||||
<div style={{ width: 30 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<ConfigSchemaItem
|
||||
configSchema={this.props.configSchema.properties.reading}
|
||||
keyName={localized('Reading')}
|
||||
|
@ -72,12 +73,12 @@ class PreferencesGeneral extends React.Component<{
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', paddingTop: 30 }}>
|
||||
<div style={{ width: '50%', paddingRight: 15 }}>
|
||||
<div className="two-columns-flexbox" style={{ paddingTop: 30 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<SendingSection config={this.props.config} configSchema={this.props.configSchema} />
|
||||
</div>
|
||||
|
||||
<div style={{ width: '50%', paddingLeft: 15 }}>
|
||||
<div style={{ width: 30 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<ConfigSchemaItem
|
||||
configSchema={this.props.configSchema.properties.composing}
|
||||
keyName={localized('Composing')}
|
||||
|
@ -87,22 +88,22 @@ class PreferencesGeneral extends React.Component<{
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', paddingTop: 30 }}>
|
||||
<div style={{ width: '50%', paddingRight: 15 }}>
|
||||
<div className="two-columns-flexbox" style={{ paddingTop: 30 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ConfigSchemaItem
|
||||
configSchema={this.props.configSchema.properties.notifications}
|
||||
keyName={localized('Notifications')}
|
||||
keyPath="core.notifications"
|
||||
config={this.props.config}
|
||||
/>
|
||||
|
||||
<div className="platform-note platform-linux-only">
|
||||
{localized(
|
||||
'Mailspring desktop notifications on Linux require Zenity. You may need to install it with your package manager.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '50%', paddingLeft: 15 }}>
|
||||
<div style={{ width: 30 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<ConfigSchemaItem
|
||||
configSchema={this.props.configSchema.properties.attachments}
|
||||
keyName={localized('Attachments')}
|
||||
|
|
|
@ -137,7 +137,7 @@ export default class PreferencesKeymaps extends React.Component<
|
|||
'You can choose a shortcut set to use keyboard shortcuts of familiar email clients. To edit a shortcut, click it in the list below and enter a replacement on the keyboard.'
|
||||
)}
|
||||
</p>
|
||||
<div className="keymaps-columns-flexbox">
|
||||
<div className="two-columns-flexbox">
|
||||
<div style={{ flex: 1 }}>
|
||||
{displayedKeybindings.slice(0, 3).map(this._renderBindingsSection)}
|
||||
</div>
|
||||
|
|
|
@ -192,6 +192,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
.container-general,
|
||||
.container-keymaps {
|
||||
.two-columns-flexbox {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.container-keymaps {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
|
@ -211,10 +218,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.keymaps-columns-flexbox {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.shortcut-section-title {
|
||||
border-bottom: 1px solid @border-color-divider;
|
||||
margin: @padding-large-vertical * 1.5 0;
|
||||
|
@ -224,7 +227,7 @@
|
|||
padding: 3px 0;
|
||||
color: @text-color-very-subtle;
|
||||
.values {
|
||||
font-family: monospace;
|
||||
font-family: @font-family-monospace;
|
||||
font-weight: 600;
|
||||
color: @text-color;
|
||||
display: inline-block;
|
||||
|
@ -305,8 +308,9 @@ body.platform-linux {
|
|||
|
||||
@media (max-width: 680px) {
|
||||
.preferences-wrap {
|
||||
.container-keymaps {
|
||||
.keymaps-columns-flexbox {
|
||||
.container-keymaps,
|
||||
.container-general {
|
||||
.two-columns-flexbox {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -114,7 +114,8 @@ h1,
|
|||
height: 60px;
|
||||
}
|
||||
|
||||
#inbox-html-wrapper {
|
||||
#inbox-html-wrapper,
|
||||
#inbox-plain-wrapper {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
|
@ -124,7 +125,8 @@ h1,
|
|||
.message-item-wrap,
|
||||
.message-item-white-wrap,
|
||||
.message-item-area,
|
||||
#inbox-html-wrapper {
|
||||
#inbox-html-wrapper,
|
||||
#inbox-plain-wrapper {
|
||||
display: block !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
|
|
|
@ -91,6 +91,9 @@ export class TranslateComposerButton extends React.Component<{
|
|||
// image from our package. `RetinaImg` will automatically chose the best image
|
||||
// format for our display.
|
||||
render() {
|
||||
if (this.props.draft.plaintext) {
|
||||
return <span />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
"core:next-item": "command+]",
|
||||
"core:select-down": "shift+command+]",
|
||||
|
||||
"core:reply": "mod+r",
|
||||
"core:reply-all": "mod+shift+r",
|
||||
"core:reply": ["mod+r", "mod+alt+r"],
|
||||
"core:reply-all": ["mod+shift+r", "mod+alt+shift+r"],
|
||||
"core:forward": "mod+shift+f",
|
||||
"core:report-as-spam": "mod+shift+j",
|
||||
"core:mark-as-unread": "mod+shift+u",
|
||||
|
|
|
@ -49,9 +49,9 @@
|
|||
"core:report-as-spam": "!",
|
||||
"core:delete-item": "#",
|
||||
|
||||
"core:reply": ["r", "mod+r"],
|
||||
"core:reply": ["r", "mod+r", "mod+alt+r"],
|
||||
"core:reply-new-window": "shift+r",
|
||||
"core:reply-all": ["a", "mod+shift+r"],
|
||||
"core:reply-all": ["a", "mod+shift+r", "mod+alt+shift+r"],
|
||||
"core:reply-all-new-window": "shift+a",
|
||||
"core:forward": ["f", "mod+shift+f"],
|
||||
"core:forward-new-window": "shift+f",
|
||||
|
|
|
@ -27,11 +27,11 @@
|
|||
"core:report-as-spam": "!",
|
||||
"core:delete-item": "#",
|
||||
|
||||
"core:reply": ["r", "mod+r"],
|
||||
"core:reply": ["r", "mod+r", "mod+alt+r"],
|
||||
"core:reply-new-window": "shift+r",
|
||||
"core:reply-all": ["a", "mod+shift+r"],
|
||||
"core:reply-all": ["a", "mod+shift+r", "mod+alt+shift+r"],
|
||||
"core:reply-all-new-window": "shift+a",
|
||||
"core:forward": ["f", "mod+shift+f"],
|
||||
"core:forward": ["f", "mod+shift+f", "mod+alt+shift+f"],
|
||||
"core:forward-new-window": "shift+f",
|
||||
|
||||
"core:remove-and-previous": ["}", "]"],
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
"core:delete-item": "mod+d",
|
||||
"core:undo": "alt+backspace",
|
||||
"composer:send-message": "alt+s",
|
||||
"core:reply": "mod+r",
|
||||
"core:reply-all": "mod+shift+r",
|
||||
"core:reply": ["mod+r", "mod+alt+r"],
|
||||
"core:reply-all": ["mod+shift+r", "mod+alt+shift+r"],
|
||||
"application:new-message": ["mod+n", "mod+shift+m"],
|
||||
|
||||
"core:find-in-thread": ["mod+shift+f", "f4"],
|
||||
|
|
|
@ -60,6 +60,8 @@ function toggleBlockTypeWithBreakout(editor: Editor, type) {
|
|||
}
|
||||
}
|
||||
|
||||
export const BLOCKQUOTE_TYPE = 'blockquote';
|
||||
|
||||
export const BLOCK_CONFIG: {
|
||||
[key: string]: IEditorToolbarConfigItem;
|
||||
} = {
|
||||
|
@ -91,7 +93,7 @@ export const BLOCK_CONFIG: {
|
|||
},
|
||||
},
|
||||
blockquote: {
|
||||
type: 'blockquote',
|
||||
type: BLOCKQUOTE_TYPE,
|
||||
tagNames: ['blockquote'],
|
||||
render: props => <blockquote {...props.attributes}>{props.children}</blockquote>,
|
||||
button: {
|
||||
|
@ -240,7 +242,7 @@ function renderNode(props, editor: Editor = null, next = () => {}) {
|
|||
|
||||
const rules = [
|
||||
{
|
||||
deserialize(el, next) {
|
||||
deserialize(el: HTMLElement, next) {
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
let config = Object.values(BLOCK_CONFIG).find(c => c.tagNames.includes(tagName));
|
||||
|
||||
|
@ -248,6 +250,7 @@ const rules = [
|
|||
// block elements with monospace font are translated to <code> blocks
|
||||
if (
|
||||
['div', 'blockquote'].includes(tagName) &&
|
||||
!el.classList.contains('gmail_default') &&
|
||||
(el.style.fontFamily || el.style.font || '').includes('monospace')
|
||||
) {
|
||||
config = BLOCK_CONFIG.code;
|
||||
|
@ -327,7 +330,7 @@ export function allNodesInBFSOrder(value: Value) {
|
|||
export function isQuoteNode(n: Node) {
|
||||
return (
|
||||
n.object === 'block' &&
|
||||
(n.type === 'blockquote' ||
|
||||
(n.type === BLOCKQUOTE_TYPE ||
|
||||
(n.data && n.data.get('className') && n.data.get('className').includes('gmail_quote')))
|
||||
);
|
||||
}
|
||||
|
|
152
app/src/components/composer-editor/composer-editor-plaintext.tsx
Normal file
152
app/src/components/composer-editor/composer-editor-plaintext.tsx
Normal file
|
@ -0,0 +1,152 @@
|
|||
import React from 'react';
|
||||
import { wrapPlaintextWithSelection } from './plaintext';
|
||||
import { handleFilePasted } from './composer-editor';
|
||||
|
||||
interface ComposerEditorPlaintextProps {
|
||||
value: string;
|
||||
propsForPlugins: any;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
onBlur?: (e: React.FocusEvent) => void;
|
||||
onDrop?: (e: React.DragEvent) => void;
|
||||
onFileReceived?: (path: string) => void;
|
||||
}
|
||||
|
||||
export class ComposerEditorPlaintext extends React.Component<ComposerEditorPlaintextProps> {
|
||||
_el: React.RefObject<HTMLTextAreaElement> = React.createRef<HTMLTextAreaElement>();
|
||||
_wrapEl: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateHeight();
|
||||
}
|
||||
|
||||
updateHeight = () => {
|
||||
const el = this._el.current;
|
||||
const wrapEl = this._wrapEl.current;
|
||||
if (!el || !wrapEl) return;
|
||||
wrapEl.style.height = el.style.height;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight + (el.offsetHeight - el.clientHeight)}px`;
|
||||
wrapEl.style.height = el.style.height;
|
||||
};
|
||||
|
||||
// Public methods required for compatibility with the rich text ComposerEditor.
|
||||
|
||||
focus = () => {
|
||||
this.focusEndReplyText();
|
||||
};
|
||||
|
||||
focusEndReplyText = () => {
|
||||
if (!this._el.current) return;
|
||||
this._el.current.focus();
|
||||
|
||||
const value = this._el.current.value;
|
||||
const quoteStart = /(On [A-Za-z0-9]+|\n> |-------)/g.exec(value);
|
||||
let pos = value.length;
|
||||
if (quoteStart) {
|
||||
pos = quoteStart.index;
|
||||
while (value[pos - 1] === '\n') {
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
this._el.current.setSelectionRange(pos, pos, 'forward');
|
||||
};
|
||||
|
||||
focusEndAbsolute = () => {
|
||||
if (!this._el.current) return;
|
||||
this._el.current.focus();
|
||||
const pos = this._el.current.value.length;
|
||||
this._el.current.setSelectionRange(pos, pos, 'forward');
|
||||
};
|
||||
|
||||
removeQuotedText = () => {};
|
||||
|
||||
insertInlineAttachment = file => {};
|
||||
|
||||
onFocusIfBlurred = event => {
|
||||
this._el.current.focus();
|
||||
};
|
||||
|
||||
// When we receive focus, the default browser behavior is to move the insertion point
|
||||
// to the end of text. If you tabbed in from the Subject field and there's quoted or
|
||||
// forwarded text this is not great. For now, let's aggressively shift selection to
|
||||
// the end of the user content.
|
||||
onFocus = event => {
|
||||
if (!this._el.current) return;
|
||||
if (this._el.current.value.length === this._el.current.selectionStart) {
|
||||
this.focusEndReplyText();
|
||||
}
|
||||
};
|
||||
|
||||
// When you backspace at the beginning of a line of quoted text that has content
|
||||
// after the insertion point, our fancy wrapPlaintext method won't let you delete
|
||||
// the `> ` because it'll keep re-wrapping the content following the insertion
|
||||
// point onto a new line and helpfully inserting the `> `. To fix this, this hack
|
||||
// deletes the entire `> ` in this scenario (as opposed to just the one ` ` char).
|
||||
onKeyUp = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Backspace' && event.target instanceof HTMLTextAreaElement) {
|
||||
const { value, selectionStart, selectionEnd, selectionDirection } = event.target;
|
||||
const preceding = value.substr(0, selectionStart);
|
||||
const precedingQuotePrefix = />+ $/.exec(preceding);
|
||||
|
||||
if (precedingQuotePrefix && selectionStart === selectionEnd) {
|
||||
event.target.value =
|
||||
value.substr(0, precedingQuotePrefix.index) + value.substr(selectionStart);
|
||||
event.target.setSelectionRange(
|
||||
precedingQuotePrefix.index,
|
||||
precedingQuotePrefix.index,
|
||||
selectionDirection as any
|
||||
);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onPaste = (event: React.ClipboardEvent<any>) => {
|
||||
const { onFileReceived } = this.props;
|
||||
if (onFileReceived && handleFilePasted(event.nativeEvent, onFileReceived)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
onChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = event.target.value;
|
||||
const wrapped = wrapPlaintextWithSelection(event.target);
|
||||
this.props.onChange(wrapped.value);
|
||||
|
||||
if (value !== wrapped.value) {
|
||||
event.target.value = wrapped.value;
|
||||
event.target.setSelectionRange(wrapped.selectionStart, wrapped.selectionEnd, event.target
|
||||
.selectionDirection as any);
|
||||
}
|
||||
this.updateHeight();
|
||||
};
|
||||
|
||||
// Event Handlers
|
||||
render() {
|
||||
const { className, onDrop } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this._wrapEl}
|
||||
className={`composer-editor-plaintext ${className || ''}`}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<textarea
|
||||
value={this.props.value}
|
||||
cols={72}
|
||||
wrap="hard"
|
||||
onFocus={this.onFocus}
|
||||
onChange={this.onChange}
|
||||
onPaste={this.onPaste}
|
||||
onKeyUp={this.onKeyUp}
|
||||
ref={this._el}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -170,45 +170,8 @@ export class ComposerEditor extends React.Component<ComposerEditorProps> {
|
|||
|
||||
if (onFileReceived && event.clipboardData.items.length > 0) {
|
||||
event.preventDefault();
|
||||
const item = event.clipboardData.items[0];
|
||||
|
||||
// If the pasteboard has a file on it, stream it to a temporary
|
||||
// file and fire our `onFilePaste` event.
|
||||
if (item.kind === 'file') {
|
||||
const temp = require('temp');
|
||||
const blob = item.getAsFile();
|
||||
const ext =
|
||||
{
|
||||
'image/png': '.png',
|
||||
'image/jpg': '.jpg',
|
||||
'image/tiff': '.tiff',
|
||||
}[item.type] || '';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('loadend', () => {
|
||||
const buffer = Buffer.from(new Uint8Array(reader.result as any));
|
||||
const tmpFolder = temp.path('-mailspring-attachment');
|
||||
const tmpPath = path.join(tmpFolder, `Pasted File${ext}`);
|
||||
fs.mkdir(tmpFolder, () => {
|
||||
fs.writeFile(tmpPath, buffer, () => {
|
||||
onFileReceived(tmpPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
reader.readAsArrayBuffer(blob);
|
||||
if (handleFilePasted(event, onFileReceived)) {
|
||||
return;
|
||||
} else {
|
||||
const macCopiedFile = decodeURI(
|
||||
ElectronClipboard.read('public.file-url').replace('file://', '')
|
||||
);
|
||||
const winCopiedFile = ElectronClipboard.read('FileNameW').replace(
|
||||
new RegExp(String.fromCharCode(0), 'g'),
|
||||
''
|
||||
);
|
||||
if (macCopiedFile.length || winCopiedFile.length) {
|
||||
onFileReceived(macCopiedFile || winCopiedFile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -307,3 +270,51 @@ export class ComposerEditor extends React.Component<ComposerEditorProps> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
export function handleFilePasted(event: ClipboardEvent, onFileReceived: (path: string) => void) {
|
||||
if (event.clipboardData.items.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const item = event.clipboardData.items[0];
|
||||
|
||||
// If the pasteboard has a file on it, stream it to a temporary
|
||||
// file and fire our `onFilePaste` event.
|
||||
if (item.kind === 'file') {
|
||||
const temp = require('temp');
|
||||
const blob = item.getAsFile();
|
||||
const ext =
|
||||
{
|
||||
'image/png': '.png',
|
||||
'image/jpg': '.jpg',
|
||||
'image/tiff': '.tiff',
|
||||
}[item.type] || '';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('loadend', () => {
|
||||
const buffer = Buffer.from(new Uint8Array(reader.result as any));
|
||||
const tmpFolder = temp.path('-mailspring-attachment');
|
||||
const tmpPath = path.join(tmpFolder, `Pasted File${ext}`);
|
||||
fs.mkdir(tmpFolder, () => {
|
||||
fs.writeFile(tmpPath, buffer, () => {
|
||||
onFileReceived(tmpPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
reader.readAsArrayBuffer(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
const macCopiedFile = decodeURI(ElectronClipboard.read('public.file-url').replace('file://', ''));
|
||||
const winCopiedFile = ElectronClipboard.read('FileNameW').replace(
|
||||
new RegExp(String.fromCharCode(0), 'g'),
|
||||
''
|
||||
);
|
||||
if (macCopiedFile.length || winCopiedFile.length) {
|
||||
onFileReceived(macCopiedFile || winCopiedFile);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ import BaseMarkPlugins from './base-mark-plugins';
|
|||
import TemplatePlugins, { VARIABLE_TYPE } from './template-plugins';
|
||||
import SpellcheckPlugins from './spellcheck-plugins';
|
||||
import UneditablePlugins, { UNEDITABLE_TYPE } from './uneditable-plugins';
|
||||
import BaseBlockPlugins, { BLOCK_CONFIG } from './base-block-plugins';
|
||||
import BaseBlockPlugins, { BLOCK_CONFIG, isQuoteNode } from './base-block-plugins';
|
||||
import InlineAttachmentPlugins, { IMAGE_TYPE } from './inline-attachment-plugins';
|
||||
import MarkdownPlugins from './markdown-plugins';
|
||||
import LinkPlugins from './link-plugins';
|
||||
|
@ -25,6 +25,7 @@ import EmojiPlugins, { EMOJI_TYPE } from './emoji-plugins';
|
|||
import { Rule, ComposerEditorPlugin } from './types';
|
||||
|
||||
import './patch-chrome-ime';
|
||||
import { deepenPlaintextQuote } from './plaintext';
|
||||
|
||||
export const schema = {
|
||||
blocks: {
|
||||
|
@ -399,10 +400,28 @@ export function convertToPlainText(value: Value) {
|
|||
|
||||
const serializeNode = (node: SlateNode) => {
|
||||
if (node.object === 'block' && node.type === UNEDITABLE_TYPE) {
|
||||
const html = node.data.get('html');
|
||||
let html = node.data.get('html');
|
||||
|
||||
// On detatched DOM nodes (where the content is not actually rendered onscreen),
|
||||
// innerText and textContent are the same and neither take into account CSS styles
|
||||
// of the various elements. To make the conversion from HTML to text decently well,
|
||||
// we insert newlines on </p> and <br> tags so their textContent contains them:
|
||||
html = html.replace(/<\/p ?>/g, '\n\n</p>');
|
||||
html = html.replace(/<br ?\/?>/g, '\n');
|
||||
html = html.replace(/<\/div ?>/g, '\n</div>');
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return div.innerText;
|
||||
|
||||
// This creates a ton of extra newlines, so anywhere this more than two empty spaces
|
||||
// we collapse them back down to two.
|
||||
let text = div.textContent;
|
||||
text = text.replace(/\n\n\n+/g, '\n\n').trim();
|
||||
return text;
|
||||
}
|
||||
if (isQuoteNode(node)) {
|
||||
const content = node.nodes.map(serializeNode).join('\n');
|
||||
return deepenPlaintextQuote(content);
|
||||
}
|
||||
if (node.object === 'document' || (node.object === 'block' && Block.isBlockList(node.nodes))) {
|
||||
return node.nodes.map(serializeNode).join('\n');
|
||||
|
|
112
app/src/components/composer-editor/plaintext.ts
Normal file
112
app/src/components/composer-editor/plaintext.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
export const ZERO_WIDTH_SPACE = '\u200B';
|
||||
export const SOFT_NEWLINE = '\n\u200B';
|
||||
export const DEFAULT_LINE_WIDTH = 72;
|
||||
|
||||
// Given a string with the `> `, `>> ` quote syntax, prepends each
|
||||
// line with another `>`, adding a trailing space only after the last `>`
|
||||
export function deepenPlaintextQuote(text: string) {
|
||||
return `\n${text}`.replace(/(\n>*) ?/g, '$1> ');
|
||||
}
|
||||
|
||||
// Forcibly wrap lines to 72 characters, inserting special "soft" newlines that
|
||||
// subsequent calls to `wrapPlaintext` can remove and re-wrap.
|
||||
|
||||
export function wrapPlaintext(text: string, lineWidth: number = DEFAULT_LINE_WIDTH) {
|
||||
return wrapPlaintextWithSelection({ value: text, selectionStart: 0, selectionEnd: 0 }, lineWidth)
|
||||
.value;
|
||||
}
|
||||
|
||||
// Our editor uses zero-width spaces to differentiate between soft and hard newlines
|
||||
// when you're editing. The newlines it automatically inserts when you hit the line
|
||||
// width are followed by a ZWS, and it knows that it can subsequently treat these as
|
||||
// spaces and not newlines when it re-runs the wrap algorithm.
|
||||
|
||||
export function wrapPlaintextWithSelection(
|
||||
{
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
value,
|
||||
}: { selectionStart: number; selectionEnd: number; value: string },
|
||||
lineWidth: number = DEFAULT_LINE_WIDTH
|
||||
) {
|
||||
let result = '';
|
||||
let resultSelectionStart = undefined;
|
||||
let resultSelectionEnd = undefined;
|
||||
let resultLineLength = 0;
|
||||
let word = '';
|
||||
let valueOffset = 0;
|
||||
|
||||
const flushWord = () => {
|
||||
if (resultLineLength + word.length > lineWidth) {
|
||||
const line = result.substr(result.length - resultLineLength);
|
||||
const lineQuotePrefixMatch = !word.startsWith('>') && /^>+ /.exec(line);
|
||||
const lineQuotePrefix = lineQuotePrefixMatch ? lineQuotePrefixMatch[0] : '';
|
||||
|
||||
const newLine = lineQuotePrefix + word.trim();
|
||||
|
||||
if (
|
||||
resultSelectionStart > result.length &&
|
||||
resultSelectionStart <= result.length + word.length
|
||||
) {
|
||||
resultSelectionStart += SOFT_NEWLINE.length + newLine.length - word.length;
|
||||
}
|
||||
if (resultSelectionEnd > result.length && resultSelectionEnd <= result.length + word.length) {
|
||||
resultSelectionEnd += SOFT_NEWLINE.length + newLine.length - word.length;
|
||||
}
|
||||
result += SOFT_NEWLINE + newLine;
|
||||
resultLineLength = newLine.length;
|
||||
} else {
|
||||
result += word;
|
||||
resultLineLength += word.length;
|
||||
}
|
||||
word = '';
|
||||
};
|
||||
|
||||
const setMarksIfPastSelection = () => {
|
||||
if (resultSelectionStart === undefined && selectionStart <= valueOffset) {
|
||||
resultSelectionStart = result.length + word.length;
|
||||
}
|
||||
if (resultSelectionEnd === undefined && selectionEnd <= valueOffset) {
|
||||
resultSelectionEnd = result.length + word.length;
|
||||
}
|
||||
};
|
||||
|
||||
while (valueOffset < value.length) {
|
||||
let char = value[valueOffset];
|
||||
setMarksIfPastSelection();
|
||||
|
||||
if (char === '\n' && value[valueOffset + 1] === ZERO_WIDTH_SPACE) {
|
||||
char = ' ';
|
||||
}
|
||||
if (char === ZERO_WIDTH_SPACE) {
|
||||
while (value[valueOffset + 1] === '>') {
|
||||
valueOffset += 1;
|
||||
}
|
||||
if (value[valueOffset + 1] === ' ') {
|
||||
valueOffset += 1;
|
||||
}
|
||||
// no-op
|
||||
} else if (char === '\n') {
|
||||
result += word;
|
||||
result += '\n';
|
||||
resultLineLength = 0;
|
||||
word = '';
|
||||
} else if (char === ' ') {
|
||||
flushWord();
|
||||
word += char;
|
||||
} else {
|
||||
word += char;
|
||||
}
|
||||
|
||||
valueOffset += 1;
|
||||
}
|
||||
|
||||
flushWord();
|
||||
setMarksIfPastSelection();
|
||||
|
||||
return {
|
||||
value: result,
|
||||
selectionStart: resultSelectionStart,
|
||||
selectionEnd: resultSelectionEnd,
|
||||
};
|
||||
}
|
|
@ -144,6 +144,15 @@ export default {
|
|||
composing: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
html: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
title: localized('Enable rich text and advanced editor features'),
|
||||
note: localized(
|
||||
'Many features are unavailable in plain-text mode. To create a single ' +
|
||||
'plain-text draft, hold Alt or Option while clicking Compose or Reply.'
|
||||
),
|
||||
},
|
||||
spellcheck: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
|
|
|
@ -118,7 +118,7 @@ export class ComposerExtension {
|
|||
*/
|
||||
static applyTransformsForSending(args: {
|
||||
draft: Message;
|
||||
draftBodyRootNode: HTMLElement;
|
||||
draftBodyRootNode?: HTMLElement; // Note: Only present for HTML messages
|
||||
recipient?: Contact;
|
||||
}) {}
|
||||
}
|
||||
|
|
|
@ -151,6 +151,11 @@ export class Message extends ModelWithMetadata {
|
|||
queryable: false,
|
||||
}),
|
||||
|
||||
plaintext: Attributes.Boolean({
|
||||
modelKey: 'plaintext',
|
||||
queryable: false,
|
||||
}),
|
||||
|
||||
version: Attributes.Number({
|
||||
jsonKey: 'v',
|
||||
modelKey: 'version',
|
||||
|
@ -194,6 +199,8 @@ export class Message extends ModelWithMetadata {
|
|||
public forwardedHeaderMessageId: string;
|
||||
public folder: Folder;
|
||||
|
||||
/** indicates that "body" is plain text, not HTML */
|
||||
public plaintext: boolean;
|
||||
public body?: string;
|
||||
|
||||
static naturalSortOrder() {
|
||||
|
|
|
@ -45,7 +45,10 @@ export function extractTextFromHtml(html, param: { maxLength?: number } = {}) {
|
|||
if (maxLength && html.length > maxLength) {
|
||||
html = html.slice(0, maxLength);
|
||||
}
|
||||
return new DOMParser().parseFromString(html, 'text/html').body.innerText;
|
||||
const body = new DOMParser().parseFromString(html, 'text/html').body;
|
||||
body.querySelectorAll('style').forEach(el => el.remove());
|
||||
body.querySelectorAll('script').forEach(el => el.remove());
|
||||
return body.textContent.trim();
|
||||
}
|
||||
|
||||
export function modelTypesReviver(k, v) {
|
||||
|
|
|
@ -15,29 +15,44 @@ import InlineStyleTransformer from '../../services/inline-style-transformer';
|
|||
import SanitizeTransformer from '../../services/sanitize-transformer';
|
||||
import DOMUtils from '../../dom-utils';
|
||||
import { Thread } from '../models/Thread';
|
||||
import { convertToPlainText, convertFromHTML } from '../../components/composer-editor/conversion';
|
||||
import { wrapPlaintext, deepenPlaintextQuote } from '../../components/composer-editor/plaintext';
|
||||
|
||||
let DraftStore: typeof import('./draft-store').default = null;
|
||||
|
||||
export type ReplyType = 'reply' | 'reply-all';
|
||||
export type ReplyBehavior = 'prefer-existing' | 'prefer-existing-if-pristine';
|
||||
|
||||
async function prepareBodyForQuoting(body) {
|
||||
// TODO: Fix inline images
|
||||
const cidRE = MessageUtils.cidRegexString;
|
||||
|
||||
// Be sure to match over multiple lines with [\s\S]*
|
||||
// Regex explanation here: https://regex101.com/r/vO6eN2/1
|
||||
let transformed = (body || '').replace(new RegExp(`<img.*${cidRE}[\\s\\S]*?>`, 'igm'), '');
|
||||
transformed = await SanitizeTransformer.run(transformed, SanitizeTransformer.Preset.UnsafeOnly);
|
||||
transformed = await InlineStyleTransformer.run(transformed);
|
||||
return transformed;
|
||||
}
|
||||
|
||||
class DraftFactory {
|
||||
useHTML() {
|
||||
const forcePlaintext = AppEnv.keymaps.getIsAltKeyDown();
|
||||
return AppEnv.config.get('core.composing.html') && !forcePlaintext;
|
||||
}
|
||||
|
||||
async prepareBodyForQuoting(message: Message) {
|
||||
if (!this.useHTML()) {
|
||||
return deepenPlaintextQuote(
|
||||
message.plaintext ? message.body : convertToPlainText(convertFromHTML(message.body)).trim()
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Fix inline images
|
||||
const cidRE = MessageUtils.cidRegexString;
|
||||
const cidRegexp = new RegExp(`<img.*${cidRE}[\\s\\S]*?>`, 'igm');
|
||||
|
||||
// Be sure to match over multiple lines with [\s\S]*
|
||||
// Regex explanation here: https://regex101.com/r/vO6eN2/1
|
||||
let transformed = (message.body || '').replace(cidRegexp, '');
|
||||
transformed = await SanitizeTransformer.run(transformed, SanitizeTransformer.Preset.UnsafeOnly);
|
||||
transformed = await InlineStyleTransformer.run(transformed);
|
||||
return transformed;
|
||||
}
|
||||
|
||||
async createDraft(fields = {}) {
|
||||
const account = this._accountForNewDraft();
|
||||
const rich = this.useHTML();
|
||||
const defaults = {
|
||||
body: '<br/>',
|
||||
body: rich ? '<br/>' : '',
|
||||
subject: '',
|
||||
version: 0,
|
||||
unread: false,
|
||||
|
@ -47,6 +62,7 @@ class DraftFactory {
|
|||
date: new Date(),
|
||||
draft: true,
|
||||
pristine: true,
|
||||
plaintext: !rich,
|
||||
accountId: account.id,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
|
@ -61,6 +77,9 @@ class DraftFactory {
|
|||
if (account.autoaddress.type === 'bcc') {
|
||||
merged.bcc = (merged.bcc || []).concat(autoContacts);
|
||||
}
|
||||
if (merged.plaintext) {
|
||||
merged.body = wrapPlaintext(merged.body);
|
||||
}
|
||||
|
||||
return new Message(merged);
|
||||
}
|
||||
|
@ -131,7 +150,7 @@ class DraftFactory {
|
|||
}
|
||||
}
|
||||
|
||||
if (query.body) {
|
||||
if (query.body && this.useHTML()) {
|
||||
query.body = query.body.replace(/[\n\r]/g, '<br/>');
|
||||
}
|
||||
|
||||
|
@ -161,7 +180,7 @@ class DraftFactory {
|
|||
}
|
||||
|
||||
async createDraftForReply({ message, thread, type }) {
|
||||
const prevBody = await prepareBodyForQuoting(message.body);
|
||||
const prevBody = await this.prepareBodyForQuoting(message);
|
||||
let participants = { to: [], cc: [] };
|
||||
if (type === 'reply') {
|
||||
participants = message.participantsForReply();
|
||||
|
@ -177,7 +196,8 @@ class DraftFactory {
|
|||
threadId: thread.id,
|
||||
accountId: message.accountId,
|
||||
replyToHeaderMessageId: message.headerMessageId,
|
||||
body: `
|
||||
body: this.useHTML()
|
||||
? `
|
||||
<br/>
|
||||
<br/>
|
||||
<div class="gmail_quote_attribution">${DOMUtils.escapeHTMLCharacters(
|
||||
|
@ -188,23 +208,28 @@ class DraftFactory {
|
|||
${prevBody}
|
||||
<br/>
|
||||
</blockquote>
|
||||
`,
|
||||
`
|
||||
: `\n\n${message.replyAttributionLine()}\n${prevBody}`,
|
||||
});
|
||||
}
|
||||
|
||||
async createDraftForForward({ thread, message }) {
|
||||
// Start downloading the attachments, if they haven't been already
|
||||
message.files.forEach(f => Actions.fetchFile(f));
|
||||
message.files.forEach((f: File) => Actions.fetchFile(f));
|
||||
|
||||
const formatContact = (cs: Contact[]) => {
|
||||
const text = cs.map(c => c.toString()).join(', ');
|
||||
return this.useHTML() ? DOMUtils.escapeHTMLCharacters(text) : text;
|
||||
};
|
||||
|
||||
const contactsAsHtml = cs => DOMUtils.escapeHTMLCharacters(_.invoke(cs, 'toString').join(', '));
|
||||
const fields = [];
|
||||
if (message.from.length > 0) fields.push(`From: ${contactsAsHtml(message.from)}`);
|
||||
if (message.from.length > 0) fields.push(`From: ${formatContact(message.from)}`);
|
||||
fields.push(`${localized('Subject')}: ${message.subject}`);
|
||||
fields.push(`${localized('Date')}: ${message.formattedDate()}`);
|
||||
if (message.to.length > 0) fields.push(`${localized('To')}: ${contactsAsHtml(message.to)}`);
|
||||
if (message.cc.length > 0) fields.push(`${localized('Cc')}: ${contactsAsHtml(message.cc)}`);
|
||||
if (message.to.length > 0) fields.push(`${localized('To')}: ${formatContact(message.to)}`);
|
||||
if (message.cc.length > 0) fields.push(`${localized('Cc')}: ${formatContact(message.cc)}`);
|
||||
|
||||
const body = await prepareBodyForQuoting(message.body);
|
||||
const body = await this.prepareBodyForQuoting(message);
|
||||
|
||||
return this.createDraft({
|
||||
subject: Utils.subjectWithPrefix(message.subject, 'Fwd:'),
|
||||
|
@ -213,7 +238,8 @@ class DraftFactory {
|
|||
threadId: thread.id,
|
||||
accountId: message.accountId,
|
||||
forwardedHeaderMessageId: message.headerMessageId,
|
||||
body: `
|
||||
body: this.useHTML()
|
||||
? `
|
||||
<br/>
|
||||
<div class="gmail_quote">
|
||||
<br>
|
||||
|
@ -224,7 +250,10 @@ class DraftFactory {
|
|||
${body}
|
||||
<br/>
|
||||
</div>
|
||||
`,
|
||||
`
|
||||
: `\n\n---------- ${localized('Forwarded Message')} ---------\n\n${fields.join(
|
||||
'\n'
|
||||
)}\n\n${body}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ class MessageBodyProcessor {
|
|||
});
|
||||
}
|
||||
|
||||
updateCacheForMessage = async changedMessage => {
|
||||
updateCacheForMessage = async (changedMessage: Message) => {
|
||||
// reprocess any subscription using the new message data. Note that
|
||||
// changedMessage may not have a loaded body if it wasn't changed. In
|
||||
// that case, we use the previous body. Note: metadata changes, etc.
|
||||
|
@ -96,7 +96,7 @@ class MessageBodyProcessor {
|
|||
return this._version;
|
||||
}
|
||||
|
||||
subscribe(message, sendInitial, callback) {
|
||||
subscribe(message: Message, sendInitial: boolean, callback) {
|
||||
const sub = { message, callback };
|
||||
|
||||
if (sendInitial) {
|
||||
|
@ -117,7 +117,7 @@ class MessageBodyProcessor {
|
|||
};
|
||||
}
|
||||
|
||||
async retrieve(message) {
|
||||
async retrieve(message: Message) {
|
||||
const key = this._key(message);
|
||||
if (this._recentlyProcessedD[key]) {
|
||||
return this._recentlyProcessedD[key];
|
||||
|
@ -128,7 +128,7 @@ class MessageBodyProcessor {
|
|||
return output;
|
||||
}
|
||||
|
||||
retrieveCached(message) {
|
||||
retrieveCached(message: Message) {
|
||||
const key = this._key(message);
|
||||
if (this._recentlyProcessedD[key]) {
|
||||
return this._recentlyProcessedD[key];
|
||||
|
@ -138,13 +138,13 @@ class MessageBodyProcessor {
|
|||
|
||||
// Private Methods
|
||||
|
||||
_key(message) {
|
||||
_key(message: Message) {
|
||||
// It's safe to key off of the message ID alone because we invalidate the
|
||||
// cache whenever the message is persisted to the database.
|
||||
return message.id;
|
||||
}
|
||||
|
||||
async _process(message) {
|
||||
async _process(message: Message) {
|
||||
if (typeof message.body !== 'string') {
|
||||
return { body: '', clipped: false };
|
||||
}
|
||||
|
@ -158,6 +158,13 @@ class MessageBodyProcessor {
|
|||
clipped = true;
|
||||
}
|
||||
|
||||
// We do NOT try to sanitize plaintext message bodies as if they were HTML
|
||||
// because it destroys them. But this means that we need to be careful not to
|
||||
// "splat" them directly into the DOM! (Instead use innerText to assign).
|
||||
if (message.plaintext) {
|
||||
return { body, clipped };
|
||||
}
|
||||
|
||||
// Sanitizing <script> tags, etc. isn't necessary because we use CORS rules
|
||||
// to prevent their execution and sandbox content in the iFrame, but we still
|
||||
// want to remove contenteditable attributes and other strange things.
|
||||
|
|
|
@ -9,21 +9,43 @@ import { Composer as ComposerExtensionRegistry } from '../../registries/extensio
|
|||
import { LocalizedErrorStrings } from '../../mailsync-process';
|
||||
import { localized } from '../../intl';
|
||||
import { AttributeValues } from '../models/model';
|
||||
import { Contact } from '../models/contact';
|
||||
import { ZERO_WIDTH_SPACE } from '../../components/composer-editor/plaintext';
|
||||
|
||||
function applyExtensionTransforms(draft, recipient) {
|
||||
const extensions = ComposerExtensionRegistry.extensions();
|
||||
const fragment = document.createDocumentFragment();
|
||||
const draftBodyRootNode = document.createElement('root');
|
||||
fragment.appendChild(draftBodyRootNode);
|
||||
draftBodyRootNode.innerHTML = draft.body;
|
||||
function applyExtensionTransforms(draft: Message, recipient: Contact) {
|
||||
// Note / todo: This code assumes that:
|
||||
// - any changes made to the draft (eg: metadata) should be saved.
|
||||
// - any changes made to a HTML body will be made to draftBodyRootNode NOT to the draft.body
|
||||
// - any changes made to a plaintext body will be made to draft.body.
|
||||
|
||||
for (const ext of extensions) {
|
||||
const extApply = ext.applyTransformsForSending;
|
||||
if (extApply) {
|
||||
extApply({ draft, draftBodyRootNode, recipient });
|
||||
const before = draft.body;
|
||||
const extensions = ComposerExtensionRegistry.extensions().filter(
|
||||
ext => !!ext.applyTransformsForSending
|
||||
);
|
||||
|
||||
if (draft.plaintext) {
|
||||
for (const ext of extensions) {
|
||||
ext.applyTransformsForSending({ draft, recipient });
|
||||
}
|
||||
const after = draft.body;
|
||||
draft.body = before;
|
||||
return after;
|
||||
} else {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const draftBodyRootNode = document.createElement('root');
|
||||
fragment.appendChild(draftBodyRootNode);
|
||||
draftBodyRootNode.innerHTML = draft.body;
|
||||
|
||||
for (const ext of extensions) {
|
||||
ext.applyTransformsForSending({ draft, draftBodyRootNode, recipient });
|
||||
if (draft.body !== before) {
|
||||
throw new Error(
|
||||
'applyTransformsForSending should modify the HTML body DOM (draftBodyRootNode) not the draft.body.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return draftBodyRootNode.innerHTML;
|
||||
}
|
||||
return draftBodyRootNode.innerHTML;
|
||||
}
|
||||
|
||||
export class SendDraftTask extends Task {
|
||||
|
@ -37,6 +59,12 @@ export class SendDraftTask extends Task {
|
|||
ext => ext.needsPerRecipientBodies && ext.needsPerRecipientBodies(task.draft)
|
||||
);
|
||||
|
||||
if (task.draft.plaintext) {
|
||||
// Our editor uses zero-width spaces to differentiate between soft and hard newlines
|
||||
// when you're editing, and we don't want to send these characters.
|
||||
task.draft.body = task.draft.body.replace(new RegExp(ZERO_WIDTH_SPACE, 'g'), '');
|
||||
}
|
||||
|
||||
if (separateBodies) {
|
||||
task.perRecipientBodies = {
|
||||
self: task.draft.body,
|
||||
|
|
1
app/src/global/mailspring-component-kit.d.ts
vendored
1
app/src/global/mailspring-component-kit.d.ts
vendored
|
@ -57,6 +57,7 @@ export * from '../components/attachment-items';
|
|||
export const CodeSnippet: typeof import('../components/code-snippet').default;
|
||||
|
||||
export * from '../components/composer-editor/composer-editor';
|
||||
export * from '../components/composer-editor/composer-editor-plaintext';
|
||||
|
||||
export const ComposerSupport: typeof import('../components/composer-editor/composer-support');
|
||||
|
||||
|
|
|
@ -117,6 +117,7 @@ lazyLoadFrom('ImageAttachmentItem', 'attachment-items');
|
|||
lazyLoad('CodeSnippet', 'code-snippet');
|
||||
|
||||
lazyLoad('ComposerEditor', 'composer-editor/composer-editor');
|
||||
lazyLoad('ComposerEditorPlaintext', 'composer-editor/composer-editor-plaintext');
|
||||
lazyLoad('ComposerSupport', 'composer-editor/composer-support');
|
||||
|
||||
lazyLoad('ScrollRegion', 'scroll-region');
|
||||
|
|
|
@ -107,10 +107,26 @@ export default class KeymapManager {
|
|||
_removeTemplate?: Disposable;
|
||||
_bindingsCache: {};
|
||||
_commandsCache: {};
|
||||
_altKeyDown: boolean = false;
|
||||
|
||||
EVENT_ALT_KEY_STATE_CHANGE = 'alt-key-state-change';
|
||||
|
||||
constructor({ configDirPath, resourcePath }) {
|
||||
this.configDirPath = configDirPath;
|
||||
this.resourcePath = resourcePath;
|
||||
window.addEventListener('keydown', this.onCheckModifierState);
|
||||
window.addEventListener('keyup', this.onCheckModifierState);
|
||||
}
|
||||
|
||||
onCheckModifierState = (e: KeyboardEvent) => {
|
||||
if (this._altKeyDown !== e.altKey) {
|
||||
this._altKeyDown = e.altKey;
|
||||
document.dispatchEvent(new CustomEvent(this.EVENT_ALT_KEY_STATE_CHANGE));
|
||||
}
|
||||
};
|
||||
|
||||
getIsAltKeyDown() {
|
||||
return this._altKeyDown;
|
||||
}
|
||||
|
||||
getUserKeymapPath() {
|
||||
|
|
|
@ -111,27 +111,23 @@ class ToolbarWindowControls extends React.Component<{}, { alt: boolean }> {
|
|||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { alt: false };
|
||||
this.state = { alt: AppEnv.keymaps.getIsAltKeyDown() };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (process.platform === 'darwin') {
|
||||
window.addEventListener('keydown', this._onAlt);
|
||||
window.addEventListener('keyup', this._onAlt);
|
||||
document.addEventListener(AppEnv.keymaps.EVENT_ALT_KEY_STATE_CHANGE, this._onAlt);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (process.platform === 'darwin') {
|
||||
window.removeEventListener('keydown', this._onAlt);
|
||||
window.removeEventListener('keyup', this._onAlt);
|
||||
document.removeEventListener(AppEnv.keymaps.EVENT_ALT_KEY_STATE_CHANGE, this._onAlt);
|
||||
}
|
||||
}
|
||||
|
||||
_onAlt = event => {
|
||||
if (this.state.alt !== event.altKey) {
|
||||
this.setState({ alt: event.altKey });
|
||||
}
|
||||
_onAlt = () => {
|
||||
this.setState({ alt: AppEnv.keymaps.getIsAltKeyDown() });
|
||||
};
|
||||
|
||||
_onMaximize = event => {
|
||||
|
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
@font-family-sans-serif: 'Nylas-Pro', 'Helvetica', sans-serif;
|
||||
@font-family-serif: Georgia, 'Times New Roman', Times, serif;
|
||||
@font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
@font-family-monospace: 'DejaVu Sans Mono', Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
|
||||
@font-family: @font-family-sans-serif;
|
||||
@font-family-heading: @font-family-sans-serif;
|
||||
|
|
|
@ -76,6 +76,12 @@
|
|||
font-family: system-ui;
|
||||
}
|
||||
|
||||
#inbox-plain-wrapper {
|
||||
font-family: @font-family-monospace;
|
||||
font-size: 14px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
strong,
|
||||
b,
|
||||
.bold {
|
||||
|
|
2
mailsync
2
mailsync
|
@ -1 +1 @@
|
|||
Subproject commit e44852a479a3f9013e6fd51b3d36c27126c142e5
|
||||
Subproject commit c4ed6069e4636265dd083739257025c534e3b6e2
|
59
package-lock.json
generated
59
package-lock.json
generated
|
@ -2375,10 +2375,21 @@
|
|||
}
|
||||
},
|
||||
"eslint-utils": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.3.1.tgz",
|
||||
"integrity": "sha1-moUbqJ7nxGA0b5fPiTnHKYgn5RI=",
|
||||
"dev": true
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz",
|
||||
"integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"eslint-visitor-keys": "^1.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz",
|
||||
"integrity": "sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"eslint-visitor-keys": {
|
||||
"version": "1.0.0",
|
||||
|
@ -3743,9 +3754,9 @@
|
|||
}
|
||||
},
|
||||
"handlebars": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.2.tgz",
|
||||
"integrity": "sha1-trN8HO0DBrIh4JT8eso+wjsTG2c=",
|
||||
"version": "4.7.3",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.3.tgz",
|
||||
"integrity": "sha512-SRGwSYuNfx8DwHD/6InAPzD6RgeruWLT+B8e8a7gGs8FWgHzlExpTFMEq2IA6QpAfOClpKHy6+8IqTjeBCu6Kg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"neo-async": "^2.6.0",
|
||||
|
@ -3923,12 +3934,12 @@
|
|||
}
|
||||
},
|
||||
"https-proxy-agent": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz",
|
||||
"integrity": "sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==",
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz",
|
||||
"integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"agent-base": "^4.1.0",
|
||||
"agent-base": "^4.3.0",
|
||||
"debug": "^3.1.0"
|
||||
}
|
||||
},
|
||||
|
@ -4598,9 +4609,9 @@
|
|||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.11",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
|
||||
"integrity": "sha1-s56mIp72B+zYniyN8SU2iRysm40=",
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
|
||||
"dev": true
|
||||
},
|
||||
"lodash._reinterpolate": {
|
||||
|
@ -4658,9 +4669,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"lodash.merge": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz",
|
||||
"integrity": "sha1-rcJdnLmbk5HFliTzefu6YNcRHVQ=",
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.pick": {
|
||||
|
@ -10803,20 +10814,20 @@
|
|||
"dev": true
|
||||
},
|
||||
"uglify-js": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.0.tgz",
|
||||
"integrity": "sha1-cEaBNFxTqLIHn7bOwpSwXq0kL/U=",
|
||||
"version": "3.7.7",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.7.tgz",
|
||||
"integrity": "sha512-FeSU+hi7ULYy6mn8PKio/tXsdSXN35lm4KgV2asx00kzrLU9Pi3oAslcJT70Jdj7PHX29gGUPOT6+lXGBbemhA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"commander": "~2.20.0",
|
||||
"commander": "~2.20.3",
|
||||
"source-map": "~0.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": {
|
||||
"version": "2.20.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz",
|
||||
"integrity": "sha1-1YuytcHuj4ew00ACfp6U4iLFpCI=",
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
}
|
||||
|
|
|
@ -77,7 +77,7 @@
|
|||
"grunt-contrib-csslint": "0.5.x",
|
||||
"grunt-contrib-less": "0.8.x",
|
||||
"grunt-lesslint": "0.13.x",
|
||||
"handlebars": "^4.1.1",
|
||||
"handlebars": "^4.7.3",
|
||||
"jasmine": "2.x.x",
|
||||
"joanna": "0.0.9",
|
||||
"license-extractor": "^1.0.4",
|
||||
|
|
Loading…
Reference in a new issue