From 61946db0c7a595ed52b02cae1ffcefc31c5d4301 Mon Sep 17 00:00:00 2001 From: Josh Nielsen Date: Sun, 26 Jan 2014 19:22:17 +1100 Subject: [PATCH] moved documentation into readme file modified etch.js a bit to better be ready to incorporate etch-image when it is ready fixed weirdness with the 'clear formatting' icon that was bugging me fixed issue where mouse.editor listener was doubling up --- .gitignore | 1 + README.md | 121 +++++++++++ demo/demo.html | 64 ++++++ license.txt | 13 ++ scripts/etch.js | 367 +++++++++++++++++++++++++++++++++ styles/etch.css | 39 ++++ styles/images/editor_icons.png | Bin 0 -> 8393 bytes 7 files changed, 605 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 demo/demo.html create mode 100644 license.txt create mode 100644 scripts/etch.js create mode 100644 styles/etch.css create mode 100644 styles/images/editor_icons.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..df33375 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +### About +Etch is a content editor built on Backbone.js and is designed to be easily plugged into your Backbone app or stand alone. + +### Include Dependencies + +Etch depends on [jQuery](http://jquery.com), [Underscore](http://underscorejs.com), [Backbone](http://backbonejs.com), as well as [Rangy](http://code.google.com/p/rangy/) if you need support for legacy browsers (IE8 and prior). + +_You can exclude Rangy and shave 41k off of your footprint if you don't need legacy support._ + +Once you have the dependencies squared away simply include the etch.js script after them and before the script where you define your Models/Views. + +At this point your scripts section should look something like this: + +``` + + + + + + + + + + +``` + +_Ensure that your scripts are in the right order or everything will likely be broken._ + +Also you need to add etch.css to your stylesheets +``` + +``` + +### Building The Model + +This part is simple, we just define a model and give it a url and some defaults. Your model will probably end up being more complex but this is all it takes to get Etch working. + +``` + var article = Backbone.Model.extend({ + url: '/some/api/url/', + + defaults: { + title: 'Default Title', + body: 'Default body text' + } + }); +``` + +### Building The View + +Basically all we need to do is call `etch.editableInit` when a user clicks (mousedown) on an editable element. Because of how backbone delegates events we need to call an intermediate function, `editableClick`, which references `etch.editableInit`. + +``` + var articleView = Backbone.View.extend({ + events: { + 'mousedown .editable': 'editableClick' + }, + + editableClick: etch.editableInit + }); +``` + +etch.editableInit handles everything else for you except for saving. Etch will trigger a 'save' event on your model when the save button is clicked. All we need to do is listen for it by adding a binding to the view like so: + +``` + var articleView = Backbone.View.extend({ + initialize: function() { + _.bindAll(this, 'save'); + this.model.bind('save', this.model.save); + }, + + events: { + 'mousedown .editable': 'editableClick' + }, + + editableClick: etch.editableInit + }); +``` + +### Customizing + +You may have noticed that the demo had different buttons in the editor widget depending on if you were editing the body or the title. Etch allows you to customize which buttons to show on a given 'editable' by adding a `data-button-class` attribute to the element. + +The default classes are: + +``` + etch.config.buttonClasses = { + 'default': ['save'], + 'all': ['bold', 'italic', 'underline', 'unordered-list', 'ordered-list', 'link', 'clear-formatting', 'save'], + 'title': ['bold', 'italic', 'underline', 'save'] + }; +``` + +_The 'default' button class will be used if no button class is defined on the element._ + +Defining your own button classes can be accomplished by extending `etch.config.buttonClasses`. Here we override 'default' to add more buttons and add a 'caption' class. +``` + _.extend(etch.config.buttonClasses, { + 'default': ['bold', 'italic', 'underline', 'save'], + 'caption': ['bold', 'italic', 'underline', 'link', 'save'] + }); +``` + +_The order of buttons in the array is how they will be presented in the editor widget._ + +If the class '.editable' causes conflicts for you or you need to change it for any reason you can do so by setting `etch.config.selector`. + +``` + etch.config.selector = '.my-new-editable-class'; +``` + +All functions are public and can be overridden to customize functionality + +For instance, if you want to create a custom popup for the link url prompt: + +``` + etch.views.Editor.prototype.urlPrompt = function(callback) { + // Custom popup code to get url + callback(url) + } +``` diff --git a/demo/demo.html b/demo/demo.html new file mode 100644 index 0000000..92ff5e0 --- /dev/null +++ b/demo/demo.html @@ -0,0 +1,64 @@ + + + + Etch.js - Minimal demo + + + + +

