Skip to content

Commit

Permalink
Merge pull request #44 from unlimitedlabs/quill
Browse files Browse the repository at this point in the history
Integrate quilljs component for custom task views
  • Loading branch information
jrbotros committed Oct 8, 2015
2 parents d8af990 + a4595d4 commit f12e156
Show file tree
Hide file tree
Showing 11 changed files with 417 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!--
Rich text editor component based on https://github.com/quilljs/quill/.
To use the editor's image upload functionality, include `AWS_S3_KEY`,
`AWS_S3_SECRET`, and `EDITOR_IMAGE_BUCKET_NAME` in your project's Orchestra
settings.
The `data` attribute binds the desired data to the rich text editor and will
contain an HTML blob.
The optional `image-prefix` attribute provides a prefix for editor image S3 keys
(http://docs.aws.amazon.com/AWSImportExport/latest/DG/ManipulatingS3KeyNames.html).
The optional `upload-limit-mb` attribute provides a maximum file size for editor
images uploaded to S3.
-->
<orchestra-quill data="vm.testData"
image-prefix="'editor-image-album'"
upload-limit-mb="15">
</orchestra-quill>
11 changes: 11 additions & 0 deletions orchestra/static/orchestra/common/components/modules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
(function () {
'use strict';

angular.module('orchestra.common.components', [
'orchestra.common.components.controllers',
'orchestra.common.components.directives'
]);

angular.module('orchestra.common.components.controllers', []);
angular.module('orchestra.common.components.directives', []);
})();
19 changes: 19 additions & 0 deletions orchestra/static/orchestra/common/components/quill/css/quill.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.orchestra-quill-wrapper {
background-color: #fff;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
box-shadow: 0px 0px 10px #ccc;
margin-left: auto;
margin-right: auto;
width: 100%;

.orchestra-quill-editor {
height: 400px;
}

.orchestra-quill-toolbar {
margin-top: 15px;
border-bottom: 1px solid #ccc;
border-top: 1px solid #ccc;
}
}

Large diffs are not rendered by default.

218 changes: 218 additions & 0 deletions orchestra/static/orchestra/common/components/quill/js/directives.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
(function () {
'use strict';

angular
.module('orchestra.common.components.directives')
.directive('orchestraQuill', ['$http', '$timeout', orchestraQuill]);

function orchestraQuill($http, $timeout) {
return {
restrict: 'E',
scope: {
'data': '=',
'imagePrefix': '=',
'readonly': '=',
'uploadLimitMb': '=?',
},
link: function(scope, el, attr) {
// Suggested limit for image upload size is 5 MB.
scope.uploadLimitMb = scope.uploadLimitMb || 5;

// Containers within directive template for Quill editor
var editorContainer = el.find('.orchestra-quill-editor').get(0);
var toolbarContainer = el.find('.orchestra-quill-toolbar').get(0);

scope.editor = new Quill(editorContainer, {
modules: {
'toolbar': {container: toolbarContainer},
// Image tooltip removed in favor of file selector dialog
'link-tooltip': true,
},
theme: 'snow'
});

// Set up two-way link between quill and parent scope attribute passed
// in by directive caller
if (scope.data) {
scope.editor.setHTML(scope.data);
}

// Editor set to read-only upon initialization.
if (scope.readonly) {
scope.editor.editor.disable();
toolbarContainer.remove();
return;
}

scope.editor.on('text-change', function() {
// Set the focus outside the $digest block.
// Taken from https://docs.angularjs.org/error/$rootScope/inprog?p0=$digest.
$timeout(function() {
scope.data = scope.editor.getHTML();
scope.$apply();
}, 0, false);
});

// Upload image via the toolbar button
scope.fileSelector = document.createElement('input');
scope.fileSelector.setAttribute('type', 'file');
scope.fileSelector.setAttribute('accept', 'image/*');
var imageSelector = el.find('.orchestra-quill-toolbar .ql-image').get(0);
scope.fileSelector.onchange = function(e) {
// Focus the editor so we can get the current selection.
scope.editor.focus();
var files = scope.fileSelector.files;
for (var i = files.length - 1; i >= 0; i--) {
if (files[i] !== null) {
uploadImage(files[i], scope.editor.getSelection());
}
}
}
imageSelector.onclick = function () {
scope.fileSelector.click();
return false;
}

// TODO(jrbotros): move paste/drop functionality into a quilljs fork

// Upload image by pasting in copied image data
editorContainer.addEventListener('paste', pasteImage, true);
function pasteImage(e) {
if (e.clipboardData && e.clipboardData.items) {
var copiedData = e.clipboardData.items[0];
var imageFile = copiedData.getAsFile();
uploadImage(imageFile, scope.editor.getSelection(), e);
}
}

// Upload image by drag and drop
editorContainer.addEventListener('drop', dropImage, true);
function dropImage(e) {
var files = e.dataTransfer.files;
for (var i = files.length - 1; i >= 0; i--) {
var file = files[i];
var dropOffset = getDropIndexOffset(e);
uploadImage(file, {'start': dropOffset, 'end': dropOffset}, e);
}
}

function getDropIndexOffset(dropEvent) {
/**
* Given a drop event, calculate the index offset within the
* Quill editor by:
* - determining the character offset of the drop within the inner node
* - finding all editor leaf nodes (reimplements some Quill functionality)
* - calculating the index offset from the leaf node lengths and inner drop node offset
* We plan to push this functionality into Quill in the future.
*/
function getDropCharOffset(x, y) {
// Helper function for getting the x-y character offset of a drop
// event in a contenteditable field.
// Modified from http://stackoverflow.com/a/10659990.
var range;
// Standards-based way; implemented only in Firefox
if (document.caretPositionFromPoint) {
var pos = document.caretPositionFromPoint(x, y);
return {'offset': pos.offset, 'node': pos.offsetNode};
} else if (document.caretRangeFromPoint) {
// Webkit
range = document.caretRangeFromPoint(x, y);
return {'offset': range.startOffset, 'node': range.startContainer};
} else if (document.body.createTextRange) {
// IE doesn't natively support retrieving the character offset, so
// insert image at end of text
// TODO(jrbotros): rewrite with https://github.com/timdown/rangy
return;
}
}

function getAllChildNodes(parent) {
// Returns in-order list of all child nodes of `parent`
var bfsStack = [parent];
var nodes = [];
while (bfsStack.length) {
var currentNode = bfsStack.pop();
if (currentNode.childNodes) {
// Concat with copy of child NodeList; can't use pop operation
// on original
bfsStack = bfsStack.concat(Array.prototype.slice.call(currentNode.childNodes));
}
nodes.push(currentNode);
}
return nodes.reverse();
}

// http://stackoverflow.com/a/22289650
function getLeafNodes(parent) {
// Returns in-order list of all Quill leaf nodes of `parent`
var textNodeType = 3;
var nodes = getAllChildNodes(parent);
var leafNodes = nodes.filter(function(elem) {
return elem.nodeType === textNodeType || !elem.firstChild;
});
return leafNodes;
}

var caretPosition = getDropCharOffset(dropEvent.clientX, dropEvent.clientY);
if (!caretPosition) {
return scope.editor.getLength();
}
var leaves = getLeafNodes(editorContainer.getElementsByClassName('ql-editor')[0]);
var opIndex = leaves.indexOf(caretPosition.node);
var contents = scope.editor.getContents()
contents.ops = contents.ops.slice(0, opIndex)
return contents.length() + caretPosition.offset;
}


// Post image data to an API endpoint that returns an image URL, then
// adding it to the editor (replacing the given character range)
function uploadImage(file, range, e) {
var uploadAPIEndpoint = '/orchestra/api/interface/upload_image/'
var supportedTypes = ['image/jpeg', 'image/png', 'image/gif']

if (supportedTypes.indexOf(file.type) === -1) {
alert('Files type ' + file.type + ' not supported.')
return;
} else if (e) {
// Cancel default browser action only if file is actually an image
e.preventDefault();
}

// If nothing is selected in the editor, append the image to its end
if (range === null) {
var endIndex = scope.editor.getLength();
range = {'start': endIndex, 'end': endIndex}
}

var reader = new FileReader();
reader.onload = function(e) {
var rawData = e.target.result;
// Remove prepended image type from data string
var imageData = rawData.substring(rawData.indexOf(",") + 1, rawData.length)

// Calculate data size of b64-encoded string
var imageSize = imageData.length * 3 / 4;

if (imageSize > scope.uploadLimitMb * Math.pow(10, 6)) {
alert('Files larger than ' + scope.uploadLimitMb + 'MB cannot be uploaded');
return;
}
// Post image data to API; response should contain the uploaded image url
$http.post(uploadAPIEndpoint,
{'image_data': imageData,
'image_type': file.type,
'prefix': scope.imagePrefix})
.then(function(response, status, headers, config) {
// Replace selected range with new image
scope.editor.deleteText(range);
scope.editor.insertEmbed(range.start, 'image', response.data.url, 'user');
})
}
reader.readAsDataURL(file);
}
},
templateUrl: '/static/orchestra/common/components/quill/partials/quill.html'
};
}
})();

Large diffs are not rendered by default.

123 changes: 123 additions & 0 deletions orchestra/static/orchestra/common/components/quill/partials/quill.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<div ng-class="{'readonly': readonly}" class="orchestra-quill-wrapper">
<div class="orchestra-quill-toolbar">
<span class="ql-format-group">
<select title="Font" class="ql-font">
<option value="sans-serif" selected="">Sans Serif</option>
<option value="serif">Serif</option>
<option value="monospace">Monospace</option>
</select>
<select title="Size" class="ql-size">
<option value="10px">Small</option>
<option value="13px" selected="">Normal</option>
<option value="18px">Large</option>
<option value="32px">Huge</option>
</select>
</span>
<span class="ql-format-group">
<span title="Bold" class="ql-format-button ql-bold"></span>
<span class="ql-format-separator"></span>
<span title="Italic" class="ql-format-button ql-italic"></span>
<span class="ql-format-separator"></span>
<span title="Underline" class="ql-format-button ql-underline"></span>
<span class="ql-format-separator"></span>
<span title="Strikethrough" class="ql-format-button ql-strike"></span>
</span>
<span class="ql-format-group">
<select title="Text Color" class="ql-color">
<option value="rgb(0, 0, 0)" label="rgb(0, 0, 0)" selected=""></option>
<option value="rgb(230, 0, 0)" label="rgb(230, 0, 0)"></option>
<option value="rgb(255, 153, 0)" label="rgb(255, 153, 0)"></option>
<option value="rgb(255, 255, 0)" label="rgb(255, 255, 0)"></option>
<option value="rgb(0, 138, 0)" label="rgb(0, 138, 0)"></option>
<option value="rgb(0, 102, 204)" label="rgb(0, 102, 204)"></option>
<option value="rgb(153, 51, 255)" label="rgb(153, 51, 255)"></option>
<option value="rgb(255, 255, 255)" label="rgb(255, 255, 255)"></option>
<option value="rgb(250, 204, 204)" label="rgb(250, 204, 204)"></option>
<option value="rgb(255, 235, 204)" label="rgb(255, 235, 204)"></option>
<option value="rgb(255, 255, 204)" label="rgb(255, 255, 204)"></option>
<option value="rgb(204, 232, 204)" label="rgb(204, 232, 204)"></option>
<option value="rgb(204, 224, 245)" label="rgb(204, 224, 245)"></option>
<option value="rgb(235, 214, 255)" label="rgb(235, 214, 255)"></option>
<option value="rgb(187, 187, 187)" label="rgb(187, 187, 187)"></option>
<option value="rgb(240, 102, 102)" label="rgb(240, 102, 102)"></option>
<option value="rgb(255, 194, 102)" label="rgb(255, 194, 102)"></option>
<option value="rgb(255, 255, 102)" label="rgb(255, 255, 102)"></option>
<option value="rgb(102, 185, 102)" label="rgb(102, 185, 102)"></option>
<option value="rgb(102, 163, 224)" label="rgb(102, 163, 224)"></option>
<option value="rgb(194, 133, 255)" label="rgb(194, 133, 255)"></option>
<option value="rgb(136, 136, 136)" label="rgb(136, 136, 136)"></option>
<option value="rgb(161, 0, 0)" label="rgb(161, 0, 0)"></option>
<option value="rgb(178, 107, 0)" label="rgb(178, 107, 0)"></option>
<option value="rgb(178, 178, 0)" label="rgb(178, 178, 0)"></option>
<option value="rgb(0, 97, 0)" label="rgb(0, 97, 0)"></option>
<option value="rgb(0, 71, 178)" label="rgb(0, 71, 178)"></option>
<option value="rgb(107, 36, 178)" label="rgb(107, 36, 178)"></option>
<option value="rgb(68, 68, 68)" label="rgb(68, 68, 68)"></option>
<option value="rgb(92, 0, 0)" label="rgb(92, 0, 0)"></option>
<option value="rgb(102, 61, 0)" label="rgb(102, 61, 0)"></option>
<option value="rgb(102, 102, 0)" label="rgb(102, 102, 0)"></option>
<option value="rgb(0, 55, 0)" label="rgb(0, 55, 0)"></option>
<option value="rgb(0, 41, 102)" label="rgb(0, 41, 102)"></option>
<option value="rgb(61, 20, 102)" label="rgb(61, 20, 102)"></option>
</select>
<span class="ql-format-separator"></span>
<select title="Background Color" class="ql-background">
<option value="rgb(0, 0, 0)" label="rgb(0, 0, 0)"></option>
<option value="rgb(230, 0, 0)" label="rgb(230, 0, 0)"></option>
<option value="rgb(255, 153, 0)" label="rgb(255, 153, 0)"></option>
<option value="rgb(255, 255, 0)" label="rgb(255, 255, 0)"></option>
<option value="rgb(0, 138, 0)" label="rgb(0, 138, 0)"></option>
<option value="rgb(0, 102, 204)" label="rgb(0, 102, 204)"></option>
<option value="rgb(153, 51, 255)" label="rgb(153, 51, 255)"></option>
<option value="rgb(255, 255, 255)" label="rgb(255, 255, 255)" selected=""></option>
<option value="rgb(250, 204, 204)" label="rgb(250, 204, 204)"></option>
<option value="rgb(255, 235, 204)" label="rgb(255, 235, 204)"></option>
<option value="rgb(255, 255, 204)" label="rgb(255, 255, 204)"></option>
<option value="rgb(204, 232, 204)" label="rgb(204, 232, 204)"></option>
<option value="rgb(204, 224, 245)" label="rgb(204, 224, 245)"></option>
<option value="rgb(235, 214, 255)" label="rgb(235, 214, 255)"></option>
<option value="rgb(187, 187, 187)" label="rgb(187, 187, 187)"></option>
<option value="rgb(240, 102, 102)" label="rgb(240, 102, 102)"></option>
<option value="rgb(255, 194, 102)" label="rgb(255, 194, 102)"></option>
<option value="rgb(255, 255, 102)" label="rgb(255, 255, 102)"></option>
<option value="rgb(102, 185, 102)" label="rgb(102, 185, 102)"></option>
<option value="rgb(102, 163, 224)" label="rgb(102, 163, 224)"></option>
<option value="rgb(194, 133, 255)" label="rgb(194, 133, 255)"></option>
<option value="rgb(136, 136, 136)" label="rgb(136, 136, 136)"></option>
<option value="rgb(161, 0, 0)" label="rgb(161, 0, 0)"></option>
<option value="rgb(178, 107, 0)" label="rgb(178, 107, 0)"></option>
<option value="rgb(178, 178, 0)" label="rgb(178, 178, 0)"></option>
<option value="rgb(0, 97, 0)" label="rgb(0, 97, 0)"></option>
<option value="rgb(0, 71, 178)" label="rgb(0, 71, 178)"></option>
<option value="rgb(107, 36, 178)" label="rgb(107, 36, 178)"></option>
<option value="rgb(68, 68, 68)" label="rgb(68, 68, 68)"></option>
<option value="rgb(92, 0, 0)" label="rgb(92, 0, 0)"></option>
<option value="rgb(102, 61, 0)" label="rgb(102, 61, 0)"></option>
<option value="rgb(102, 102, 0)" label="rgb(102, 102, 0)"></option>
<option value="rgb(0, 55, 0)" label="rgb(0, 55, 0)"></option>
<option value="rgb(0, 41, 102)" label="rgb(0, 41, 102)"></option>
<option value="rgb(61, 20, 102)" label="rgb(61, 20, 102)"></option>
</select>
</span>
<span class="ql-format-group">
<span title="List" class="ql-format-button ql-list"></span>
<span class="ql-format-separator"></span>
<span title="Bullet" class="ql-format-button ql-bullet"></span>
<span class="ql-format-separator"></span>
<select title="Text Alignment" class="ql-align">
<option value="left" label="Left" selected=""></option>
<option value="center" label="Center"></option>
<option value="right" label="Right"></option>
<option value="justify" label="Justify"></option>
</select>
</span>
<span class="ql-format-group">
<span title="Link" class="ql-format-button ql-link"></span>
<span class="ql-format-separator"></span>
<span title="Image" class="ql-format-button ql-image"></span>
</span>
</div>

<div class="orchestra-quill-editor">
</div>
</div>

0 comments on commit f12e156

Please sign in to comment.