mirror of
https://github.com/roundcube/roundcubemail.git
synced 2025-12-27 23:45:58 +00:00
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:
parent
3141699a8b
commit
e34a813355
@ -34,6 +34,7 @@ $config['plugins'] = [
|
||||
'archive',
|
||||
'attachment_reminder',
|
||||
'markasjunk',
|
||||
'markdown_editor',
|
||||
'zipdownload',
|
||||
];
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
18
Makefile
18
Makefile
@ -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
5
plugins/markdown_editor/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
/bundle-stats.html
|
||||
/skins/elastic/styles/markdown_editor.*css
|
||||
/skins/elastic/styles/iframe.*css
|
||||
/markdown_editor.*js
|
||||
20
plugins/markdown_editor/README.md
Normal file
20
plugins/markdown_editor/README.md
Normal 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.
|
||||
5
plugins/markdown_editor/build.sh
Executable file
5
plugins/markdown_editor/build.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex
|
||||
|
||||
exec npm run build
|
||||
22
plugins/markdown_editor/composer.json
Normal file
22
plugins/markdown_editor/composer.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
379
plugins/markdown_editor/javascript/index.js
Normal file
379
plugins/markdown_editor/javascript/index.js
Normal 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();
|
||||
}
|
||||
});
|
||||
23
plugins/markdown_editor/javascript/toolbar-button.js
Normal file
23
plugins/markdown_editor/javascript/toolbar-button.js
Normal 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);
|
||||
24
plugins/markdown_editor/javascript/toolbar-plugin.js
Normal file
24
plugins/markdown_editor/javascript/toolbar-plugin.js
Normal 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);
|
||||
}
|
||||
}
|
||||
38
plugins/markdown_editor/localization/en_US.inc
Normal file
38
plugins/markdown_editor/localization/en_US.inc
Normal 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';
|
||||
46
plugins/markdown_editor/markdown_editor.php
Normal file
46
plugins/markdown_editor/markdown_editor.php
Normal 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
1925
plugins/markdown_editor/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
plugins/markdown_editor/package.json
Normal file
29
plugins/markdown_editor/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
plugins/markdown_editor/rollup.config.js
Normal file
22
plugins/markdown_editor/rollup.config.js
Normal 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()]
|
||||
};
|
||||
22
plugins/markdown_editor/skins/elastic/styles/iframe.less
Normal file
22
plugins/markdown_editor/skins/elastic/styles/iframe.less
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
113
plugins/markdown_editor/tests/Browser/ComposeTest.php
Normal file
113
plugins/markdown_editor/tests/Browser/ComposeTest.php
Normal 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!');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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::')) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user