Very very basic demo, so developers can play with it while they're developing features.

+

Just click on text below to see Etch in action.

+
+
+

Here is a title

+
+ Text with a light blue background is editable. You will find it easy to use etch to underline text as well as bold and italic. + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +
+
+
+ + + + + + + + + diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..eee6fa5 --- /dev/null +++ b/license.txt @@ -0,0 +1,13 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/scripts/etch.js b/scripts/etch.js new file mode 100644 index 0000000..f596732 --- /dev/null +++ b/scripts/etch.js @@ -0,0 +1,367 @@ +(function() { + 'use strict'; + + var models = {}, + views = {}, + collections = {}, + etch = {}; + + // versioning as per semver.org + etch.VERSION = '0.6.3'; + + etch.config = { + // selector to specify editable elements + selector: '.editable', + + // Named sets of buttons to be specified on the editable element + // in the markup as "data-button-class" + buttonClasses: { + 'default': ['save'], + 'all': ['bold', 'italic', 'underline', 'unordered-list', 'ordered-list', 'link', 'clear-formatting', 'save'], + 'title': ['bold', 'italic', 'underline', 'save'] + } + }; + + models.Editor = Backbone.Model; + + views.Editor = Backbone.View.extend({ + initialize: function() { + this.$el = $(this.el); + + // Model attribute event listeners: + _.bindAll(this, 'changeButtons', 'changePosition', 'changeEditable', 'insertImage'); + this.model.bind('change:buttons', this.changeButtons); + this.model.bind('change:position', this.changePosition); + this.model.bind('change:editable', this.changeEditable); + + // Init Routines: + this.changeEditable(); + }, + + events: { + 'click .etch-bold': 'toggleBold', + 'click .etch-italic': 'toggleItalic', + 'click .etch-underline': 'toggleUnderline', + 'click .etch-heading': 'toggleHeading', + 'click .etch-unordered-list': 'toggleUnorderedList', + 'click .etch-justify-left': 'justifyLeft', + 'click .etch-justify-center': 'justifyCenter', + 'click .etch-justify-right': 'justifyRight', + 'click .etch-ordered-list': 'toggleOrderedList', + 'click .etch-link': 'toggleLink', + 'click .etch-image': 'getImage', + 'click .etch-save': 'save', + 'click .etch-clear-formatting': 'clearFormatting' + }, + + changeEditable: function() { + this.setButtonClass(); + // Im assuming that Ill add more functionality here + }, + + setButtonClass: function() { + // check the button class of the element being edited and set the associated buttons on the model + var editorModel = this.model; + var buttonClass = editorModel.get('editable').attr('data-button-class') || 'default'; + editorModel.set({ buttons: etch.config.buttonClasses[buttonClass] }); + }, + + changeButtons: function() { + // render the buttons into the editor-panel + this.$el.empty(); + var view = this; + var buttons = this.model.get('buttons'); + + // hide editor panel if there are no buttons in it and exit early + if (!buttons.length) { $(this.el).hide(); return; } + + _.each(this.model.get('buttons'), function(button){ + var $buttonEl = $(''); + view.$el.append($buttonEl); + }); + + $(this.el).show('fast'); + }, + + changePosition: function() { + // animate editor-panel to new position + var pos = this.model.get('position'); + this.$el.animate({'top': pos.y, 'left': pos.x}, { queue: false }); + }, + + wrapSelection: function(selectionOrRange, elString, cb) { + // wrap current selection with elString tag + var range = selectionOrRange === Range ? selectionOrRange : selectionOrRange.getRangeAt(0); + var el = document.createElement(elString); + range.surroundContents(el); + }, + + clearFormatting: function(e) { + e.preventDefault(); + document.execCommand('removeFormat', false, null); + }, + + toggleBold: function(e) { + e.preventDefault(); + document.execCommand('bold', false, null); + }, + + toggleItalic: function(e) { + e.preventDefault(); + document.execCommand('italic', false, null); + }, + + toggleUnderline: function(e) { + e.preventDefault(); + document.execCommand('underline', false, null); + }, + + toggleHeading: function(e) { + e.preventDefault(); + var range = window.getSelection().getRangeAt(0); + var wrapper = range.commonAncestorContainer.parentElement + if ($(wrapper).is('h3')) { + $(wrapper).replaceWith(wrapper.textContent) + return; + } + var h3 = document.createElement('h3'); + range.surroundContents(h3); + }, + + urlPrompt: function(callback) { + // This uses the default browser UI prompt to get a url. + // Override this function if you want to implement a custom UI. + + var url = prompt('Enter a url', 'http://'); + + // Ensure a new link URL starts with http:// or https:// + // before it's added to the DOM. + // + // NOTE: This implementation will disallow relative URLs from being added + // but will make it easier for users typing external URLs. + if (/^((http|https)...)/.test(url)) { + callback(url); + } else { + callback("http://" + url); + } + }, + + toggleLink: function(e) { + e.preventDefault(); + var range = window.getSelection().getRangeAt(0); + + // are we in an anchor element? + if (range.startContainer.parentNode.tagName === 'A' || range.endContainer.parentNode.tagName === 'A') { + // unlink anchor + document.execCommand('unlink', false, null); + } else { + // promt for url and create link + this.urlPrompt(function(url) { + document.execCommand('createLink', false, url); + }); + } + }, + + toggleUnorderedList: function(e) { + e.preventDefault(); + document.execCommand('insertUnorderedList', false, null); + }, + + toggleOrderedList: function(e){ + e.preventDefault(); + document.execCommand('insertOrderedList', false, null); + }, + + justifyLeft: function(e) { + e.preventDefault(); + document.execCommand('justifyLeft', false, null); + }, + + justifyCenter: function(e) { + e.preventDefault(); + document.execCommand('justifyCenter', false, null); + }, + + justifyRight: function(e) { + e.preventDefault(); + document.execCommand('justifyRight', false, null); + }, + + getImage: function(e) { + e.preventDefault(); + + // call startUploader with callback to handle inserting it once it is uploaded/cropped + this.startUploader(this.insertImage); + }, + + startUploader: function(cb) { + // initialize Image Uploader + var model = new models.ImageUploader(); + var view = new views.ImageUploader({model: model}); + + // stash a reference to the callback to be called after image is uploaded + model._imageCallback = function(image) { + view.startCropper(image, cb); + }; + + + // stash reference to saved range for inserting the image once its + this._savedRange = window.getSelection().getRangeAt(0); + + // insert uploader html into DOM + $('body').append(view.render().el); + }, + + insertImage: function(image) { + // insert image - passed as a callback to startUploader + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(this._savedRange); + + var attrs = { + 'editable': this.model.get('editable'), + 'editableModel': this.model.get('editableModel') + }; + + _.extend(attrs, image); + + var model = new models.EditableImage(attrs); + var view = new views.EditableImage({model: model}); + this._savedRange.insertNode($(view.render().el).addClass('etch-float-left')[0]); + }, + + save: function(e) { + e.preventDefault(); + var editableModel = this.model.get('editableModel'); + editableModel.trigger('save'); + } + }); + + // tack on models, views, etc... as well as init function + _.extend(etch, { + models: models, + views: views, + collections: collections, + + // This function is to be used as callback to whatever event + // you use to initialize editing + editableInit: function(e) { + e.stopPropagation(); + var target = e.target || e.srcElement; + var $editable = $(target).etchFindEditable(); + $editable.attr('contenteditable', true); + + // if the editor isn't already built, build it + var $editor = $('.etch-editor-panel'); + var editorModel = $editor.data('model'); + if (!$editor.size()) { + $editor = $('
'); + var editorAttrs = { editable: $editable, editableModel: this.model }; + document.body.appendChild($editor[0]); + $editor.etchInstantiate({classType: 'Editor', attrs: editorAttrs}); + editorModel = $editor.data('model'); + + // check if we are on a new editable + } else if ($editable[0] !== editorModel.get('editable')[0]) { + // set new editable + editorModel.set({ + editable: $editable, + editableModel: this.model + }); + } + + // Firefox seems to be only browser that defaults to `StyleWithCSS == true` + // so we turn it off here. Plus a try..catch to avoid an error being thrown in IE8. + try { + document.execCommand('StyleWithCSS', false, false); + } + catch (err) { + // expecting to just eat IE8 error, but if different error, rethrow + if (err.message !== "Invalid argument.") { + throw err; + } + } + + if (models.EditableImage) { + // instantiate any images that may be in the editable + var $imgs = $editable.find('img'); + if ($imgs.size()) { + var attrs = { editable: $editable, editableModel: this.model }; + $imgs.each(function() { + var $this = $(this); + if (!$this.data('editableImageModel')) { + var editableImageModel = new models.EditableImage(attrs); + var editableImageView = new views.EditableImage({model: editableImageModel, el: this, tagName: this.tagName}); + $this.data('editableImageModel', editableImageModel); + } + }); + } + } + + // listen for mousedowns that are not coming from the editor + // and close the editor + // unbind first to make sure we aren't doubling up on listeners + $('body').unbind('mousedown.editor').bind('mousedown.editor', function(e) { + // check to see if the click was in an etch tool + var target = e.target || e.srcElement; + if ($(target).not('.etch-editor-panel, .etch-editor-panel *, .etch-image-tools, .etch-image-tools *').size()) { + // remove editor + $editor.remove(); + + + if (models.EditableImage) { + // unblind the image-tools if the editor isn't active + $editable.find('img').unbind('mouseenter'); + + // remove any latent image tool model references + $(etch.config.selector+' img').data('editableImageModel', false) + } + + // once the editor is removed, remove the body binding for it + $(this).unbind('mousedown.editor'); + } + }); + + editorModel.set({position: {x: e.pageX - 15, y: e.pageY - 80}}); + } + }); + + // jquery helper functions + $.fn.etchInstantiate = function(options, cb) { + return this.each(function() { + var $el = $(this); + options || (options = {}); + + var settings = { + el: this, + attrs: {} + } + + _.extend(settings, options); + + var model = new models[settings.classType](settings.attrs, settings); + + // initialize a view is there is one + if (_.isFunction(views[settings.classType])) { + var view = new views[settings.classType]({model: model, el: this, tagName: this.tagName}); + } + + // stash the model and view on the elements data object + $el.data({model: model}); + $el.data({view: view}); + + if (_.isFunction(cb)) { + cb({model: model, view: view}); + } + }); + } + + $.fn.etchFindEditable = function() { + // function that looks for the editable selector on itself or its parents + // and returns that el when it is found + var $el = $(this); + return $el.is(etch.config.selector) ? $el : $el.closest(etch.config.selector); + } + + window.etch = etch; +})(); diff --git a/styles/etch.css b/styles/etch.css new file mode 100644 index 0000000..3efe7ce --- /dev/null +++ b/styles/etch.css @@ -0,0 +1,39 @@ +/* === Editor === */ +.etch-editor-panel { display: none; padding: 5px; position: absolute; z-index: 10000; + background: #dadada; border: 1px solid #bdbdbd; color: #535353; text-shadow: 0 1px 0 #fff; + background-image: -moz-linear-gradient(center bottom, #ccc 0%, #ddd 100%); + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#ddd), to(#ccc)); + -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; + -webkit-box-shadow: inset 1px 0 0 #dfdfdf, inset 0 1px 0 #dfdfdf, inset -1px 0 0 #dfdfdf, inset 0 -1px 0 #dfdfdf, 0 0 5px rgba(0,0,0,.3); + } +.etch-editor-button { display: block; float: left; margin-right: 3px; position: relative; text-decoration: none; + display: block; width: 27px; height: 27px; background: #dadada; border: 1px solid #aaa; color: #535353; text-shadow: 0 1px 0 #fff; + background: -moz-linear-gradient(center bottom, #e1e1e1 0%, #efefef 100%); + background: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#fFfFfF), to(#f1f1f1)); + -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; + } + +.etch-editor-button:last-child { margin-right: 0; } +.etch-editor-button:hover { background: #fff; border-color: #999; } +.etch-editor-button span:active { box-shadow: inset 0 0 15px rgba(0,0,0,.5); border-color: #999; } + +.etch-editor-button span, +.etch-editor-button span { background: url('images/editor_icons.png') no-repeat; + display: block; height: 100%; width: 100%; +} + +.etch-editor-panel .etch-bold span { background-position: 0 0; } +.etch-editor-panel .etch-italic span { background-position: -30px 0; } +.etch-editor-panel .etch-underline span { background-position: -60px 0; } +.etch-editor-panel .etch-unordered-list span { background-position: -90px 0; } +.etch-editor-panel .etch-ordered-list span { background-position: -120px 0; } +.etch-editor-panel .etch-image span { background-position: -150px 0; } +.etch-editor-panel .etch-link span { background-position: -180px 0; } +.etch-editor-panel .etch-save span { background-position: -210px 0; } +.etch-editor-panel .etch-justify-left span { background-position: -240px 0; } +.etch-editor-panel .etch-justify-center span { background-position: -270px 0; } +.etch-editor-panel .etch-justify-right span { background-position: -300px 0; } +.etch-editor-panel .etch-cite span { background-position: -330px 0; } +.etch-editor-panel .etch-heading span { background-position: -360px 0; } +.etch-editor-panel .etch-clear-formatting span { background-position: -390px 0; } + diff --git a/styles/images/editor_icons.png b/styles/images/editor_icons.png new file mode 100644 index 0000000000000000000000000000000000000000..1be429293200a850e3ea9c2fc0c19bfbcb4d1650 GIT binary patch literal 8393 zcmbt(WmsEV*KL3lm*8Gpiw7w#0TSF>q*!pLxDlcOR(oWVuc zGynj)q!k3BDhGj3t2#NDTiKcc05Gh~H}0Am^F(2ztpcrMc(l|ECuCk|)DyxmLZDiN zoWCk!XfUIj^dngUT$X31njHwxFj^BC0*s(Q_$=B_kZND2nzYQuQnU@)_j11D<@DQr zot62m`q_Dl?>@6l0M(m7V3&(FfEYgc7-p1Jo+pcR17BLmQ7rujvD8HqO#lQe32NqO#ppH7ll zctW{(h;q^1mC>&ERN9^uZ9ZupOif|@mP@8HLfXQc72T}5oX)V87Y)pPk;<@Yk@pJ4 ztW{Gk9k6lEJUMysRuRBOkY+f%#h7aw!6QRJdKmE&5rCT1BiQ1E9d#*anFa7DwD=YY zjF5vD;?Aa8ZEO#iUmewGT*A4ZBy}sTg?lKDp44K-auNGiI=qoVV9>EkvrITBr#Lv! znc1|wY@c6w?5lArI^cD!b;P(onk5;XrL$tYU&h%=1URGD&7iDlOV$F^+96A9td*&J zrw06d1gKqpd;B=m)*!LxD<%_0)c(G$GeWiv$CGV=>+C>>PwkL95S$-E42OJBW`$wzbr@lhA@AESp0_0<_~f zOwoh=rq}#P_?b{heKTzcEL{x&J|Tjt4RR<04x#`E9TL60xEG?&o*sxBeW0zyvOJ;E zIgZEcZtIE)<3*(XnxZ7F#^xDjoT(GTJ2uk1m0;UWM7($ummGB701hrhHX|$wf3PFc zGBqIB9|l5tgTw(tw2}aRkQAGH*n-@12yQ{vj)18EuQ8x3*hq(EJqAXf2ag{ zXr}@f!g7EWYTyqL;XBFq@kIGJX;L#Fa$QtUh@|RM0;FC@L_8=_;&P0!n1nUxeJ(^* z^n}iZtO->==qd+gjG7nu7QrVNHx+Tx7;6sj(SN>5m;x2rHMA=2LHHJhxMN{WzJW*# zyS|Hfjr<3IEyS?vHLAn_D$=;DHY)j%v{bw{JpwL`O#*E`Vu?(8Ja#^LV+<_e-T>oURjK%7qJ zAMjT+ZRma!even;sQTKO;?Z=0Hi0-aZ>Z^Q=`}%Qs9UJF!MI(#Y5~P6F0`Q_&z=`> zmg?9!$rd>ZM&;K8a-sCZ>hvsWwE>!P*0lPJqKvIHuLp%pp-v<^5(X+2^x4#g3^Dy`B!@;;o<*Wi@nl=Z>#*QE(I z$$Kf@3@S;pDeftT9NA2UG=B1Tg*Od|YMm0D8UcA#&vIqgYP&>3F@}6 z&{w`*HHMbNfW&${l@f`astJYy8ZxQL0?&ztTBTZ{FJ@o3D4_!R*ORYzdQYgfTTjr; z7|irGm^Uak$Tn&|sUvrB8C`(YNeiN1M>j{u9rJEBj;@a`@K;(H+_$hKU$WY>zDlM| zHcv)Kj(e}D(lOC(cRZ%>11tWq-0uTDZG7rr0MqQgXtUTTfF_Xmbw^qK-~@9tvUm)>5t zD8bzFii0Bdak2cRQqgjg;u>v>Jms1ZeEmcYJpsx37xfzMQYs3Z+MI@!YTt9k)UwO7 z^V^~AdVZfe#;Cs-MM-9K7UxFhwi{;|=dGF0y-z3|XU)^i6YhIz^6_Qlk=VK(V^u_^ zT%ugeppwSi)Uu{g(`fA|rp=}$BX22RbN{=lMID_o*_j-{Zjpgkd7c@UPcTHIw4*YK zmWT}54%jr<_t>a(Sacp~+tuz?U3GuyNw?8A@#zn>UN@;3DO}pDD=%SRace*D5l+{y zS|VyzZXP|uKBK*6yyiv`4f6cS>s^d0lt!YFKGDX3MR|u4idZbpE#C z`U#E#P69NlKdI>T7TG9^@->3@7CXo#Dv5m#A3wo+uYhpeK;?SZxL_Z zu9l8xe{5aNUaJ6~0?W|0(er__z-K687(8frk6eM>od})HfmECmhV(C{h!CZ2AZ{Td zXndIZgjdca9BjO0jQgGqS{}Y0?m%|9j5MUD85TD%F#wHq~Y$wuo&Qx}R7QIQjR)4Ms9oKGw%gsty?MPEPU0Ka%sv;W1ZZlh#qv?AWH zQ|Be?V{$6W70`VIepktcV#SGJqx%MM_H}dEb>ddvldYH|{R^|31Yk<=hv0GPlpg&E z6S)dGLb-KB70J3)*AIq21HP3c#V6&lfJ??pK3fIXUer!XF&IZyJ<$h?GPQDc^X73u znm$=gA8O&hlNe7>WOmjuZ%&Q9Q#^%5(`PUQ>0s1Ye^|DFnq_}d8^+x{`Ii38eu#4L zc<|bn&z#TQ)uPSqF7&38@6ZOjJY=Tg#X_U9yMAZW+G*QfoE^0tZ2rfP>`;su`HFqy z@|0SRT2-5oWzF}^KB-12wY66E5j||%URy%jgC+Bta_fZ@T7&UZ_|VtLuPO|SiF+$L zKF=?ghR=czS_{q!uH9GNP~8`ocAAd0)8IM=^J5jO`sEwLKi~bVZ+^bt_RLuCr4f!* zoM~JwPw|IV`ATc(bkT19ZU9+a`ey5{cl|-;m;Qy>_7yw7NrV`rUKFv%1QacP1LvwX z<&t*2#*1rZ-J^T&cAMHQ{0#l14`4?LqtFm&L4Q}A>$LFHtU$3q5`R}rVqf+8%YKL3 z)l-N!e(8=XIIr;n_>@NQ=xhH4)%Pq+k$KNZpZ7aVx7lYO$8;lgb7vN3B0lExTX`#b zuk83wW>St&vWsWjzdDINOG~f$WT7ycFhck?LonOj_oq}}$Wc@TahT9^7qO<=Zxfjn zRTaxa0V$ORM~zM;3$52TS}CO#6@vQ0KAAqP2cfqyGmo3I-WcxtTJ6+*w`<*Tths%& zULM=_$Y<+H|C)38tjT91_CAvwDGK@OkfY7}-e6I5$7lI=9Hj)uKz!Ki>8<6h$^QB~ zCUh0ntoP#Ox6>E1<|VBs`#G~lqc!0caiI+m&imxcN|o+Mqa~wHWNT%2q2*;lzxNzRTDcYlR;^V5UT&U|y_JoxyUM@Yx_;h7T65vJn| z0AS$%dLsbR-;w|TKxQjVZ5M4NMIjRhI}T%02e=uBhn?dC8UPUS5PBHenYkEKd)V39 zI}3S;(*8yWJ&b>uIcceXLtJb`X|T}Bzxz-WjzSw{N>@~=HTM|H=3D; z)&D~K<@sM}rY8SU<>=~U`&*N#38$H@nVp%vi}M4I`(ML7)b>Bb|0_ukW5>UO{Vw8P z+C+Y3E~IYe>|pEqs{~s1RxV=PBEJRw)BN9x{KbMe*f~0xIXgel#Q6TE{Av3qUi-gc z!~}T$Cj4pqCqc!@>OraTue|<~)1S6~;&uKlz@NrH2){}%q-y11W~(i2^-$2?DtLLg zL^%J|^G_zk!Pdb^!_nBp>{n*LDSw#$iT)i&=f81y1pW;3hvN^Tsfm!YE8N=5#O06W zdRVmIn_tSAFzJpkT-lu;Lr#zZr314MNoxdPZSZrir0m9i6XDN>%PJjRY*f3k7~7#ARUR z43KyOT^>9F@2**DJBQ`}=NQ9eF|rBu znb2=iX8AFpz)30Qx~u6``mDT#KSOEmz%5rjted9NbJP;^aa>qEfBpsr5!<%$tC_cz z*N_&sZ(<#Chs)q5iZn0YGp zv9p>RR1AxAXuIrtW?+yNd!zOd49>L9ysKP zzmcbQ61s!LyMO>no#;Ta!7b$)WeLYdC*IlF!JrOi_?#i&a1_Pa=E0p9+;)A~tWl*b zf4e75r9K$dIW$lddMTQXWAJg9le0{(OdpL);SF1au+No?kmcIy-Lrt%g5-j(Lzq(> zIe3#|rViSg_lZQK`G|3h4|xpZvQN@cm0xpMMg`$fY?ekC{im-bua?YggL*Y z;^u;9mSy8dD#Q-;+H4+?c6)zseID-Z6o(a_4{12q%FR*%v8eU&kZgg?4-$`90a?v>Zevj?UF{Uw(*Uekh%E6k|0$ab)C$objYG>Vj;JofySkQRnRMKl(_ z4>lk6xT|PByz+T!SN)y0pPQ%HX0x<-*j3x+Lp0A?$rg{sJ+B^mQgX7EVT;H3?M8}W z+vVx3p^BjAPt(W4<%m)-`_9(ZyX)HL3Y)`e_-c2sv7;l}x)gyQ1}w-8jDEgj&<3~| zMAM8;N@X9)BBzwjJ9yMfYZW+b-2f!osWjcBFB;ACnL>SFaeBr(?=8Oil&YaHigSjv0JOs6kl)c zG!2I6M%(mR1{dDV4eLctDySaJ(U*TI9_)8oO{Oq?iB50=_|u49oiF^*&dFf`6Gx1_BxLvQJA zOvL!;kRO@8FB`_Z?cW!fL}LE%ywcZUuWj1VjoZ$Q^u=w@m!V($dkQ>wWoi z1F`Htg=^M?#x%Mhp3icO8lff=V04SUfmf)Q_QCS@>U66dvm(Jg$!r(p26Yd5WT#b_ zU_C4(B`>Rrc-lO?~uLE-0Y-tsF%tvQ!>Qh_MHXv{L@fF1!bo$ z8d`jy)>rR4q`=^dta&(4Vs^oyoznFOc!3*ZBNJgcBDoPFr}7>{%^}5HQ1+3$ZK@)f zn;Qa_40BAlE)psKh&nyeR-MddSsxKsNLr`w$@X~rqTl^(+~(F+R7*=s-JG-vHkH_o z(pi*$c$*hu8-)~OK!{O($D2)zN6HlVNc{yBo}+H0I zSjw7Q1iU0vWm=jx!r)Tfmm8l5Uw#|F9j9q*z5coJA#v9uOn5Gy4G9lb!N`cMuQ2nG zf4C5}=#LI0(P}yGKtjm=% z5Y)o@D7BWEg(bnp>)SwrI`e$9`*&MZCVNZ5D1O_yA3?|2o1zn?t>8dhrz?||dFQQ> zgY1NawVWGG4ShORR@RWxlXuK)b76eh(WibbJ)5V)+l#pnxhn2-WO|S!X5xn(K1Vtgv2sk~%tr*W5^;eEP;$mriIBreo{fN1=;TZ=HFZQ)g%=RIFu%-4AV1`c`l|o(s5at0FA4``1rp`dpph0`r!d-8m6R z*s+Qv3w8(z3DLOdtRmO7NeDnk2KwN3{bo1Ce4@c~c3=&oFw>VRYsThh=k}>$Mp%u^ z5{~T72dj%}5*nZs4*f*WDZK3war0pk$%EXSoKL`DXAjO`5tywCRU#t;!?4G<;^fOO~{57!Aqan2T_jk%&(a>~j=H6BM_bMW3+ zNk|o$2gFwf=ICrM)`SC#*+AZ}=qxyJbJ6JcjTc*BCam+&{NsQpgAo3M_xK>X&jhL) zeBGrUi6JDeMG?{I>O`%>raYdCnXGy}3!wg7 z{o$?Gb5U>tf5D)#>+!<}5nzjOcmb4Z0!D>B+zVc>B)+_RTK`rdD`?_{_kvx$96L$Z zh(l+~!C3wq`=zGSGFsi#9sJd9S)6ZNr8XBreSrm5#}&GDtdGg=VP)&dIHD)2jKR`S zPo0qBE>bRp!I0Y;1}3OWKPlPa(lyRLr&LR z60WRW;%ypk>fMrZ8A%|Dsro!p-*>Tp{8;F^qxj2)6Jb{x&FRhM@m?o=7c$*hf?|aE zQ)D5BcpWvbO%E19=Q4^6#L|a-81=pFTG`&Vt^arD_|KoWk8Qs;j`R*NFy);--5!S+ ziB4lszo>WginqCl6+VnVzMi+p77Z1&j~f3RK8Cs{uA+AO=t5COlqZ=Ll#7Jaf(EW8 zeh&X`+J!cNu%_g(^mwglI}sbR;0KqYib9cDcdOO$Casztm~iuqHkG-+E%QoA}ai zS?UzQkY<$&?^W?5Jkw>*ElSDf?nRR<{pAv27N$x0o`teZsHgTFk>sPiPqZ&)KY~b~ zQR1ifOnw&dXS5;Dsl`6l(bLwx5CEuo7G7;P0*)S8(k2t+ry=C5G0DH|H*38CslG2avDa@W z;zW;W4;b~t&aqQ`-<#I-+L|{%hDOS)Nk$tq#LnUSPquql1X3x*Y|IagHUkL z0YsFKm5pPmx-W+c9nlWF4Rs24laaJ40~|_ub`y47Mj!rkT3b2{3d4RY7y3ltj#*9< zDc*9e10IrA@oax!=t=d09nOmH^??jDmOz1vHiw4S%ieTfQB?vFUo4b(7bOb$Lwt&i zY=DG0(a}8=-phJHHJrO-VtFt5>cyrXbEN^Aa6vsaz+I$IKDlZ6Gv&DmIO=0Y^E}pp zG}UJ=yb*@ZOlJ#VzGZ#XrjaZ1z}OpQWW~^77`qg=VKP*MD2>1MHHm~bn)1)SZ#Z9u zma9$rNotSDUv;?S>>;C$^PS4&kEWJ!eEYh6Ol1TyMC2lqUoAjO+(Mzix9ae9T%;hU z8&ZX+uh1KphqWYNWm?O>rfhx*sUPk3e)s~36*Gt7eos78p`X#`X9s|xS#C2`A6g)l z@sn>3O_=%Lf5-r~ZTF_5pBTD{USeYDY)*?)3Y{#@=rkX07}*mCF7aK7x#1b=%kab0rKK?|A;ko3gCi-+MLa^IVW zDq}_R!xS7t(~f-XuHF}@0&)n#_->w*ZEaM2bo`tRL>lc95Bg?=`NEK$(dc_xA6kt> zemJfsME#Mgm%ds~1B)yYat3Tu;d5AYntxA{Vp_r%{50-5;Fo0-Yzk&d1k1yBL?J&T zh^{{KjdYveVH~~U3c0mhiE%ECfi61V`47jCBSU<8+i}+uaV#wp4I2&K;dr5mqxm4eC{!n??s> z$J5UZ$B$@jY5JwyyS#c?8?>#acz^0x