New plugin "markdown_editor": compose in markdown, send as HTML

This adds a markdown editor that sends HTML to the server.

It uses codemirror and some custom code to show a syntax highlighted
textarea and some buttons to help editing
(including a preview).

Drafts get marked via an internal email header that causes the markdown
editor to automatically start if a message composition is
continued that was started using the markdown editor.
This commit is contained in:
Pablo Zmdl 2025-06-23 14:52:21 +02:00 committed by Pablo Zmdl
parent 3141699a8b
commit e34a813355
21 changed files with 2845 additions and 3 deletions

View File

@ -34,6 +34,7 @@ $config['plugins'] = [
'archive',
'attachment_reminder',
'markasjunk',
'markdown_editor',
'zipdownload',
];

View File

@ -47,6 +47,9 @@ bin/install-jsdeps.sh
# Compile Elastic's styles
make css-elastic
# Build JS and CSS for plugins
make plugins-build
# Use minified javascript files
bin/jsshrink.sh

View File

@ -92,8 +92,12 @@ file_filter = plugins/zipdownload/localization/<lang>.inc
source_file = plugins/zipdownload/localization/en_US.inc
source_lang = en_US
[o:roundcube:p:roundcube-webmail:r:plugin-markdown_editor]
file_filter = plugins/markdown_editor/localization/<lang>.inc
source_file = plugins/markdown_editor/localization/en_US.inc
source_lang = en_US
[o:roundcube:p:roundcube-webmail:r:timezones]
file_filter = program/localization/<lang>/timezones.inc
source_file = program/localization/en_US/timezones.inc
source_lang = en_US

View File

