Use tinymce instead of CKEditor.

- better integration of tinymce in bower and gulp
- Improve support for html tags in reportlab's motion pdf.
- Now paste from word works without problems
  (That was the main reason of switching to tinymce:
   The data loss problem with MS Word is still unfixed in CKEditor,
   see https://dev.ckeditor.com/ticket/13174)
- The editor is now used for customslides (text), motions (text,
  reason) and users (about).
- Use mediafile image list for tinymce.
- Use own repository for tinymce-i18n: OpenSlides/tinymce-i18n
This commit is contained in:
Emanuel Schuetze 2016-02-12 00:15:35 +01:00
parent ad7653fb76
commit 16f1ad5731
18 changed files with 232 additions and 81 deletions

View File

@ -27,15 +27,22 @@
"js-data": "~2.8.2",
"js-data-angular": "~3.1.0",
"ng-file-upload": "~11.2.3",
"ckeditor": "4.5.6",
"angular-ckeditor": "~1.0.3",
"angular-ui-tinymce": "~0.0.13",
"angular-pdf": "~1.3.0",
"roboto-condensed": "~0.3.0",
"open-sans-fontface": "https://github.com/OpenSlides/open-sans.git#1.4.2.post1"
"open-sans-fontface": "https://github.com/OpenSlides/open-sans.git#1.4.2.post1",
"tinymce-i18n": "OpenSlides/tinymce-i18n"
},
"overrides": {
"pdfjs-dist": {
"main": "build/pdf.combined.js"
},
"tinymce-dist": {
"main": [
"tinymce.js",
"themes/modern/theme.js",
"plugins/*/plugin.js"
]
}
},
"resolutions": {

View File

@ -25,6 +25,7 @@ var argv = require('yargs').argv,
jshint = require('gulp-jshint'),
mainBowerFiles = require('main-bower-files'),
path = require('path'),
rename = require('gulp-rename'),
through = require('through2'),
uglify = require('gulp-uglify'),
vsprintf = require('sprintf-js').vsprintf;
@ -67,10 +68,27 @@ gulp.task('fonts-libs', function() {
.pipe(gulp.dest(path.join(output_directory, 'fonts')));
});
// Extra task only for CKEditor
gulp.task('ckeditor', function () {
return gulp.src(path.join('bower_components', 'ckeditor', '**'))
.pipe(gulp.dest(path.join(output_directory, 'ckeditor')));
// Catches all skins files for TinyMCE editor.
gulp.task('tinymce-skins', function () {
return gulp.src(path.join('bower_components', 'tinymce-dist', 'skins', '**'))
.pipe(gulp.dest(path.join(output_directory, 'tinymce', 'skins')));
});
// Catches all required i18n files for TinyMCE editor.
gulp.task('tinymce-i18n', function () {
return gulp.src([
'bower_components/tinymce-i18n/langs/cs.js',
'bower_components/tinymce-i18n/langs/de.js',
'bower_components/tinymce-i18n/langs/es.js',
'bower_components/tinymce-i18n/langs/fr_FR.js',
'bower_components/tinymce-i18n/langs/pt_PT.js',
])
.pipe(rename(function (path) {
if (path.basename === 'pt_PT') {path.basename = 'pt'}
if (path.basename === 'fr_FR') {path.basename = 'fr'}
}))
.pipe(gulpif(argv.production, uglify()))
.pipe(gulp.dest(path.join(output_directory, 'tinymce', 'i18n')));
});
// Compiles translation files (*.po) to *.json and saves them in the directory
@ -84,7 +102,7 @@ gulp.task('translations', function () {
});
// Gulp default task. Runs all other tasks before.
gulp.task('default', ['js-libs', 'css-libs', 'fonts-libs', 'ckeditor', 'translations'], function () {});
gulp.task('default', ['js-libs', 'css-libs', 'fonts-libs', 'tinymce-skins', 'tinymce-i18n', 'translations'], function () {});
/**

View File

@ -75,9 +75,10 @@ angular.module('OpenSlidesApp.agenda.site', ['OpenSlidesApp.agenda'])
'operator',
'ngDialog',
'Agenda',
'CustomslideForm',
'AgendaTree',
'Projector',
function($scope, $http, $state, DS, operator, ngDialog, Agenda, AgendaTree, Projector) {
function($scope, $http, $state, DS, operator, ngDialog, Agenda, CustomslideForm, AgendaTree, Projector) {
// Bind agenda tree to the scope
$scope.$watch(function () {
return Agenda.lastModified();
@ -110,11 +111,7 @@ angular.module('OpenSlidesApp.agenda.site', ['OpenSlidesApp.agenda'])
};
// open new dialog
$scope.newDialog = function () {
ngDialog.open({
template: 'static/templates/core/customslide-form.html',
controller: 'CustomslideCreateCtrl',
className: 'ngdialog-theme-default wide-form'
});
ngDialog.open(CustomslideForm.getDialog());
};
// open edit dialog
$scope.editDialog = function (item) {

View File

@ -424,34 +424,15 @@ angular.module('OpenSlidesApp.core', [
}
])
/* Options for CKEditor used in various create and edit views. */
.value('CKEditorOptions', {
allowedContent:
'h1 h2 h3 p pre b i u strike strong em blockquote;' +
'a[!href];' +
'img[!src,alt]{width,height,float};' +
'table tr th td caption;' +
'li ol ul{list-style};' +
'span{color,background-color};',
extraPlugins: 'colorbutton',
toolbarGroups: [
{ name: 'clipboard', groups: [ 'clipboard', 'undo' ] },
{ name: 'editing', groups: [ 'find', 'selection', 'spellchecker', 'editing' ] },
{ name: 'forms', groups: [ 'forms' ] },
{ name: 'tools', groups: [ 'tools' ] },
{ name: 'about', groups: [ 'about' ] },
{ name: 'document', groups: [ 'mode', 'document', 'doctools' ] },
{ name: 'others', groups: [ 'others' ] },
'/',
{ name: 'styles', groups: [ 'styles' ] },
{ name: 'basicstyles', groups: [ 'basicstyles', 'cleanup' ] },
{ name: 'paragraph', groups: [ 'list', 'indent', 'blocks', 'align', 'bidi', 'paragraph' ] },
{ name: 'links', groups: [ 'links' ] },
{ name: 'insert', groups: [ 'insert' ] },
{ name: 'colors', groups: [ 'colors' ] }
],
removeButtons: 'Anchor,SpecialChar,Subscript,Superscript,Styles,RemoveFormat,HorizontalRule'
})
// mark HTML as "trusted"
.filter('trusted', [
'$sce',
function ($sce) {
return function(text) {
return $sce.trustAsHtml(text);
};
}
])
// Make sure that the DS factories are loaded by making them a dependency
.run([

View File

@ -14,8 +14,8 @@ angular.module('OpenSlidesApp.core.site', [
'ngMessages',
'ngCsvImport',
'ui.select',
'ui.tinymce',
'luegg.directives',
'ckeditor',
])
// Provider to register entries for the main menu.
@ -337,10 +337,16 @@ angular.module('OpenSlidesApp.core.site', [
.run([
'formlyConfig',
function (formlyConfig) {
// NOTE: This next line is highly recommended. Otherwise Chrome's autocomplete will appear over your options!
// NOTE: This next line is highly recommended. Otherwise Chrome's autocomplete
// will appear over your options!
formlyConfig.extras.removeChromeAutoComplete = true;
// Configure custom types
formlyConfig.setType({
name: 'editor',
extends: 'textarea',
templateUrl: 'static/templates/core/editor.html',
});
formlyConfig.setType({
name: 'ui-select-single',
extends: 'select',
@ -354,6 +360,35 @@ angular.module('OpenSlidesApp.core.site', [
}
])
// Options for TinyMCE editor used in various create and edit views.
.factory('Editor', [
'gettextCatalog',
function (gettextCatalog) {
return {
getOptions: function (images) {
return {
language_url: '/static/tinymce/i18n/' + gettextCatalog.getCurrentLanguage() + '.js',
theme_url: '/static/js/openslides-libs.js',
skin_url: '/static/tinymce/skins/lightgray/',
inline: false,
statusbar: false,
browser_spellcheck: true,
image_advtab: true,
image_list: images,
plugins: [
'lists link autolink charmap preview searchreplace code fullscreen',
'paste textcolor colorpicker image imagetools'
],
menubar: '',
toolbar: 'undo redo searchreplace | styleselect | bold italic underline strikethrough ' +
'forecolor backcolor removeformat | bullist numlist | outdent indent | ' +
'link image charmap table | code preview fullscreen'
};
}
}
}
])
// html-tag os-form-field to generate generic from fields
// TODO: make it possible to use other fields then config fields
.directive('osFormField', [
@ -504,17 +539,19 @@ angular.module('OpenSlidesApp.core.site', [
// Provide generic customslide form fields for create and update view
.factory('CustomslideForm', [
'gettextCatalog',
'CKEditorOptions',
'Editor',
'Mediafile',
function (gettextCatalog, CKEditorOptions, Mediafile) {
function (gettextCatalog, Editor, Mediafile) {
return {
// ngDialog for customslide form
getDialog: function (customslide) {
var resolve = {};
if (customslide) {
var resolve = {
resolve = {
customslide: function(Customslide) {return Customslide.find(customslide.id);}
};
}
resolve.mediafiles = function(Mediafile) {return Mediafile.findAll();}
return {
template: 'static/templates/core/customslide-form.html',
controller: (customslide) ? 'CustomslideUpdateCtrl' : 'CustomslideCreateCtrl',
@ -525,6 +562,7 @@ angular.module('OpenSlidesApp.core.site', [
}
},
getFormFields: function () {
var images = Mediafile.getAllImages();
return [
{
key: 'title',
@ -536,11 +574,13 @@ angular.module('OpenSlidesApp.core.site', [
},
{
key: 'text',
type: 'textarea',
type: 'editor',
templateOptions: {
label: gettextCatalog.getString('Text')
},
ngModelElAttrs: {'ckeditor': 'CKEditorOptions'}
data: {
tinymceOption: Editor.getOptions(images)
}
},
{
key: 'attachments_id',

View File

@ -30,7 +30,7 @@
</div>
<div class="details">
<div ng-bind-html="customslide.text"></div>
<div ng-bind-html="customslide.text | trusted"></div>
<h3 ng-if="customslide.attachments.length > 0" translate>Attachments</h3>
<ul>
<li ng-repeat="attachment in customslide.attachments">

View File

@ -0,0 +1,2 @@
<!-- custom angular formly template for tinymce textarea field -->
<textarea ui-tinymce="options.data.tinymceOption" ng-model="model[options.key]" class="form-control"></textarea>

View File

@ -1,4 +1,4 @@
<div ng-controller="SlideCustomSlideCtrl" class="content scrollcontent">
<h1>{{ customslide.agenda_item.getTitle() }}</h1>
<div ng-bind-html="customslide.text"></div>
<div ng-bind-html="customslide.text | trusted"></div>
</div>

View File

@ -10,7 +10,6 @@
<link rel="stylesheet" href="static/css/app.css">
<link rel="icon" href="/static/img/favicon.png">
<script src="static/js/openslides-libs.js"></script>
<script src="static/ckeditor/ckeditor.js"></script>
<div id="wrapper" ng-cloak>

View File

@ -12,6 +12,15 @@ angular.module('OpenSlidesApp.mediafiles', [])
return DS.defineResource({
name: name,
useClass: jsDataModel,
getAllImages: function () {
var images = []
angular.forEach(this.getAll(), function(file) {
if (file.is_image) {
images.push({title: file.title, value: file.mediafileUrl});
}
});
return images;
},
methods: {
getResourceName: function () {
return name;
@ -30,6 +39,10 @@ angular.module('OpenSlidesApp.mediafiles', [])
var PRESENTABLE_FILE_TYPES = ['application/pdf'];
return _.contains(PRESENTABLE_FILE_TYPES, filetype);
}],
is_image: ['filetype', function (filetype) {
var IMAGE_FILE_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
return _.contains(IMAGE_FILE_TYPES, filetype);
}],
mediafileUrl: [function () {
return this.media_url_prefix + this.mediafile.name;
}],

View File

@ -1,4 +1,5 @@
import random
import re
from html import escape
from operator import attrgetter
@ -153,6 +154,23 @@ def motion_to_pdf(pdf, motion):
def convert_html_to_reportlab(pdf, text):
# parsing and replacing not supported html tags for reportlab...
soup = BeautifulSoup(text, "html5lib")
# number ol list elements
ols = soup.find_all('ol')
for ol in ols:
counter = 0
for li in ol.children:
if li.name == 'li':
# if start attribute is available set counter for first list element
if li.parent.get('start') and not li.find_previous_sibling():
counter = int(ol.get('start'))
else:
counter += 1
if li.get('value'):
counter = li.get('value')
else:
li['value'] = counter
# read all list elements...
for element in soup.find_all('li'):
# ... and replace ul list elements with <para><bullet>&bull;</bullet>...<para>
@ -167,22 +185,32 @@ def convert_html_to_reportlab(pdf, text):
bullet_tag = soup.new_tag("bullet")
bullet_tag.string = u""
element.insert(0, bullet_tag)
# ... and replace ol list elements with <para><bullet><seq id="%id"></seq>.</bullet>...</para>
# ... and replace ol list elements with <para><bullet><seqreset id="%id" base="value"><seq id="%id"></seq>.</bullet>...</para>
if element.parent.name == "ol":
counter = None
# set list id if element is the first of numbered list
if not element.find_previous_sibling():
id = random.randrange(0, 101)
if element.parent.get('start'):
counter = element.parent.get('start')
if element.get('value'):
counter = element.get('value')
# nested lists
if element.ul or element.ol:
for i in element.find_all('li'):
element.insert_before(i)
element.clear()
else:
element.name = "para"
element.insert(0, soup.new_tag("bullet"))
element.bullet.insert(0, soup.new_tag("seq"))
element.bullet.seq['id'] = id
element.bullet.insert(1, ".")
nested_list = element.find_all('li')
for i in reversed(nested_list):
element.insert_after(i)
element.attrs = {}
element.name = "para"
element.insert(0, soup.new_tag("bullet"))
element.bullet.insert(0, soup.new_tag("seq"))
element.bullet.seq['id'] = id
if counter:
element.bullet.insert(0, soup.new_tag("seqreset"))
element.bullet.seqreset['id'] = id
element.bullet.seqreset['base'] = int(counter) - 1
element.bullet.insert(2, ".")
# remove tags which are not supported by reportlab (replace tags with their children tags)
for tag in soup.find_all('ul'):
tag.unwrap()
@ -190,8 +218,60 @@ def convert_html_to_reportlab(pdf, text):
tag.unwrap()
for tag in soup.find_all('li'):
tag.unwrap()
# use tags which are supported by reportlab
# replace <s> to <strike>
for tag in soup.find_all('s'):
tag.name = "strike"
# replace <del> to <strike>
for tag in soup.find_all('del'):
tag.name = "strike"
for tag in soup.find_all('a'):
# remove a tags without href attribute
if not tag.get('href'):
tag.extract()
for tag in soup.find_all('img'):
# remove img tags without src attribute
if not tag.get('src'):
tag.extract()
# replace style attributes in <span> tags
for tag in soup.find_all('span'):
tag.unwrap()
if tag.get('style'):
# replace style attribute "text-decoration: line-through;" to <strike> tag
if 'text-decoration: line-through' in str(tag['style']):
strike_tag = soup.new_tag("strike")
strike_tag.string = tag.string
tag.replace_with(strike_tag)
# replace style attribute "text-decoration: underline;" to <u> tag
elif 'text-decoration: underline' in str(tag['style']):
u_tag = soup.new_tag("u")
u_tag.string = tag.string
tag.replace_with(u_tag)
# replace style attribute "color: #xxxxxx;" to "<font backcolor='#xxxxxx'>...</font>"
elif 'background-color: ' in str(tag['style']):
font_tag = soup.new_tag("font")
color = re.findall('background-color: (.*?);', str(tag['style']))
if color:
font_tag['backcolor'] = color
if tag.string:
font_tag.string = tag.string
tag.replace_with(font_tag)
# replace style attribute "color: #xxxxxx;" to "<font color='#xxxxxx'>...</font>"
elif 'color: ' in str(tag['style']):
font_tag = soup.new_tag("font")
color = re.findall('color: (.*?);', str(tag['style']))
if color:
font_tag['color'] = color
if tag.string:
font_tag.string = tag.string
tag.replace_with(font_tag)
else:
tag.unwrap()
else:
tag.unwrap()
# print paragraphs with numbers
text = soup.body.contents
paragraph_number = 1

View File

@ -152,18 +152,20 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
.factory('MotionForm', [
'gettextCatalog',
'operator',
'Editor',
'Category',
'Config',
'Mediafile',
'Tag',
'User',
'Workflow',
function (gettextCatalog, operator, Category, Config, Mediafile, Tag, User, Workflow) {
function (gettextCatalog, operator, Editor, Category, Config, Mediafile, Tag, User, Workflow) {
return {
// ngDialog for motion form
getDialog: function (motion) {
var resolve = {}
if (motion) {
var resolve = {
resolve = {
motion: function() {
return motion;
},
@ -172,6 +174,7 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
}
};
}
resolve.mediafiles = function(Mediafile) {return Mediafile.findAll();}
return {
template: 'static/templates/motions/motion-form.html',
controller: (motion) ? 'MotionUpdateCtrl' : 'MotionCreateCtrl',
@ -187,6 +190,7 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
angular.forEach(workflows, function(workflow) {
workflow.name = gettextCatalog.getString(workflow.name);
});
var images = Mediafile.getAllImages();
return [
{
key: 'identifier',
@ -220,20 +224,24 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
},
{
key: 'text',
type: 'textarea',
type: 'editor',
templateOptions: {
label: gettextCatalog.getString('Text'),
required: true
},
ngModelElAttrs: {'ckeditor': 'CKEditorOptions'}
data: {
tinymceOption: Editor.getOptions(images)
}
},
{
key: 'reason',
type: 'textarea',
type: 'editor',
templateOptions: {
label: gettextCatalog.getString('Reason')
label: gettextCatalog.getString('Reason'),
},
ngModelElAttrs: {'ckeditor': 'CKEditorOptions'}
data: {
tinymceOption: Editor.getOptions(images)
}
},
{
key: 'disable_versioning',

View File

@ -218,12 +218,12 @@
<div class="row">
<div class="col-sm-8">
<h3 translate>Text</h3>
<div ng-bind-html="motion.getText(version)"></div>
<div ng-bind-html="motion.getText(version) | trusted"></div>
<!-- reason -->
<div ng-if="motion.getReason(version) != ''">
<h3 translate>Reason</h3>
<div ng-bind-html="motion.getReason()"></div>
<div ng-bind-html="motion.getReason() | trusted"></div>
</div>
<!-- attachments -->

View File

@ -15,4 +15,3 @@
</button>
</formly-form>
</form>

View File

@ -62,9 +62,9 @@
</div>
<!-- Text -->
<div ng-bind-html="motion.getText()"></div>
<div ng-bind-html="motion.getText() | trusted"></div>
<!-- Reason -->
<h3 ng-if="motion.getReason()" translate>Reason</h3>
<div ng-bind-html="motion.getReason()"></div>
<div ng-bind-html="motion.getReason() | trusted"></div>
</div>

View File

@ -235,8 +235,10 @@ angular.module('OpenSlidesApp.users.site', ['OpenSlidesApp.users'])
.factory('UserForm', [
'$http',
'gettextCatalog',
'Editor',
'Group',
function ($http, gettextCatalog, Group) {
'Mediafile',
function ($http, gettextCatalog, Editor, Group, Mediafile) {
return {
// ngDialog for user form
getDialog: function (user) {
@ -256,6 +258,7 @@ angular.module('OpenSlidesApp.users.site', ['OpenSlidesApp.users'])
},
// angular-formly fields for user form
getFormFields: function (hideOnCreateForm) {
var images = Mediafile.getAllImages();
return [
{
key: 'username',
@ -334,12 +337,13 @@ angular.module('OpenSlidesApp.users.site', ['OpenSlidesApp.users'])
},
{
key: 'about_me',
type: 'textarea',
type: 'editor',
templateOptions: {
label: gettextCatalog.getString('About me'),
description: gettextCatalog.getString('Profile text.')
},
ngModelElAttrs: {'ckeditor': 'CKEditorOptions'}
data: {
tinymceOption: Editor.getOptions(images)
}
},
{
key: 'is_present',
@ -549,10 +553,12 @@ angular.module('OpenSlidesApp.users.site', ['OpenSlidesApp.users'])
.controller('UserProfileCtrl', [
'$scope',
'$state',
'Editor',
'User',
'user',
function($scope, $state, User, user) {
function($scope, $state, Editor, User, user) {
$scope.user = user; // autoupdate is not activated
$scope.tinymceOption = Editor.getOptions();
$scope.save = function (user) {
User.save(user, { method: 'PATCH' }).then(
function(success) {

View File

@ -37,7 +37,7 @@
</div>
<div class="form-group">
<label for="textAbout" translate>About me</label>
<textarea ng-model="user.about_me" class="form-control" name="textAbout" />
<textarea ng-model="user.about_me" ui-tinymce="tinymceOption" class="form-control" name="textAbout" />
</div>
<button type="submit" ng-click="save(user)" class="btn btn-primary" translate>

View File

@ -13,6 +13,7 @@
"gulp-cssnano": "~2.1.0",
"gulp-if": "~2.0.0",
"gulp-jshint": "~2.0.0",
"gulp-rename": "~1.2.2",
"gulp-uglify": "~1.5.2",
"main-bower-files": "~2.11.1",
"po2json": "~0.4.1",