if (!RedactorPlugins) var RedactorPlugins = {}; (function ($R) { $R.add('plugin', 'definedlinks', { init: function (app) { this.app = app; this.opts = app.opts; this.component = app.component; // local this.links = []; }, // messages onmodal: { link: { open: function ($modal, $form) { if (!this.opts.definedlinks) return; this.$modal = $modal; this.$form = $form; this._load(); } } }, // private _load: function () { if (typeof this.opts.definedlinks === 'object') { this._build(this.opts.definedlinks); } else { $R.ajax.get({ url: this.opts.definedlinks, success: this._build.bind(this) }); } }, _build: function (data) { var $selector = this.$modal.find('#redactor-defined-links'); if ($selector.length === 0) { var $body = this.$modal.getBody(); var $item = $R.dom('
'); var $selector = $R.dom(' \
\ ' }, init: function (app) { this.app = app; this.lang = app.lang; this.opts = app.opts; this.toolbar = app.toolbar; this.component = app.component; this.insertion = app.insertion; this.inspector = app.inspector; this.selection = app.selection; }, // messages onmodal: { video: { opened: function ($modal, $form) { $video = $form.getField('video'); $video.focus(); }, insert: function ($modal, $form) { var data = $form.getData(); this._insert(data); } } }, oncontextbar: function (e, contextbar) { var data = this.inspector.parse(e.target) if (data.isComponentType('video')) { var node = data.getComponent(); var buttons = { "remove": { title: this.lang.get('delete'), api: 'plugin.video.remove', args: node } }; contextbar.set(e, node, buttons, 'bottom'); } }, // public start: function () { var obj = { title: this.lang.get('video'), api: 'plugin.video.open' }; var $button = this.toolbar.addButtonAfter('image', 'video', obj); $button.setIcon(''); }, open: function () { var options = { title: this.lang.get('video'), width: '600px', name: 'video', handle: 'insert', commands: { insert: { title: this.lang.get('insert') }, cancel: { title: this.lang.get('cancel') } } }; this.app.api('module.modal.build', options); }, remove: function (node) { this.component.remove(node); }, // private _insert: function (data) { this.app.api('module.modal.close'); if (data.video.trim() === '') { return; } // parsing data.video = this._matchData(data.video); // inserting if (this._isVideoIframe(data.video)) { var $video = this.component.create('video', data.video); this.insertion.insertHtml($video); } }, _isVideoIframe: function (data) { return (data.match(/]*>/gi; data = data.replace(/]?)>([\w\W]*?)<\/p>/gi, ''); data = data.replace(tags, function ($0, $1) { return (allowed.indexOf($1.toLowerCase()) === -1) ? '' : $0; }); } else { if (data.match(this.opts.regex.youtube)) { var yturl = '//www.youtube.com'; if (data.search('youtube-nocookie.com') !== -1) { yturl = '//www.youtube-nocookie.com'; } data = data.replace(this.opts.regex.youtube, iframeStart + yturl + '/embed/$1' + iframeEnd); } else if (data.match(this.opts.regex.vimeo)) { data = data.replace(this.opts.regex.vimeo, iframeStart + '//player.vimeo.com/video/$2' + iframeEnd); } } return data; } }); $R.add('class', 'video.component', { mixins: ['dom', 'component'], init: function (app, el) { this.app = app; // init return (el && el.cmnt !== undefined) ? el : this._init(el); }, // private _init: function (el) { if (typeof el !== 'undefined') { var $node = $R.dom(el); var $wrapper = $node.closest('figure'); if ($wrapper.length !== 0) { this.parse($wrapper); } else { this.parse('
'); this.append(el); } } else { this.parse('
'); } this._initWrapper(); }, _initWrapper: function () { this.addClass('redactor-component'); this.attr({ 'data-redactor-type': 'video', 'tabindex': '-1', 'contenteditable': false }); } }); $R.add('plugin', 'textdirection', { translations: { en: { "change-text-direction": "RTL-LTR", "left-to-right": "Left to Right", "right-to-left": "Right to Left" } }, init: function(app) { this.app = app; this.lang = app.lang; this.block = app.block; this.editor = app.editor; this.toolbar = app.toolbar; this.selection = app.selection; }, // public start: function() { var dropdown = {}; dropdown.ltr = { title: this.lang.get('left-to-right'), api: 'plugin.textdirection.set', args: 'ltr' }; dropdown.rtl = { title: this.lang.get('right-to-left'), api: 'plugin.textdirection.set', args: 'rtl' }; var $button = this.toolbar.addButton('textdirection', { title: this.lang.get('change-text-direction') }); $button.setIcon(''); $button.setDropdown(dropdown); }, set: function(type) { var block = this.selection.getBlock(); if (block && block.tagName === 'LI') { var list = $R.dom(block).parents('ul, ol', this.editor.getElement()).last(); this.block.add({ attr: { dir: type }}, false, list); } else { this.block.add({ attr: { dir: type }}); } } }); // Monkey patch context bar to have ability to add a button var contextbar = $R[$R.env['module']]['contextbar']; $R.add('module', 'contextbar', $R.extend(contextbar.prototype, { append: function(e, button) { var $btn = $R.create('contextbar.button', this.app, button); if ($btn.html() !== '') { this.$contextbar.append($btn); } var pos = this._buildPosition(e, this.$el); this.$contextbar.css(pos); } })); $R.add('plugin', 'imageannotate', { init: function(app) { this.app = app; this.lang = app.lang; this.colors = [ '#ffffff', '#888888', '#000000', 'fuchsia', 'blue', 'red', 'lime', 'blueviolet', 'cyan', '#f4a63b', 'yellow'] .concat(app.instances.fontcolor.colors.slice(11)); this.loadedFabric = false; }, modals: { 'annotate': '
' +'
', }, onmodal: { annotate: { open: function($modal, $form) { this._build($modal); }, commit: function ($modal, $form) { this.commit(); } }, }, oncontextbar: function(e, contextbar) { var current = this.app.selection.getCurrent(); var data = this.app.inspector.parse(current); if (!data.isFigcaption() && data.isComponentType('image')) { contextbar.append(e, { title: __('Annotate'), api: 'plugin.imageannotate.startAnnotate', args: [data.getComponent()] }); } }, _build: function($modal) { var $body = $modal.getBody(); this._buildToolbar($body.find('.toolbar')); this.$image = $R.dom(this.image).find('img'); canvas = this.initCanvas(this.$image, $body); }, startAnnotate: function(img) { this.image = img; var that=this; if (!this.loadedFabric) { getConfig().then(function(c) { $.getScript(c.path + 'js/fabric.min.js', function() { that.loadedFabric = true; }); }); } var options = { title: __('Annotate Image'), width: '850px', name: 'annotate', handle: 'commit', commands: { commit: { title: __('Commit') }, cancel: { title: this.lang.get('cancel') } } }; // Await loading of Fabric framework var that = this, I = setInterval(function() { if (that.loadedFabric) { clearInterval(I); that.app.api('module.modal.build', options); } }, 25); }, teardownAnnotate: function() { var state = this.canvas.toObject(), places = 2; // Capture current annotations delete state.backgroundImage; this.$image.attr('data-annotations', btoa(JSON.stringify(state, function(key, value) { // limit precision of floats if (typeof value === 'number') { return parseFloat(value.toFixed(places)); } return value; })) ); this.app.api('module.modal.close'); }, _buildToolbar: function($body) { var T = this.toolbar = $R.create('toolbar', this.app); T.getWrapper().addClass('redactor-toolbar-wrapper'); T.getElement().addClass('redactor-toolbar'); $body.append(T.getWrapper()); var $button = T.addButton('drawshape', { title: __('Add Shape'), dropdown: { arrow: { title: ' {}' .replace('{}', __('Add Arrow')), api: 'plugin.imageannotate.drawArrow' }, box: { title: ' {}' .replace('{}', __('Add Box')), api: 'plugin.imageannotate.drawBox' }, ellipse: { title: ' {}' .replace('{}', __('Add Ellipse')), api: 'plugin.imageannotate.drawEllipse' }, text: { title: ' {}' .replace('{}', __('Add Text')), api: 'plugin.imageannotate.drawText' }, scribble: { title: ' {}' .replace('{}', __('Add Scribble')), api: 'plugin.imageannotate.drawFree' }, }, icon: '', }, 'first'); T.addButton('sel_color', { title: __('Shape Color'), icon: '', dropdown: this._buildDropdown(), }); var dropdown = {}, sizes = [15,20,25,30,40,50]; sizes.forEach(function(i) { var text = '' + i + 'px'; dropdown['size' + i] = { title: $R.dom('').css('font-size', '' + i + 'px').text(text).get().outerHTML, api: 'plugin.imageannotate.setFontSize', args: i, }; }); var $button = T.addButton('sel_textsize', { title: __('Font Size'), icon: '', dropdown: dropdown, }); var dropdown = {}; var fonts = ['Arial', 'Times New Roman', 'Monospace', 'Fantasy', 'Cursive']; fonts.forEach(function(i) { dropdown[i] = { title: $R.dom('').css('font-family', i).text(i).get().outerHTML, api: 'plugin.imageannotate.setFontFamily', args: i, }; }); $button = T.addButton('sel_fontfamily', { title: __('Font Family'), icon: '', dropdown: dropdown, }); var $button = T.addButton('sel_strokewidth', { title: __('Outline'), icon: '', }); var dropdown = {}, sizes = [0,2.5,5,7.5,10,15]; sizes.forEach(function(i) { var text = '' + i + 'px'; dropdown['size' + i] = { title: $R.dom('').css('border-left', '' + i + 'px solid black') .append($R.dom('').text(text)) .get().outerHTML, api: 'plugin.imageannotate.setStrokeWidth', args: i, }; }); $button.setDropdown(dropdown); T.addButton('sel_layerup', { title: __('Bring Forward'), icon: '', api: 'plugin.imageannotate.bringForward', }); T.addButton('sel_opacity', { title: __('Toggle Opacity'), icon: '', api: 'plugin.imageannotate.setOpacity', }); T.addButton('sel_trash', { title: __('Delete Shape'), icon: '', api: 'plugin.imageannotate.discard', }); this.disableContextualButtons(); }, updateSelection: function() { if (this.canvas.getActiveObjects().length > 0) this.enableContextualButtons(); else this.disableContextualButtons(); }, enableContextualButtons: function() { this.toolbar.getButtons().forEach(function(b) { if (b.name.indexOf('sel_') == 0) b.enable(); }); }, disableContextualButtons: function() { this.toolbar.getButtons().forEach(function(b) { if (b.name.indexOf('sel_') == 0) b.disable(); }); }, _setColor: function(attr, color) { $.each(this.canvas.getActiveObjects(), function() { this.set(attr, color); }); this.canvas.renderAll(); }, _removeColor: function(attr) { $.each(this.canvas.getActiveObjects(), function() { this.set(attr, 'rgba(0, 0, 0, 0)'); }); this.canvas.renderAll(); }, // Color picker -- copied from `fontcolor` plugin _buildDropdown: function () { var $dropdown = $R.dom('
'); this.$selector = this._buildSelector(); this.$selectorFill = this._buildSelectorItem('fill', __('Fill')); this.$selectorFill.addClass('active'); this.$selectorStroke = this._buildSelectorItem('stroke', __('Outline')); this.$selector.append(this.$selectorFill); this.$selector.append(this.$selectorStroke); this.$pickerFill = this._buildPicker('fill'); this.$pickerStroke = this._buildPicker('stroke'); $dropdown.append(this.$selector); $dropdown.append(this.$pickerFill); $dropdown.append(this.$pickerStroke); this._buildSelectorEvents(); $dropdown.width(242); return $dropdown; }, _buildSelector: function () { var $selector = $R.dom('
'); $selector.addClass('redactor-dropdown-selector'); return $selector; }, _buildSelectorItem: function (name, title) { var $item = $R.dom(''); $item.attr('rel', name) .html(title); $item.addClass('redactor-dropdown-not-close'); return $item; }, _buildSelectorEvents: function () { this.$selectorFill.on('mousedown', function (e) { e.preventDefault(); this.$selector.find('span') .removeClass('active'); this.$pickerStroke.hide(); this.$pickerFill.show(); this.$selectorFill.addClass('active'); }.bind(this)); this.$selectorStroke.on('mousedown', function (e) { e.preventDefault(); this.$selector.find('span') .removeClass('active'); this.$pickerFill.hide(); this.$pickerStroke.show(); this.$selectorStroke.addClass('active'); }.bind(this)); }, _buildPicker: function (name) { var $box = $R.dom('
'); var len = this.colors.length; var self = this; var func = function (e) { e.preventDefault(); var $el = $R.dom(e.target); self._setColor($el.data('rule'), $el.attr('rel')); }; for (var z = 0; z < len; z++) { var color = this.colors[z]; var $swatch = $R.dom(''); $swatch.attr({ 'rel': color, 'data-rule': name }); $swatch.css({ 'background-color': color, 'font-size': 0, 'border': '2px solid #fff', 'width': '22px', 'height': '22px' }); $swatch.on('mousedown', func); $box.append($swatch); } var $el = $R.dom(''); $el.attr({ 'href': '#' }); $el.css({ 'display': 'block', 'clear': 'both', 'padding': '8px 5px', 'font-size': '12px', 'line-height': 1 }); $el.html(this.lang.get('none')); $el.on('click', function (e) { e.preventDefault(); self._removeColor(name); }); $box.append($el); if (name == 'stroke') $box.hide(); return $box; }, // Shapes drawShape: function(ondown, onmove, onup, cursor) { // @see http://jsfiddle.net/URWru/ var fcanvas = this.canvas, scale = this.scale, isDown, shape, that = this, mousedown = function(o) { isDown = true; that.app.api('module.buffer.trigger'); var pointer = fcanvas.getPointer(o.e); shape = ondown(pointer, o.e); if (shape) fcanvas.add(shape); }, mousemove = function(o) { if (!isDown) return; onmove(shape, fcanvas.getPointer(o.e), o.e); fcanvas.requestRenderAll(); }, mouseup = function(o) { isDown = false; if (onup) { if (shape2 = onup(shape, fcanvas.getPointer(o.e))) { shape.remove(); fcanvas.add(shape2); shape = shape2; } } if (shape) shape.setCoords() fcanvas.calcOffset() .off('mouse:down', mousedown) .off('mouse:up', mouseup) .off('mouse:move', mousemove) .discardActiveObject() .renderAll(); if (shape) fcanvas.setActiveObject(shape); fcanvas.selection = true; fcanvas.defaultCursor = 'default'; }; fcanvas.selection = false; fcanvas.defaultCursor = cursor || 'crosshair'; // Ensure double presses of same button are squelched fcanvas.off('mouse:down'); fcanvas.off('mouse:up'); fcanvas.off('mouse:move'); fcanvas.on('mouse:down', mousedown); fcanvas.on('mouse:up', mouseup); fcanvas.on('mouse:move', mousemove); return false; }, drawFree: function() { var scale = this.scale, fcanvas = this.canvas; fcanvas.isDrawingMode = true; fcanvas.freeDrawingBrush = new fabric.PencilBrush(fcanvas) fcanvas.freeDrawingBrush.width = 5 * scale; fcanvas.freeDrawingBrush.color = 'red'; return this.drawShape( function() {}, function() {}, function(shape, pointer, event) { fcanvas.isDrawingMode = false; } ); }, drawArrow: function() { var top, left, scale = this.scale return this.drawShape( function(pointer) { top = pointer.y; left = pointer.x; return new fabric.Group([ new fabric.Line([0, 5 * scale, 0, 5 * scale], { strokeWidth: 5 * scale, fill: 'red', stroke: 'red', originX: 'center', originY: 'center', selectable: false, hasBorders: false }), new fabric.Polygon([ {x: 20 * scale, y: 0}, {x: 0, y: -5 * scale}, {x: 0, y: 5 * scale} ], { strokeWidth: 0, fill: 'red', originX: 'center', originY: 'center', selectable: false, hasBorders: false }) ], { left: pointer.x, top: pointer.y, originX: 'center', originY: 'center' }); }, function(group, pointer) { var dx = pointer.x - left, dy = pointer.y - top, angle = Math.atan(dy / dx), d = Math.sqrt(dx * dx + dy * dy) - 10, sign = dx < 0 ? -1 : 1, dy2 = Math.sin(angle) * d * sign; dx2 = Math.cos(angle) * d * sign, group.item(0) .set({ x2: dx2, y2: dy2 }); group.item(1) .set({ angle: angle * 180 / Math.PI, flipX: dx < 0, flipY: dy < 0 }) .setPositionByOrigin(new fabric.Point(dx, dy), 'center', 'center'); }, function(shape, pointer) { var dx = pointer.x - left, dy = pointer.y - top, angle = Math.atan(dy / dx), d = Math.sqrt(dx * dx + dy * dy); // Mess with the next two lines and you *will* be sorry! shape.forEachObject(function(e) { shape.removeWithUpdate(e); }); return new fabric.Path( 'M '+left+' '+top+' l '+(d-15*scale)+' 0 0 -a b a -b a 0 -a z' .replace(/a/g, 3 * scale).replace(/b/g, 15 * scale), { angle: angle * 180 / Math.PI + (dx < 0 ? 180 : 0), strokeWidth: 5 * scale, fill: 'red', stroke: 'red' }); } ); }, drawEllipse: function() { var scale = this.scale return this.drawShape( function(pointer) { return new fabric.Ellipse({ top: pointer.y, left: pointer.x, strokeWidth: 5 * scale, fill: 'transparent', stroke: 'red', originX: 'left', originY: 'top' }); }, function(circle, pointer, event) { var x = circle.get('left'), y = circle.get('top'), dx = pointer.x - x, dy = pointer.y - y, sw = circle.strokeWidth / 2; // Use SHIFT to draw circles if (event.shiftKey) { dy = dx = Math.max(dx, dy); } circle.set({ rx: Math.max(0, Math.abs(dx/2) - sw), ry: Math.max(0, Math.abs(dy/2) - sw), originX: dx < 0 ? 'right' : 'left', originY: dy < 0 ? 'bottom' : 'top'}); } ); }, drawBox: function() { var scale = this.scale return this.drawShape( function(pointer) { return new fabric.Rect({ top: pointer.y, left: pointer.x, strokeWidth: 5 * scale, fill: 'transparent', stroke: 'red', originX: 'left', originY: 'top' }); }, function(rect, pointer, event) { var x = rect.get('left'), y = rect.get('top'), dx = pointer.x - x, dy = pointer.y - y; // Use SHIFT to draw squares if (event.shiftKey) { dy = dx = Math.max(dx, dy); } rect.set({ width: Math.abs(dx), height: Math.abs(dy), originX: dx < 0 ? 'right' : 'left', originY: dy < 0 ? 'bottom' : 'top'}); } ); }, drawText: function() { var fcanvas = this.canvas, scale = this.scale; return this.drawShape( function(pointer) { return new fabric.IText(__('Text'), { top: pointer.y, left: pointer.x, fill: 'red', originX: 'left', originY: 'top', fontFamily: 'sans-serif', fontSize: 30 * scale }); }, function(rect, pointer, event) { rect.set({width: 75 * scale, height: 30 * scale, originX: 'left', originY: 'top'}); }, function(shape) { shape.on('editing:exited', function() { if (!shape.get('text')) fcanvas.remove(shape); }); }, 'text' ); }, // Action buttons _setFont: function(attr, value) { var obj = {}; obj[attr] = value; $.each(this.canvas.getActiveObjects(), function(element) { if (this instanceof fabric.IText) { if (this.getSelectedText()) { this.setSelectionStyles(obj); } else { this.set(attr, value); } } }); this.canvas.renderAll(); }, setFontSize: function(pixels) { var scale = this.scale; this._setFont('fontSize', pixels * scale); }, setFontFamily: function(name) { this._setFont('fontFamily', name); }, setStrokeWidth: function(pixels) { var scale = this.scale; $.each(this.canvas.getActiveObjects(), function() { this.set('strokeWidth', pixels * scale); }); this.canvas.renderAll(); }, setOpacity: function() { $.each(this.canvas.getActiveObjects(), function() { if (this.get('opacity') != 1) this.set('opacity', 1); else this.set('opacity', 0.6); }); this.canvas.renderAll(); }, bringForward: function(e) { $.each(this.canvas.getActiveObjects(), function() { this.bringForward(); }); }, keydown: function(e) { // Check if [delete] was pressed with selected objects if (e.keyCode == 8 || e.keyCode == 46) return this.discard(e); // Revert to previous state for CTRL+Z or CMD+Z else if (e.keyCode == 90 && (e.metaKey || e.ctrlKey)) { this.canvas.loadFromJSON(atob(this.$image.attr('data-annotations'))); return false; } }, discard: function(e) { this.setBuffer(); var that = this; $.each(this.canvas.getActiveObjects(), function() { that.canvas.remove(this); } ); this.canvas.renderAll(); }, commit: function() { this.canvas.discardActiveObject(); // Upload the annotated image to server this.app.api('module.buffer.trigger'); this.setBuffer(); var annotated = this.canvas.toDataURL({ format: 'jpg', quality: 4, multiplier: 1 / this.canvas.getZoom() }), file = new Blob([annotated], {type: 'image/jpeg'}); // Fallback to the data URL — show while the image is being uploaded this.origSrc = this.$image.attr('src'); this.$image.attr('src', annotated); this.teardownAnnotate(); this.app.api('module.upload.send', { url: this.app.opts.imageUpload, data: this.app.opts.imageData, paramName: this.app.opts.imageUploadParam, name: 'imageannotate', files: [file], }); this.toolbar.destroy(); return false; }, // Fired from module.upload.send() after completion onupload: { imageannotate: { complete: function(response) { response.imageannotate = true; this.app.api('module.image.insert', response); }, }, }, onimage: { uploaded: function(image, response) { // This is called for all uploads, but we only care about the // ones initiated from this plugin if (!this.$image || !response.imageannotate) return; // After successful upload, replace the old image with the new one. // Transfer the annotation state to the new image for replay. var $image = $R.dom(image).find('img'); // Transfer the annotation JSON data and drop the original image. $image.attr('data-annotations', this.$image.attr('data-annotations')); // Record the image that was originally annotated. If the committed // image is annotated again, it should be the original image with // the annotations placed live on the original image. The image // being committed here will be discarded. $image.attr('data-orig-annotated-image-src', this.$image.attr('data-orig-annotated-image-src') || this.origSrc ); // Out with the old this.$image.remove(); }, }, // Keep the scale at 1.0 so that the stroke size is not stretched when // the size and shape of the object is stretched. resizeShape: function(o) { var shape = o.target; if (shape instanceof fabric.Ellipse) { shape.set({ rx: shape.get('rx') * shape.get('scaleX'), ry: shape.get('ry') * shape.get('scaleY'), scaleX: 1, scaleY: 1 }); } else if (shape instanceof fabric.Rect) { shape.set({ width: shape.get('width') * shape.get('scaleX'), height: shape.get('height') * shape.get('scaleY'), scaleX: 1, scaleY: 1 }); } }, setBuffer: function() { var state = this.canvas.toObject(), places = 2; // Capture current annotations delete state.backgroundImage; this.$image.attr('data-annotations', btoa(JSON.stringify(state, function(key, value) { // limit precision of floats if (typeof value === 'number') { return parseFloat(value.toFixed(places)); } return value; })) ); }, // Startup initCanvas: function($img, $body) { var canvas = $R.dom('').css({ width: '100%', height: '100%' }); $body.find('.canvas').append(canvas); var fcanvas = new fabric.Canvas(canvas.get(), { backgroundColor: 'rgba(0,0,0,0)', includeDefaultValues: false, width: canvas.width(), height: canvas.height(), }), previous = $img.attr('data-annotations'); // Catch [delete] key and map to delete object //self.opts.keydownCallback = this.keydown.bind(self); //self.opts.keyupCallback = this.keydown.bind(self); var I = new Image(); I.src = $img.attr('src'); // Determine the scaling adjustment to fit the image in the modal // dialog. Also note, that both the width and height should be // considered to ensure very tall images do not float off the // screen. var width = Math.min(canvas.width(), $img.width()), viewscale = width / $img.width(), height = $img.height() * viewscale, maxheight = $(window).height() - 300; if (height > maxheight) { viewscale *= maxheight / height; height = maxheight; width = $img.width() * viewscale; } var drawscale = width / I.width, scaleWidth = width / drawscale, scaleHeight = height / drawscale; this.scale = 1 / drawscale; // Set default control appearance for all objects fabric.Object.prototype.set({ transparentCorners: false, borderColor: 'rgba(102,153,255,0.9)', cornerColor: 'rgba(102,153,255,0.5)', cornerSize: 8, }); fcanvas .setDimensions({width: width, height: height}) .setZoom(drawscale) .setBackgroundImage( $img.attr('data-orig-annotated-image-src') || $img.attr('src'), fcanvas.renderAll.bind(fcanvas), { width: scaleWidth, height: scaleHeight, // Needed to position overlayImage at 0/0 originX: 'left', originY: 'top' }) .on('object:scaling', this.resizeShape.bind(this)) .on('selection:updated', this.updateSelection.bind(this)) .on('selection:created', this.updateSelection.bind(this)) .on('selection:cleared', this.updateSelection.bind(this)); if (previous) { fcanvas.loadFromJSON(atob(previous)); } this.canvas = fcanvas; return fcanvas; } }); $R.add('plugin', 'contexttypeahead', { typeahead: false, context: false, variables: false, init: function(app) { this.app = app; }, start: function() { this.$editor = this.app.editor.getElement(); this.$element = $(this.app.rootElement); if (!this.$element.data('rootContext')) return; this.$editor.on('keyup', this.watch.bind(this)); this.$editor.on('keydown', this.watch.bind(this)); this.$editor.on('click', this.watch.bind(this)); }, watch: function(e) { var current = this.app.api('selection.getCurrent'), allText = this.$editor.text(), offset = this.app.api('offset.get', this.app.editor.$editor), lhs = allText.substring(0, offset.start), search = new RegExp(/%\{([^}]*)$/), match, e = $.Event(e); if (!lhs) { return !e.isDefaultPrevented(); } if (e.which == 27 || !(match = search.exec(lhs))) // No longer in a element — close typeahead return this.destroy(); if (e.type == 'click') return; // Locate the position of the cursor and the number of characters back // to the `%{` symbols var sel = this.app.api('selection.get'), range = sel.getRangeAt(0), content = current.textContent, clientRects = range.getClientRects(), position = clientRects[0], backText = match[1], parent = this.app.api('selection.getParent') || this.$element, that = this; // Insert a hidden text input to receive the typed text and add a // typeahead widget if (!this.typeahead) { this.typeahead = $('') .css({position: 'absolute', visibility: 'hidden'}) .width(0).height(position.height - 4) .appendTo(document.body) .typeahead({ property: 'variable', minLength: 0, arrow: $('') .css('padding', '0 0 0 6px'), highlighter: function(variable, item) { var base = $.fn.typeahead.Constructor.prototype.highlighter .call(this, variable), further = new RegExp(variable + '\\.'), extendable = Object.keys(that.variables).some(function(v) { return v.match(further); }), arrow = extendable ? this.options.arrow.clone() : ''; return $('
').html(base).prepend(arrow).html() + $('') .text(' — ' + item.desc) .wrap('
').parent().html(); }, item: '
  • ', source: this.getContext.bind(this), sorter: function(items) { items.sort( function(a,b) {return a.variable > b.variable ? 1 : -1;} ); return items; }, matcher: function(item) { if (item.toLowerCase().indexOf(this.query.toLowerCase()) !== 0) return false; return (this.query.match(/\./g) || []).length == (item.match(/\./g) || []).length; }, onselect: this.select.bind(this), scroll: true, items: 100 }); } if (position) { var width = this.textWidth( backText, this.app.api('selection.getParent') || $('
    ') ), pleft = $(parent).offset().left, left = position.left - width; if (left < pleft) // This is a bug in chrome, but I'm not sure how to adjust it left += pleft; this.typeahead .css({top: position.top + this.app.$win.scrollTop(), left: left}); } this.typeahead .val(match[1]) .triggerHandler(e); return !e.isDefaultPrevented(); }, getContext: function(typeahead, query) { var dfd, that=this, root = this.$element.data('rootContext'); if (!this.context) { dfd = $.Deferred(); $.ajax('ajax.php/content/context', { data: {root: root}, success: function(json) { var items = $.map(json, function(v,k) { return {variable: k, desc: v}; }); that.variables = json; dfd.resolve(items); } }); this.context = dfd; } // Only fetch the context once for this redactor box this.context.then(function(items) { typeahead.process(items); }); }, textWidth: function(text, clone) { var c = $(clone), o = c.clone().text(text) .css({'position': 'absolute', 'float': 'left', 'white-space': 'nowrap', 'visibility': 'hidden'}) .css({'font-family': c.css('font-family'), 'font-weight': c.css('font-weight'), 'font-size': c.css('font-size')}) .appendTo($('body')), w = o.width(); o.remove(); return w; }, destroy: function() { if (this.typeahead) { this.typeahead.typeahead('hide'); this.typeahead.remove(); this.typeahead = false; } }, select: function(item, event) { // Collapse multiple textNodes together (this.app.api('selection.getBlock') || this.$element.get(0)).normalize(); var current = this.app.api('selection.getCurrent'), sel = this.app.api('selection.get'), range = sel.getRangeAt(0), cursorAt = range.endOffset, // TODO: Consume immediately following `}` symbols search = new RegExp(/%\{([^}]*)(\}?)$/); // FIXME: ENTER will end up here, but current will be empty if (!current) return; // Set cursor at the end of the expanded text var left = current.textContent.substring(0, cursorAt), right = current.textContent.substring(cursorAt), autoExpand = event.target.nodeName == 'I', selected = item.variable + (autoExpand ? '.' : '') newLeft = left.replace(search, '%{' + selected + '}'); current.textContent = newLeft // Drop the remaining part of a variable block, if any + right.replace(/[^%}]*?[%}]/, ''); range.setStart(current, newLeft.length - 1); range.setEnd(current, newLeft.length - 1); this.app.api('selection.setRange', range); if (!autoExpand) return this.destroy(); this.typeahead.val(selected); this.typeahead.typeahead('lookup'); return false; } }); // Make the toolbar a class rather than a service, so it can be reused in // a dialog var ToolbarService = $R[$R.env['service']]['toolbar']; $R.add('class', 'toolbar', $R.extend(ToolbarService.prototype, { init: function(app) { this.app = app this._oldtoolbar = app.toolbar; this.app.toolbar = this; // Connect what's normally available in a service module this.opts = app.opts; this.detector = app.detector; this.buttons = []; this.dropdownOpened = false; this.buttonsObservers = {}; // Start immediately this.create(); this.$wrapper.append(this.$toolbar); }, is: function() { return true; }, destroy: function() { this.app.toolbar = this._oldtoolbar; } })); $R.add('plugin', 'translatable', { langs: undefined, config: undefined, current: undefined, primary: undefined, changed: {}, init: function(app) { this.app = app; this.statusbar = app.statusbar; this.$textarea = $R.dom(this.app.rootElement); this.$editor = app.editor.getElement(); }, start: function() { this.fetch('ajax.php/i18n/langs/all') .then(this.setLangs.bind(this)); getConfig().then(this.setConfig.bind(this)); $editor = this.app.editor.getElement(); this.translateTag = this.$textarea.data()['translateTag']; }, setLangs: function(langs) { if (Object.keys(langs).length < 2) return; this.langs = langs; this.buildDropdown(); }, setConfig: function(config) { this.config = config; this.buildDropdown(); }, buildDropdown: function() { if (!this.config || !this.langs) return; var primary = this.$textarea, primary_lang = this.config.primary_language.replace('-','_'), primary_info = this.langs[primary_lang], items = {}, dropdown = { primary_lang: { title: ' '+primary_info.name, api: 'plugin.translatable.switchTo', args: primary_lang, }, }, button = this.app.toolbar.addButton('flag', { title: __('Translate'), }); this.primary = this.current = primary_lang; this.button = button; $.each(this.langs, function(lang, info) { if (lang == primary_lang) return; dropdown[lang] = { title: ' '+info.name, api: 'plugin.translatable.switchTo', args: lang, }; }); // Add the button to the toolbar button.setDropdown(dropdown); // Flip back to primary language before submitting var that=this; this.app.editor.getElement().closest('form').on('submit', function() { that.switchTo(primary_lang); }); this.showStatus(this.primary); }, showStatus: function(lang) { var tstatus = $R.dom('').text('lang: ') tstatus.append($R.dom('')) tstatus.append(document.createTextNode(' ' + this.current)) this.statusbar.add('translatable', tstatus); this.button.setIcon(''); }, switchTo: function(lang) { if (lang == this.current) return; var that = this; this.fetch('ajax.php/i18n/translate/' + this.translateTag) .then(function(json) { // Preserve current text json[that.current] = that.app.source.getCode(); that.current = lang; that.app.insertion.set(json[lang] || '', false, true); that.app.api('module.source.sync'); that.app.editor.getElement() .attr({lang: lang, dir: that.langs[lang].direction}); that.showStatus(lang); that.showCommit(); }); }, onchanged: function() { this.showCommit(); this.changed[this.current] = true; }, showCommit: function() { if (this.current == this.primary) { this.statusbar.remove('translatable:commit'); return true; } if (!this.changed[this.current]) return true; var tstatus = $R.dom('') .text(__('save translation')) .on('click', this.commit.bind(this)) this.statusbar.add('translatable:commit', tstatus); }, commit: function() { if (!this.changed[this.current]) return this.app.statusbar.remove('translatable:commit'); var changes = {}, self = this; this.app.statusbar.add('translatable:commit', __('saving...')) changes[this.current] = this.app.source.getCode(); $.ajax('ajax.php/i18n/translate/' + this.translateTag, { type: 'post', data: changes, success: function() { self.changed[self.current] = false; self.app.statusbar.remove('translatable:commit'); } }); // Don't bubble the click event return false; }, urlcache: {}, fetch: function( url, data, callback ) { var urlcache = this.urlcache; if ( !urlcache[ url ] ) { urlcache[ url ] = $.Deferred(function( defer ) { $.ajax( url, { data: data, dataType: 'json' } ) .then( defer.resolve, defer.reject ); }).promise(); } return urlcache[ url ].done( callback ); }, }); })(Redactor);