@ -11,6 +11,7 @@ This file includes only changes we consider noteworthy for users, admins and plu
- Add ability to chose from all available contact fields on CSV import (#9419)
- Password: Removed the (insecure) virtualmin driver (#8007)
- Fix jqueryui plugin's minicolors.css issue with custom skins (#9967)
- Add a new plugin called `markdown_editor` that provides an alternative editor to compose emails with in Markdown syntax, which gets converted into HTML before sending.
## Release 1.7-beta2

View File

@ -61,7 +61,7 @@ verify:
shasum:
shasum -a 256 roundcubemail-$(VERSION).tar.gz roundcubemail-$(VERSION)-complete.tar.gz roundcube-framework-$(VERSION).tar.gz
roundcubemail-git: buildtools
roundcubemail-git: buildtools plugin-markdown_editor-prepare-for-release
git clone --branch=$(GITBRANCH) --depth=1 $(GITREMOTE) roundcubemail-git
(cd roundcubemail-git; bin/jsshrink.sh; bin/updatecss.sh; bin/cssshrink.sh)
(cd roundcubemail-git/skins/elastic && make css)
@ -119,4 +119,18 @@ composer-update: /tmp/composer.phar
install-jsdeps: npm-install
./bin/install-jsdeps.sh
build: composer-update install-jsdeps css-elastic
build: composer-update install-jsdeps css-elastic plugins-build
plugins-build: plugin-markdown_editor-build
plugin-markdown_editor-build: plugin-markdown_editor-clean
cd plugins/markdown_editor && \
npm clean-install && \
npm run build && \
rm -rf node_module
plugin-markdown_editor-clean:
cd plugins/markdown_editor && npm run clean
plugin-markdown_editor-prepare-for-release: plugin-markdown_editor-build
(cd roundcubemail-git/plugins/markdown_editor; rm -rf node_modules package*.json rollup.config.*js build.sh javascript *.less tests)

5
plugins/markdown_editor/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
/bundle-stats.html
/skins/elastic/styles/markdown_editor.*css
/skins/elastic/styles/iframe.*css
/markdown_editor.*js

View File

@ -0,0 +1,20 @@
MarkdownEditor
==============
A markdown editor (duh!).
This plugins adds an alternative editor to compose emails with in Markdown syntax, which gets converted into HTML before sending.
It provides syntax highlighting and a toolbar (including a preview button) to help writing markdown. Drafts are saved as-is, and are automatically re-opened in this editor on re-editing. On sending the written markdown text gets converted into HTML.
(Roundcubemail automatically produces a multipart/alternative email from the given HTML, including a text/plain part consisting of re-converted HTML-to-text. Using our original text as text/plain part would be preferable but is left for a future version of this plugin.)
Installation
------------
Run `npm clean-install && npm run build` to produce the minified Javascript and CSS files required to run.
To enable this editor, add `'markdown_editor'` to the list of plugins in your `config.inc.php`.
There is no configuration.

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -ex
exec npm run build

View File

@ -0,0 +1,22 @@
{
"name": "roundcube/markdown_editor",
"type": "roundcube-plugin",
"description": "An editor to compose emails in Markdown syntax, which gets converted into HTML before sending.",
"license": "GPL-3.0-or-later",
"version": "0.1",
"authors": [
{
"name": "Pablo Zimdahl",
"homepage": "https://github.com/pabzm"
}
],
"require": {
"php": ">=8.1.0",
"roundcube/plugin-installer": "~0.3.5"
},
"autoload": {
"classmap": [
"markdown_editor.php"
]
}
}

View File

@ -0,0 +1,379 @@
import markdownit from 'markdown-it';
import {
EditorView, keymap, highlightSpecialChars, ViewPlugin,
} from '@codemirror/view';
import { defaultHighlightStyle, syntaxHighlighting, indentOnInput } from '@codemirror/language';
import {
defaultKeymap, history, historyKeymap, undo, redo,
} from '@codemirror/commands';
import { EditorState, Compartment } from '@codemirror/state';
import { markdown } from '@codemirror/lang-markdown';
import * as Commands from 'codemirror-markdown-commands';
import { materialLight } from '@fsegurai/codemirror-theme-material-light';
import { materialDark } from '@fsegurai/codemirror-theme-material-dark';
import ToolbarButton from './toolbar-button';
import ToolbarPlugin from './toolbar-plugin';
// TODO:
// * Better icons for 'redo' and 'undo' buttons. In Font Awesome v5 Free the good icons are not included.
// * Replace SVG markdown element with markdown icon from Font Awesome after upgrading to a version that includes it.
class Index {
#defaultTextarea;
#toolbar;
#markdownIt;
#previewIframe;
#domParser;
#textEditingToolbarButtons;
#debounceTimers;
#editorTheme;
#view;
#container;
// Use a map with fixed callbacks so we can remove the event-listeners later, too.
#eventListeners = new Map();
constructor() {
this.#defaultTextarea = rcmail.gui_objects.messageform.querySelector('#composebody');
this.#toolbar = document.querySelector('.editor-toolbar');
this.#toolbar.append(this.#makeMarkdownEditorButton());
this.#markdownIt = markdownit({
// Turn '\n' into '<br>' (required to preserve e.g. email signatures)
breaks: true,
});
this.#editorTheme = new Compartment();
// Reload from plain text textarea if text was inserted or changed through buttons.
this.#eventListeners.set('change_identity', () => this.#reloadContentFromDefaultTextarea());
// If a quick-response is to be inserted, put the textarea cursor at the position where our cursor is, so the
// response text is actually inserted at the right position.
this.#eventListeners.set('requestsettings/response-get', () => this.#setTextareaCursorPosition());
// Reload content from the textarea after a quick-response was inserted.
this.#eventListeners.set('insert_response', () => this.#reloadContentFromDefaultTextarea());
}
get #wantedTheme() {
if (document.firstElementChild.classList.contains('dark-mode')) {
return materialDark;
}
return materialLight;
}
#toggleTheme() {
if (!this.#view) {
return;
}
this.#view.dispatch({
effects: this.#editorTheme.reconfigure(this.#wantedTheme),
});
}
#makePreviewIframe(elementToAppendTo) {
// We're using an iframe to isolate the preview content from any styles of the main document. Those wouldn't be
// sent with the email, so the preview shouldn't be using them, either.
const iframe = document.createElement('iframe');
iframe.id = 'markdown-editor-preview';
this.#hide(iframe);
elementToAppendTo.append(iframe);
// Handle dark-mode by injecting minimal CSS and a class (must be done after connecting the iframe-element to
// the DOM).
const iframeDoc = iframe.contentWindow.document;
if (document.firstElementChild.classList.contains('dark-mode')) {
iframeDoc.firstElementChild.classList.add('dark-mode');
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = rcmail.assets_path(rcmail.env.markdown_editor_iframe_css_path);
iframeDoc.head.append(link);
return iframe;
}
#makeMarkdownEditorButton() {
const markdownEditorButton = document.createElement('a');
markdownEditorButton.className = 'markdown-editor-start-button';
markdownEditorButton.tabIndex = '-2';
markdownEditorButton.href = '#';
markdownEditorButton.addEventListener('click', (ev) => {
ev.preventDefault();
this.startMarkdownEditor();
// Force saving to mark this content as edited by markdown_editor.
rcmail.submit_messageform(true);
});
markdownEditorButton.title = rcmail.get_label('markdown_editor.editor_button_title');
const readonly = this.#defaultTextarea.hasAttribute('readonly') || this.#defaultTextarea.hasAttribute('disabled');
if (readonly) {
markdownEditorButton.setAttribute('disabled', 'disabled');
}
// Use an inline SVG element so we can style it with CSS.
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('viewBox', '0 0 471 289.85');
const svgTitle = document.createElement('title');
svgTitle.textContent = 'markdown editor';
const svgPath1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
svgPath1.setAttribute('d', 'M437,289.85H34a34,34,0,0,1-34-34V34A34,34,0,0,1,34,0H437a34,34,0,0,1,34,34V255.88A34,34,0,0,1,437,289.85ZM34,22.64A11.34,11.34,0,0,0,22.64,34V255.88A11.34,11.34,0,0,0,34,267.2H437a11.34,11.34,0,0,0,11.33-11.32V34A11.34,11.34,0,0,0,437,22.64Z');
const svgPath2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
svgPath2.setAttribute('d', 'M67.93,221.91v-154h45.29l45.29,56.61L203.8,67.93h45.29v154H203.8V133.6l-45.29,56.61L113.22,133.6v88.31Zm283.06,0-67.94-74.72h45.29V67.93h45.29v79.26h45.29Z');
svg.append(svgTitle, svgPath1, svgPath2);
markdownEditorButton.append(svg);
return markdownEditorButton;
}
#makeContainer() {
const container = document.createElement('div');
container.id = 'markdown-editor-container';
return container;
}
#makeEditorView(content) {
const contentChangedNotifier = EditorView.updateListener.of(
(viewUpdate) => this.#save()
);
this.#textEditingToolbarButtons = [
new ToolbarButton('bold', '\uF032', Commands.bold),
new ToolbarButton('italic', '\uF033', Commands.italic),
new ToolbarButton('strike', '\uF0CC', Commands.strike),
new ToolbarButton('separator', '|'),
new ToolbarButton('h1', '\uF1DC1', Commands.h1),
new ToolbarButton('h2', '\uF1DC2', Commands.h2),
new ToolbarButton('h3', '\uF1DC3', Commands.h3),
new ToolbarButton('h4', '\uF1DC4', Commands.h4),
new ToolbarButton('separator', '|'),
new ToolbarButton('blockquote', '\uF10E', Commands.quote),
new ToolbarButton('ordered_list', '\uF0CB', Commands.ol),
new ToolbarButton('unordered_list', '\uF0CA', Commands.ul),
new ToolbarButton('separator', '|'),
new ToolbarButton('link', '\uF0C1', Commands.link),
new ToolbarButton('separator', '|'),
new ToolbarButton('undo', '\uF0E2', (view) => undo(view)),
new ToolbarButton('redo', '\uF01E', (view) => redo(view)),
];
const toolbarItems = [
new ToolbarButton('quit', '\uF00D', (view) => this.stopMarkdownEditor()),
new ToolbarButton('separator', '|'),
...this.#textEditingToolbarButtons,
new ToolbarButton('space', ''),
new ToolbarButton('help', '\uF128', (view) => window.open('https://www.markdownguide.org/basic-syntax/', '_blank')),
new ToolbarButton('preview', '\uF06E', (view) => this.#togglePreview()),
];
const toolbarExtension = ViewPlugin.define((view) => new ToolbarPlugin(view, toolbarItems));
return new EditorView({
parent: this.#container,
state: EditorState.create({
doc: content,
extensions: [
this.#editorTheme.of(this.#wantedTheme),
markdown(),
// Replace non-printable characters with placeholders
highlightSpecialChars(),
// The undo history
history(),
// Re-indent lines when typing specific input
indentOnInput(),
// Highlight syntax with a default style
syntaxHighlighting(defaultHighlightStyle),
keymap.of([
// A large set of basic bindings
...defaultKeymap,
// Redo/undo keys
...historyKeymap,
]),
contentChangedNotifier,
toolbarExtension,
EditorView.lineWrapping,
],
}),
});
}
startMarkdownEditor() {
if (!this.#container) {
this.#container = this.#makeContainer();
document.querySelector('#composebodycontainer').append(this.#container);
}
const content = this.#defaultTextarea.value ?? '';
this.#view = this.#makeEditorView(content);
this.#previewIframe = this.#makePreviewIframe(this.#view.scrollDOM);
this.#setupDarkModeWatcher();
// Add a new field to mark the content as markdown (pun intended).
const markdownField = document.createElement('input');
markdownField.type = 'hidden';
markdownField.name = '_markdown_editor';
markdownField.value = '1';
this.#view.dom.append(markdownField);
this.#eventListeners.forEach((callback, eventName) => {
rcmail.addEventListener(eventName, callback);
});
// Disable the spellchecker
rcmail.enable_command('spellcheck', false);
// Hook into the sending logic to convert the content to HTML
this.#defaultTextarea.form.addEventListener('submit', (ev) => {
const is_draft = this.#defaultTextarea.form._draft.value === '1';
// Only convert to HTML if this actually gets send now. We want drafts to be in plain text to not trigger
// TinyMCE to take over when editing drafts.
if (!is_draft) {
this.#defaultTextarea.value = this.#editorContentAsHTML;
this.#defaultTextarea.form._is_html.value = '1';
this.#defaultTextarea.form._markdown_editor.value = '0';
}
});
rcmail.editor.spellcheck_stop();
this.#hide(this.#defaultTextarea, this.#toolbar);
}
stopMarkdownEditor() {
this.#defaultTextarea.value = this.#editorContent;
this.#view.destroy();
this.#eventListeners.forEach((callback, eventName) => {
rcmail.removeEventListener(eventName, callback);
});
this.#stopDarkModeWatcher();
this.#hide(this.#previewIframe);
this.#show(this.#defaultTextarea, this.#toolbar);
// Re-enable the spellchecker
rcmail.enable_command('spellcheck', true);
// Force saving to mark this content as *not* edited by markdown_editor.
rcmail.submit_messageform(true);
}
#stopDarkModeWatcher() {
this.mutationObserver.disconnect();
}
#setupDarkModeWatcher() {
// Callback function to execute when mutations are observed
const mutationCallback = (mutationList, observer) => {
for (const mutation of mutationList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
this.#toggleTheme();
}
}
};
// Create an observer instance linked to the callback function
this.mutationObserver = new MutationObserver(mutationCallback);
// Start observing the target node for configured mutations
this.mutationObserver.observe(document.firstElementChild, { attributes: true, childList: false, subtree: false });
}
#setTextareaCursorPosition() {
this.#defaultTextarea.selectionEnd = this.#view.state.selection.main.head;
}
#reloadContentFromDefaultTextarea() {
this.#debounce(() => {
this.#view.dispatch({
changes: {
from: 0,
to: this.#view.state.doc.length,
insert: this.#defaultTextarea.value,
},
});
const cursorPosition = this.#defaultTextarea.selectionEnd;
if (cursorPosition !== undefined) {
this.#view.dispatch({
selection: {
anchor: cursorPosition,
},
scrollIntoView: true,
});
}
this.#view.focus();
}, 100);
}
#debounce(callback, delay) {
this.#debounceTimers ??= {};
if (this.#debounceTimers[callback]) {
clearTimeout(this.#debounceTimers[callback]);
}
this.#debounceTimers[callback] = setTimeout(() => callback(), delay);
}
#save() {
// Debounce writing to the textarea using a delay of 1s.
this.#debounce(() => {
this.#defaultTextarea.value = this.#editorContent;
}, 1000);
}
#hide(...elems) {
elems.forEach((elem) => {
elem.style.display = 'none';
});
}
#show(...elems) {
elems.forEach((elem) => {
elem.style.display = null;
});
}
get #editorContent() {
return this.#view.state.doc.toString();
}
#disableToolbarButtons() {
this.#textEditingToolbarButtons.forEach((elem) => {
elem.disabled = 'disabled';
});
}
#enableToolbarButtons() {
this.#textEditingToolbarButtons.forEach((elem) => {
elem.disabled = null;
});
}
#togglePreview() {
const previewButtonElem = document.querySelector('.codemirror-toolbar .toolbar-button-preview');
if (this.#previewIframe.checkVisibility()) {
this.#enableToolbarButtons();
this.#hide(this.#previewIframe);
this.#show(this.#view.contentDOM);
previewButtonElem.classList.remove('active');
} else {
// markdown-it by default strips raw HTML, so we don't have to purify the result.
this.#domParser ??= new DOMParser();
const doc = this.#domParser.parseFromString(this.#editorContentAsHTML, 'text/html');
this.#previewIframe.contentDocument.body = doc.body;
this.#disableToolbarButtons();
this.#previewIframe.style.height = this.#view.scrollDOM.scrollHeight + 'px';
this.#hide(this.#view.contentDOM);
this.#show(this.#previewIframe);
previewButtonElem.classList.add('active');
}
}
get #editorContentAsHTML() {
// Replace the space in the signature separator ('\n-- \n') by a non-breakable white-space so it gets preserved in HTML.
return this.#markdownIt.render(this.#editorContent.replace(/\n-- \n/, '\n--\u00A0\n'));
}
}
rcmail.addEventListener('init', () => {
window.markdown_editor = new Index();
if (rcmail.env.start_markdown_editor === true) {
window.markdown_editor.startMarkdownEditor();
}
});

View File

@ -0,0 +1,23 @@
export default class ToolbarButton extends HTMLElement {
constructor(name, content, command) {
super();
this.name = name;
this.className = `fa-icon toolbar-button-${name}`;
this.title = rcmail.get_label(`markdown_editor.toolbar_button_${name}`),
this.command = command;
this.append(content);
}
set disabled(value) {
if (value) {
this.classList.add('disabled');
} else {
this.classList.remove('disabled');
}
}
get disabled() {
return this.classList.contains('disabled');
}
}
customElements.define('toolbar-button', ToolbarButton);

View File

@ -0,0 +1,24 @@
export default class ToolbarPlugin {
destroy() {
this.element.remove();
}
constructor(view, buttons) {
this.view = view;
buttons.forEach((button) => {
if (typeof button.command === 'function') {
button.addEventListener('click', (ev) => {
ev.preventDefault();
if (!button.disabled) {
button.command(this.view);
}
});
button.classList.add('clickable');
}
});
this.element = document.createElement('div');
this.element.classList.add('codemirror-toolbar');
this.element.append(...buttons);
this.view.dom.prepend(this.element);
}
}

View File

@ -0,0 +1,38 @@
<?php
/*
+-----------------------------------------------------------------------+
| Localization file of the Roundcube Webmail MarkdownEditor plugin |
| |
| Copyright (C) The Roundcube Dev Team |
| |
| Licensed under the GNU General Public License version 3 or |
| any later version with exceptions for skins & plugins. |
| See the README file for a full license statement. |
+-----------------------------------------------------------------------+
For translation see https://www.transifex.com/projects/p/roundcube-webmail/resource/plugin-markdown_editor/
*/
$labels = [];
$labels['editor_icon_alt'] = 'Markdown editor';
$labels['editor_button_title'] = 'Compose message in Markdown and have it sent as HTML';
$labels['toolbar_button_quit'] = 'Back to plain text editor';
$labels['toolbar_button_help'] = 'Open a markdown syntax guide in a new window';
$labels['toolbar_button_preview'] = 'Preview HTML as it would be sent';
$labels['toolbar_button_bold'] = 'Bold';
$labels['toolbar_button_italic'] = 'Italic';
$labels['toolbar_button_strike'] = 'Strike through';
$labels['toolbar_button_h1'] = 'Heading level 1';
$labels['toolbar_button_h2'] = 'Heading level 2';
$labels['toolbar_button_h3'] = 'Heading level 3';
$labels['toolbar_button_h4'] = 'Heading level 4';
$labels['toolbar_button_h5'] = 'Heading level 5';
$labels['toolbar_button_h6'] = 'Heading level 6';
$labels['toolbar_button_blockquote'] = 'Blockquote';
$labels['toolbar_button_unordered_list'] = 'Unordered list';
$labels['toolbar_button_ordered_list'] = 'Ordered list';
$labels['toolbar_button_link'] = 'Link';
$labels['toolbar_button_image'] = 'Image';
$labels['toolbar_button_undo'] = 'Undo';
$labels['toolbar_button_redo'] = 'Redo';

View File

@ -0,0 +1,46 @@
<?php
class markdown_editor extends rcube_plugin
{
public $task = 'mail';
protected $rcmail;
#[\Override]
public function init()
{
$this->add_hook('message_compose_body', [$this, 'load_editor']);
$this->add_hook('message_ready', [$this, 'save_markdown_editor_usage']);
}
public function load_editor(array $args): array
{
$start_markdown_editor = false;
if (isset($args['message']->headers)) {
$draft_info = rcmail_sendmail::draftinfo_decode($args['message']->headers->get('x-draft-info'));
$start_markdown_editor = $draft_info['markdown_editor'] === 'yes';
}
$rcmail = rcube::get_instance();
$rcmail->output->set_env('start_markdown_editor', $start_markdown_editor);
// Load the editor files.
$this->include_script('markdown_editor.min.js', ['type' => 'module']);
$this->include_stylesheet($this->local_skin_path() . '/styles/markdown_editor.min.css');
$rcmail->output->set_env('markdown_editor_iframe_css_path', $this->url($this->local_skin_path() . '/styles/iframe.min.css'));
$this->add_texts('localization', true);
return $args;
}
public function save_markdown_editor_usage($args)
{
if (isset($_POST['_markdown_editor']) && $_POST['_markdown_editor'] === '1') {
$draft_info = rcmail_sendmail::draftinfo_decode($args['message']->headers()['X-Draft-Info']);
$draft_info['markdown_editor'] = 'yes';
$args['message']->headers(['X-Draft-Info' => rcmail_sendmail::draftinfo_encode($draft_info)], true);
}
return $args;
}
}

1925
plugins/markdown_editor/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
{
"type": "module",
"dependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/language": "^6.11.2",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.1",
"@fsegurai/codemirror-theme-material-dark": "^6.2.2",
"@fsegurai/codemirror-theme-material-light": "^6.2.2",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4",
"codemirror-markdown-commands": "^0.1.0",
"less": "^4.4.1",
"less-plugin-clean-css": "^1.6.0",
"markdown-it": "^14.1.0",
"rollup": "^4.45.1"
},
"devDependencies": {
"rollup-plugin-bundle-stats": "^4.21.3"
},
"scripts": {
"build": "npm run build-js && npm run build-css",
"build-js": "rollup -c",
"build-css": "cd skins/elastic/styles && lessc --verbose --clean-css='--s1' markdown_editor.less markdown_editor.min.css && lessc --verbose --clean-css='--s1' iframe.less iframe.min.css",
"watch-js": "rollup -c -w",
"clean": "rm -rf node_modules markdown_editor.min.js skins/elastic/styles/{markdown_editor,iframe}.min.css"
}
}

View File

@ -0,0 +1,22 @@
import { nodeResolve } from '@rollup/plugin-node-resolve';
// Un-comment to generate bundle-stats.html
// import { bundleStats } from 'rollup-plugin-bundle-stats';
import terser from '@rollup/plugin-terser';
export default {
input: 'javascript/index.js',
output: [
{
file: 'markdown_editor.min.js',
format: 'es',
plugins: [terser()],
// Un-comment to generate bundle-stats.html
// assetFileNames: 'assets/[name].[hash][extname]',
// chunkFileNames: 'assets/[name].[hash].js',
// entryFileNames: 'assets/[name].[hash].js',
},
],
plugins: [nodeResolve()],
// Un-comment to generate bundle-stats.html
// plugins: [nodeResolve(), bundleStats()]
};

View File

@ -0,0 +1,22 @@
@import (reference) "../../../../../skins/elastic/styles/colors";
// These are the same styles we send in outgoing HTML messages.
body {
color: @color-font;
}
html.dark-mode body {
color: @color-dark-font;
}
blockquote {
padding: 0 0.4em;
border-left: #1010ff 2px solid;
margin: 0;
}
pre, div.pre {
margin: 0;
padding: 0;
font-family: monospace;
}

View File

@ -0,0 +1,142 @@
@import (reference) "../../../../../skins/elastic/styles/colors";
@import (reference) "../../../../../skins/elastic/styles/mixins";
#composebodycontainer.html-editor .editor-toolbar .mce-i-html {
float: left;
margin-left: 6px;
}
.markdown-editor-start-button {
display: block;
float: left;
margin: 2px 0px 2px 0px;
border-radius: .25rem;
color: @color-toolbar-button;
&:hover {
border-color: #e2e4e7;
background-color: #dee0e2; // from TinyMCE
}
svg {
display: block;
width: 24px;
margin: 0.5rem 0.4rem;
fill: @color-toolbar-button;
}
}
#markdown-editor-container {
.cm-editor {
min-height: 45vh;
height: auto;
resize: vertical;
border: solid thin @color-input-border;
border-radius: .25rem;
}
.cm-editor:has(.cm-content:focus) {
border-color: @color-main;
box-shadow: 0 0 0 .2rem @color-input-border-focus-shadow;
}
.cm-content {
padding: 4px;
font-size: 13px;
line-height: 1.5;
}
.codemirror-toolbar {
display: flex;
flex-wrap: wrap;
min-height: 2.47rem;
background-color: #eee;
padding: 0.4rem 0.2rem;
border-bottom: solid thin @color-input-border;
.fa-icon {
&:extend(.font-icon-class);
cursor: default;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1em;
height: 1.5rem;
width: 2rem;
margin: 0;
padding: 0;
}
.fa-icon.toolbar-button-space {
/* Make this element take all left-over space */
flex: 1;
}
.fa-icon.toolbar-button-separator {
color: lightgrey;
margin: 0;
width: 1rem;
}
.fa-icon.clickable {
&:hover {
cursor: pointer;
border-color: #e2e4e7;
background-color: #dee0e2; // from TinyMCE
}
&.active {
color: @color-main;
}
&.disabled,
&.disabled:hover {
opacity: .5;
background-color: inherit;
cursor: default;
}
}
}
#markdown-editor-preview {
height: 45vh;
width: 100%;
border: none;
}
}
html.dark-mode {
.markdown-editor-start-button {
color: @color-dark-font;
svg {
fill: @color-dark-font;
}
&:hover,
&:focus {
background-color: @color-dark-input-addon-background-focus;
}
}
#markdown-editor-container .codemirror-toolbar {
background-color: @color-dark-input-addon-background;
.fa-icon {
color: @color-dark-font;
&.clickable:hover {
background-color: @color-dark-input-addon-background-focus;
}
&.disabled,
&.disabled:hover {
background-color: inherit;
}
}
}
#markdown-preview {
color: @color-dark-font;
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace Tests\Browser\Plugins\MarkdownEditor;
use Roundcube\Tests\Browser\TestCase;
class ComposeTest extends TestCase
{
public function testComposeUI()
{
$this->browse(static function ($browser) {
$browser->go('mail');
$browser->waitFor('#taskmenu .compose');
$browser->click('#taskmenu .compose');
$browser->waitFor('#compose-content .markdown-editor-start-button', 10);
$browser->click('.markdown-editor-start-button');
$browser->waitFor('#markdown-editor-container .cm-editor');
$browser->assertVisible('#markdown-editor-container .cm-content');
$browser->assertMissing('#markdown-editor-container #markdown-editor-preview');
$browser->assertVisible('#markdown-editor-container .codemirror-toolbar');
$browser->assertVisible('#markdown-editor-container .codemirror-toolbar .toolbar-button-quit');
$browser->assertVisible('#markdown-editor-container .codemirror-toolbar .toolbar-button-bold');
$browser->assertVisible('#markdown-editor-container .codemirror-toolbar .toolbar-button-italic');
$browser->assertVisible('#markdown-editor-container .codemirror-toolbar .toolbar-button-blockquote');
$browser->assertVisible('#markdown-editor-container .codemirror-toolbar .toolbar-button-help');
$browser->assertVisible('#markdown-editor-container .codemirror-toolbar .toolbar-button-preview');
$browser->assertElementsCount('#markdown-editor-container .codemirror-toolbar .clickable', 16);
$browser->assertElementsCount('#markdown-editor-container .codemirror-toolbar .clickable.disabled', 0);
// Test that clicking the preview button disables all but two toolbar buttons.
$browser->click('#markdown-editor-container .codemirror-toolbar .toolbar-button-preview');
$browser->assertVisible('#markdown-editor-container .codemirror-toolbar .toolbar-button-bold.disabled');
$browser->assertElementsCount('#markdown-editor-container .codemirror-toolbar .clickable.disabled', 13);
$browser->assertVisible('#markdown-editor-container #markdown-editor-preview');
$browser->assertMissing('#markdown-editor-container .cm-content');
// Test that clicking the preview button again re-enables all toolbar buttons.
$browser->click('#markdown-editor-container .codemirror-toolbar .toolbar-button-preview');
$browser->assertElementsCount('#markdown-editor-container .codemirror-toolbar .clickable.disabled', 0);
$browser->assertMissing('#markdown-editor-container #markdown-editor-preview');
$browser->assertVisible('#markdown-editor-container .cm-content');
// Test the preview iframe.
$browser->keys('#markdown-editor-container .cm-content', 'Hello, World!');
$browser->click('#markdown-editor-container .codemirror-toolbar .toolbar-button-h1');
$browser->assertSeeIn('#markdown-editor-container .cm-content .cm-line:first-child span:nth-child(1)', '#');
$browser->assertSeeIn('#markdown-editor-container .cm-content .cm-line:first-child span:nth-child(2)', 'Hello, World!');
$browser->click('#markdown-editor-container .codemirror-toolbar .toolbar-button-preview');
$browser->withinFrame('#markdown-editor-container #markdown-editor-preview', static function ($browser) {
$browser->assertSeeIn('h1', 'Hello, World!');
});
$browser->click('#markdown-editor-container .codemirror-toolbar .toolbar-button-preview');
$browser->assertMissing('#markdown-editor-container #markdown-editor-preview');
// Test that drafts are still plaintext/markdown.
$browser->click('.header .menu .save.draft');
$browser->waitForMessage('confirmation', 'Message saved to Drafts.');
$browser->waitUntilMissing('#messagestack .confirmation', 10);
// For some reason this only works if we click the button again.
$browser->click('.header .menu .save.draft');
$browser->waitForMessage('confirmation', 'Message saved to Drafts.');
$browser->waitUntilMissing('#messagestack .confirmation', 10);
$browser->click('#taskmenu .mail');
if (!$browser->isDesktop()) {
$browser->click('#messagelist-header .back-sidebar-button');
}
$browser->waitFor('#mailboxlist .mailbox.inbox.selected');
$browser->waitUntilNotBusy();
$browser->click('#mailboxlist .mailbox.drafts a');
if ($browser->isDesktop()) {
$browser->waitFor('#mailboxlist .mailbox.drafts.selected');
}
$browser->waitUntilNotBusy();
$browser->waitFor('#messagelist .message:first-child');
$browser->click('#messagelist .message:first-child');
$browser->waitFor('#messagecontframe');
$browser->withinFrame('#messagecontframe', static function ($browser) {
$browser->waitFor('#message-content #messagebody');
$browser->assertSeeIn('#message-content #messagebody', '# Hello, World!');
});
// Test that the editor starts automatically for a message that we had edited with it.
$browser->withinFrame('#messagecontframe', static function ($browser) {
$browser->press('#message-buttons .btn');
});
$browser->waitFor('#markdown-editor-container .cm-content');
$browser->assertSeeIn('#markdown-editor-container .cm-content .cm-line:first-child', '# Hello, World!');
// Test that sent messages are converted to HTML.
$browser->keys('#compose_to input', 'user@example.com');
$browser->keys('#compose_subject input', 'markdown-editor test');
$browser->click('#compose-content .btn.send');
if (!$browser->isDesktop()) {
$browser->waitFor('#messagelist-header .back-sidebar-button');
$browser->click('#messagelist-header .back-sidebar-button');
}
$browser->waitFor('#mailboxlist .mailbox.drafts.selected');
$browser->waitUntilNotBusy();
$browser->click('#mailboxlist .mailbox.sent a');
if ($browser->isDesktop()) {
$browser->waitFor('#mailboxlist .mailbox.sent.selected');
}
$browser->waitFor('#messagelist .message:first-child');
$browser->click('#messagelist .message:first-child');
$browser->waitFor('#messagecontframe');
$browser->withinFrame('#messagecontframe', static function ($browser) {
$browser->waitFor('#message-content #messagebody');
$browser->assertSeeIn('#message-content #messagebody h1', 'Hello, World!');
});
});
}
}

View File

@ -886,6 +886,10 @@ class rcmail_sendmail
{
$info = [];
if (empty(trim($str))) {
return $info;
}
foreach (preg_split('/;\s+/', $str) as $part) {
[$key, $val] = explode('=', $part, 2);
if (str_starts_with($val, 'B::')) {