diff --git a/Gemfile b/Gemfile deleted file mode 100644 index 948cb1f..0000000 --- a/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source 'https://rubygems.org' - -gem 'render_parent', '>= 0.0.4' \ No newline at end of file diff --git a/README.md b/README.md index 855659a..21f87f0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Redmine Image Clipboard Paste -Plugin for redmine which allows pasting image data from the clipboard directly into the comments input field on a new ticket or comment. The image will be given an arbitrary filename and added as an attachment and also inserted into the comment text using Redmine's markup language. +Plugin for redmine which allows pasting image data from the clipboard directly into the comments input field on a new ticket or comment. The image will be given an arbitrary filename and added as an attachment and also inserted into the comment text using Redmine's markup language (textile or markdown). ## Features @@ -11,7 +11,7 @@ Plugin for redmine which allows pasting image data from the clipboard directly i ## Getting the plugin -A copy of the plugin can be downloaded from GitHub: http://github.com/credativUK/redmine_image_clipboard_paste +A copy of the plugin can be downloaded from GitHub: https://github.com/thorin/redmine_image_clipboard_paste ## Installation @@ -19,15 +19,15 @@ To install the plugin clone the repro from github and migrate the database: ``` cd /path/to/redmine/ -git clone git://github.com/credativUK/redmine_image_clipboard_paste.git plugins/redmine_image_clipboard_paste -rake db:migrate_plugins RAILS_ENV=production +git clone git://github.com/thorin/redmine_image_clipboard_paste.git plugins/redmine_image_clipboard_paste +bundle exec rake redmine:plugins:migrate RAILS_ENV=production ``` To uninstall the plugin migrate the database back and remove the plugin: ``` cd /path/to/redmine/ -rake db:migrate:plugin NAME=redmine_image_clipboard_paste VERSION=0 RAILS_ENV=production +bundle exec rake redmine:plugins:migrate NAME=redmine_image_clipboard_paste VERSION=0 RAILS_ENV=production rm -rf plugins/redmine_image_clipboard_paste ``` @@ -35,11 +35,8 @@ Further information about plugin installation can be found at: http://www.redmin ## Compatibility -The latest version of this plugin is only tested with Redmine 2.3.x. +The latest version of this plugin is tested with Redmine 5.0.4. -Browser compatibility will be an issue since it is making use of the FileAPI which is still a working draft at time of writing and each browser has it's own implementation of this. - -Paste is only supported by WebKit based browsers. Drag and drop should be supported by all modern browsers, tested with Chrome, Firefox and IE. ## License diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb deleted file mode 100644 index 73edccb..0000000 --- a/app/views/boards/show.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%= javascript_include_tag 'image_paste.js', :plugin => 'redmine_image_clipboard_paste' %> -<%= render :parent, {:you_can_pass => :locals} %> diff --git a/app/views/issues/_imagepaste.erb b/app/views/issues/_imagepaste.erb deleted file mode 100644 index 1b8bf2f..0000000 --- a/app/views/issues/_imagepaste.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% content_for :header_tags do %> - <%= javascript_include_tag 'image_paste.js', :plugin => 'redmine_image_clipboard_paste' %> -<% end %> diff --git a/app/views/issues/update_form.js.erb b/app/views/issues/update_form.js.erb deleted file mode 100644 index 986522f..0000000 --- a/app/views/issues/update_form.js.erb +++ /dev/null @@ -1,9 +0,0 @@ -replaceIssueFormWith('<%= escape_javascript(render :partial => 'form') %>'); - -<% if User.current.allowed_to?(:log_time, @issue.project) %> - $('#log_time').show(); -<% else %> - $('#log_time').hide(); -<% end %> - -preparePasteEvents(); diff --git a/app/views/messages/show.html.erb b/app/views/messages/show.html.erb deleted file mode 100644 index 73edccb..0000000 --- a/app/views/messages/show.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%= javascript_include_tag 'image_paste.js', :plugin => 'redmine_image_clipboard_paste' %> -<%= render :parent, {:you_can_pass => :locals} %> diff --git a/app/views/wiki/edit.html.erb b/app/views/wiki/edit.html.erb deleted file mode 100644 index 22a5b1c..0000000 --- a/app/views/wiki/edit.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= javascript_include_tag 'image_paste.js', :plugin => 'redmine_image_clipboard_paste' %> -<%= render :parent, {:you_can_pass => :locals} %> - diff --git a/assets/javascripts/image_paste.js b/assets/javascripts/image_paste.js index 98aabf1..653e2ad 100644 --- a/assets/javascripts/image_paste.js +++ b/assets/javascripts/image_paste.js @@ -1,95 +1,46 @@ -jQuery.event.props.push('clipboardData'); - -function pasteImageName(e, name) { - var text = '!' + name + '! '; - var scrollPos = e.scrollTop; - var method = ((e.selectionStart || e.selectionStart == '0') ? 1 : (document.selection ? 2 : false ) ); - if (method == 2) { - e.focus(); - var range = document.selection.createRange(); - range.moveStart ('character', -e.value.length); - strPos = range.text.length; - } - else if (method == 1) strPos = e.selectionStart; - - var front = (e.value).substring(0,strPos); - var back = (e.value).substring(strPos,e.value.length); - if (front.length == 0 || front.slice(-1) == '\n') { - e.value=front+text+back; - } else { - e.value=front+' '+text+back; - } - strPos = strPos + text.length; - if (method == 2) { - e.focus(); - var range = document.selection.createRange(); - range.moveStart ('character', -e.value.length); - range.moveStart ('character', strPos); - range.moveEnd ('character', 0); - range.select(); - } - else if (method == 1) { - e.selectionStart = strPos; - e.selectionEnd = strPos; - e.focus(); - } - e.scrollTop = scrollPos; -} - -/** -* Some browser doesn't support cliboardData.items, so this function recreates a simpler version of it -* by getting the blob linked to the image. -* Currently only works when copying an image from a webpage -*/ -function getDataItems(clipboardData, editElement, event) { - clipboardData.items = []; - for(var i = 0; i < clipboardData.types.length; i++) { - console.log(clipboardData.types[i]); - var data = clipboardData.getData(clipboardData.types[i]); - if(clipboardData.types[i] == "text/html") { - var nodes = $(data); - for(var j = 0; j < nodes.length; j++) { - var item = {}; - var node = nodes[j]; - if(node.tagName == 'IMG') { - var xhr = new XMLHttpRequest(); - xhr.addEventListener('load', function(){ - if (xhr.status == 200){ - //Do something with xhr.response (not responseText), which should be a Blob - item.getAsFile = function() { - return xhr.response; - } - item.type = xhr.response.type; - clipboardData.items.push(item); - processClipboardItems(clipboardData, editElement, event); - } - }); - xhr.open('GET', node.src); - xhr.responseType = 'blob'; - xhr.send(null); - } - } - } - else if(clipboardData.types[i] == "text/plain") { - var file_regexp = /file:\/\/.*/; - var regexp = new RegExp(file_regexp); - if(data.match(regexp)) { - alert('Your browser does not support pasting images from disk. Please use the upload form.'); - } - +jQuery.event.addProp('clipboardData'); +jQuery.event.addProp('dataTransfer'); +(function ($) { + var imgpaste = {}; + + // Override attachments.js uploadBlob + window.uploadBlob = function (blob, uploadUrl, attachmentId, options) { + var actualOptions = $.extend({ + loadstartEventHandler: $.noop, + progressEventHandler: $.noop + }, options); + + uploadUrl = uploadUrl + '?attachment_id=' + attachmentId; + if (blob instanceof window.File || blob.name) { + uploadUrl += '&filename=' + encodeURIComponent(blob.name); + uploadUrl += '&content_type=' + encodeURIComponent(blob.type); } + + return $.ajax(uploadUrl, { + type: 'POST', + contentType: 'application/octet-stream', + beforeSend: function(jqXhr, settings) { + jqXhr.setRequestHeader('Accept', 'application/js'); + // attach proper File object + settings.data = blob; + }, + xhr: function() { + var xhr = $.ajaxSettings.xhr(); + xhr.upload.onloadstart = actualOptions.loadstartEventHandler; + xhr.upload.onprogress = actualOptions.progressEventHandler; + return xhr; + }, + data: blob, + cache: false, + processData: false + }); } -} -function processClipboardItems(clipboardData, editElement, event) { - for (var file = 0; file= 535); + var isCompatSafari = (M[1] === 'webkit' && typeof window.safari === "object" && browserMajor >= 600); + + if (isCompatChrome || + isCompatSafari || + (M[1] === 'firefox' && browserMajor >= 3) || + (M[1] === 'trident' && browserMajor >= 7)) + return true; } + return false; + }, + + showSupportedBrowsers: function () { + alert("Please use latest Firefox or Chrome to paste images from clipboard."); + }, + + /** + * Inits the clipboard evetns for editors on the page. + */ + initClipboardEvents: function(){ + var self = this; + + // - creates a capture to insert images from clipboards + + // fake editable content + this.createPasteCapture(); + + // focuses on the fake editable content when ctrl+v is pressed + this.preventPaste = function() { + if (!document.activeElement) return; + + // we can insert images only into wp editor + if ( !$(document.activeElement).is(".wiki-edit") ) return; + + self.saveCurrentSelection(); + $("#paster").css('top', window.pageYOffset + 20).focus(); + } + + $(document).keydown(function(e){ + var isCtrlVCombination = (e.ctrlKey || e.metaKey) && !e.altKey && e.keyCode === 86; + var isShiftInsertCombination = e.shiftKey && e.keyCode === 45; + + if (isCtrlVCombination || isShiftInsertCombination) { + if ( !self.isBrowserSupported() ) { + return; + } + self.preventPaste(); + }; + }); + + // catchs the "paste" event to upload image on a server + $(document).on('paste', '.wiki-edit, #paster', function (e) { + // we can insert images only into wp editor or editable content + if (!document.activeElement || + (!$(document.activeElement).is('.wiki-edit') && + !$(document.activeElement).attr('contenteditable'))) + { + return; + } + + if (!self.selection) { self.saveCurrentSelection(); } + if ( e.clipboardData && e.clipboardData.items) { + self.uploadFromClipboard(e); + } else if (self.isBrowserSupported()) { + self.uploadFromCapture(); + } else if (e.clipboardData) { + self.getDataItems(e.clipboardData, self.selection.editor, e) + } + }); + + $('.wiki-edit').on('drop', function(e) { + self.saveCurrentSelection(); + var files = e.dataTransfer.files; + + for (var i = 0; i < files.length; i++) { + var file = files[i]; + + if (file.type.indexOf('image/') < 0) { continue } + + var blob = file.slice(); + self.uploadImage(file.type, blob, this, file.name.replace(/[ !"#%&\'()*:<=>?\[\\\]|]/g, '_')); + + e.preventDefault(); + e.stopPropagation(); + } + }); + }, + + // -------------------------------------------------------------------------- + // Methods for uploading + // -------------------------------------------------------------------------- + + uploadImage: function(type, blob, editElement, filename) { + var ext = (typeof filename === 'undefined') ? getExtension(type) : ('_' + filename); var fileinput = $('.file_selector').get(0); var timestamp = Math.round(+new Date()/1000); var name = 'screenshot_'+addFile.nextAttachmentId+'_'+timestamp+ext; /* Upload pasted image */ - var blob = clipboardData.items[file].getAsFile(); - blob.name = name; /* Not very elegent, but we pretent the Blob is actually a File */ + if (Object.defineProperty) { + Object.defineProperty(blob, 'name', { value: name }); + } else { + blob.name = name; + } uploadAndAttachFiles([blob], fileinput); /* Inset text into input */ - pasteImageName(editElement, name); + this.pasteImageName(editElement, name); + }, - event.preventDefault(); - event.stopPropagation(); - break; - } - - } - -} -function preparePasteEvents() { - $('.wiki-edit').each(function(){ - this.addEventListener('drop', function (e) { - for (var file = 0; file?\[\\\]|]/g, '_'); - var blob = e.dataTransfer.files[file].slice(); - blob.name = name; - uploadAndAttachFiles([blob], $('input:file.file_selector')); - pasteImageName(this, name); - - e.preventDefault(); - e.stopPropagation(); - break; + this.uploadImage(file.type, file.getAsFile(), editElement); + event.preventDefault(); + event.stopPropagation(); + break; + } + + } + }, + + /** + * Some browser doesn't support cliboardData.items, so this function recreates a simpler version of it + * by getting the blob linked to the image. + * Currently only works when copying an image from a webpage + */ + getDataItems: function(clipboardData, editElement, event) { + if (!clipboardData.types) return; + var self = this; + + clipboardData.items = []; + for (var i = 0; i < clipboardData.types.length; i++) { + console.log(clipboardData.types[i]); + var data = clipboardData.getData(clipboardData.types[i]); + if (clipboardData.types[i] == "text/html") { + var nodes = $(data); + $(data).each(function(j, node) { + var item = {}; + if (node.tagName !== 'IMG') return; + + var xhr = new XMLHttpRequest(); + xhr.addEventListener('load', function(){ + if (xhr.status !== 200) return; + + //Do something with xhr.response (not responseText), which should be a Blob + item.getAsFile = function() { + return xhr.response; + } + item.type = xhr.response.type; + clipboardData.items.push(item); + self.processClipboardItems(clipboardData, editElement, event); + }); + xhr.open('GET', node.src); + xhr.responseType = 'blob'; + xhr.send(null); + }); + } + else if (clipboardData.types[i] == "text/plain") { + var file_regexp = /file:\/\/.*/; + var regexp = new RegExp(file_regexp); + if (data.match(regexp)) { + alert('Your browser does not support pasting images from disk. Please use the upload form.'); } } - }); - }); - $('.wiki-edit').bind('paste', function (e) { - var clipboardData; - if (document.attachEvent) clipboardData = window.clipboardData; - else clipboardData = e.clipboardData; - if(!clipboardData.items) { - getDataItems(clipboardData, this); - } - else { - processClipboardItems(clipboardData, this,e); - } + } + }, - }); + /** + * Uploads image by using Clipboard API. (firefox | opera | chrome) + */ + uploadFromClipboard: function(e, options) { + var self = this; - uploadBlob = function (blob, uploadUrl, attachmentId, options) { - var actualOptions = $.extend({ - loadstartEventHandler: $.noop, - progressEventHandler: $.noop - }, options); + // if the image is inserted into the textarea, then return focus + self.returnFocusForTextArea(); + var items = $.makeArray(e.clipboardData.items).concat($.makeArray(e.clipboardData.files)); - uploadUrl = uploadUrl + '?attachment_id=' + attachmentId; - if (blob instanceof window.File || blob.name) { - uploadUrl += '&filename=' + encodeURIComponent(blob.name); - } + // read data from the clipboard and upload the first file + for (var i = 0; i < items.length; ++i) { + if ((!items[i].kind || items[i].kind === 'file') && items[i].type.indexOf('image/') !== -1) { - return $.ajax(uploadUrl, { - type: 'POST', - contentType: 'application/octet-stream', - beforeSend: function(jqXhr) { - jqXhr.setRequestHeader('Accept', 'application/js'); - }, - xhr: function() { - var xhr = $.ajaxSettings.xhr(); - xhr.upload.onloadstart = actualOptions.loadstartEventHandler; - xhr.upload.onprogress = actualOptions.progressEventHandler; - return xhr; - }, - data: blob, - cache: false, - processData: false - }); - } -} -$( document ).ready(function() { - preparePasteEvents() -}); + // only paste 1 image at a time + e.preventDefault(); + + // uploads image on a server + var image = items[i].getAsFile ? items[i].getAsFile() : items[i]; + this.uploadImage(items[i].type, image, this.selection.editor); + return; + } + } + var items = e.clipboardData.items; + for (var i = 0; i < items.length; i++) { + if (items[i].kind === 'string' && items[i].type === 'text/plain') { + items[i].getAsString($.proxy(this.insertHtmlForTextarea, this)); + + e.preventDefault(); + + return; + } + } + }, + + b64toBlob: function(b64Data, contentType, sliceSize) { + contentType = contentType || ''; + sliceSize = sliceSize || 512; + + var byteCharacters = atob(b64Data); + var byteArrays = []; + + for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) { + var slice = byteCharacters.slice(offset, offset + sliceSize); + + var byteNumbers = new Array(slice.length); + for (var i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + var byteArray = new Uint8Array(byteNumbers); + + byteArrays.push(byteArray); + } + + var blob = new Blob(byteArrays, {type: contentType}); + return blob; + }, + + + /** + * Uploads image by using the capture. (IE) + */ + uploadFromCapture: function(options) { + var self = this; + + var timeout = 5000, step = 100; + $("#paster").html(""); + + var timer = setInterval(function(){ + + var html = $("#paster").html(); + + // in nothing found, carry on to wait + if ( html.length > 0 ) { + clearInterval(timer); + + if ( html.indexOf("') + .attr({ + 'contenteditable': 'true', + '_moz_resizing': 'false' + }) + .css({ + 'position': 'absolute', + 'height': '1', + 'width': '1', + 'opacity': '0', + 'outline': '0', + 'overflow': 'auto', + 'z-index': '-9999' + }) + .prependTo('body'); + }, + + // -------------------------------------------------------------------------- + // Methods for working with textarea + // -------------------------------------------------------------------------- + + /** + * Returns a focus on the current editor textarea. + */ + returnFocusForTextArea: function() { + if ( !this.selection ) return; + + $(this.selection.editor).focus(); + this.selection.editor.selectionStart = this.selection.start; + this.selection.editor.selectionEnd = this.selection.end; + }, + + insertHtmlForTextarea: function(html) { + if ( !this.selection ) return; + + var inserted = false; + try { + inserted = document.execCommand('insertText', false, html.replace(/\r\n/g, "\n")); + } catch (e) {} + if ( inserted ) return; + + this.selection.editor.value = + this.selection.editor.value.slice(0, this.selection.start) + + html + + this.selection.editor.value.slice(this.selection.end); + + // $(this.selection.editor).focus(); + + var elem = this.selection.editor; + var caretPos = this.selection.start + html.length; + if (elem.createTextRange) { + var range = elem.createTextRange(); + range.move('character', caretPos); + range.select(); + } else if(elem.selectionStart || elem.selectionStart === 0) { + elem.focus(); + elem.setSelectionRange(caretPos, caretPos); + } else { + elem.focus(); + } + this.selection = null; + }, + + pasteImageName: function(e, name) { + var text = null; + jsToolBar.prototype.elements.img.fn.wiki.call({ + encloseSelection: function(prefix, suffix, fn) { + text = prefix + name + suffix; + } + }) + this.insertHtmlForTextarea(text); + }, + + /** + * Credits: + * http://stackoverflow.com/questions/3964710/replacing-selected-text-in-the-textarea + */ + getInputSelection: function(editor) { + var start = 0, end = 0; + + if (typeof editor.selectionStart == "number" && typeof editor.selectionEnd == "number") { + start = editor.selectionStart; + end = editor.selectionEnd; + } + + return { + start: start, + end: end, + editor: editor + }; + }, + + saveCurrentSelection: function() { + var self = this; + + if (!document.activeElement || !$(document.activeElement).is(".wiki-edit")) { + var editor = self.contentWrap.find(".wiki-edit"); + this.selection = { + start: editor.val().length, + end: editor.val().length, + editor: editor[0] + }; + } else { + this.selection = this.getInputSelection(document.activeElement); + } + } + }; + + $(function() { + imgpaste.context.init(); + }); +})(jQuery); diff --git a/init.rb b/init.rb index b44fedf..e5b6513 100644 --- a/init.rb +++ b/init.rb @@ -1,10 +1,10 @@ -require 'redmine' -require 'issue_hooks' - -Redmine::Plugin.register :redmine_image_clipboard_paste do - name 'Image Clipboard Paste' - author 'credativ Ltd' - description 'Allow pasting an image from the clipboard into the comment box on the form' - version '1.0.0' - requires_redmine :version_or_higher => '2.3.0' -end +require 'redmine' +require File.expand_path('lib/image_clipboard_paste/hooks', __dir__) + +Redmine::Plugin.register :redmine_image_clipboard_paste do + name 'Image Clipboard Paste' + author 'credativ Ltd' + description 'Allow pasting an image from the clipboard into the comment box on the form' + version '3.3.0' + requires_redmine :version_or_higher => '2.3.0' +end diff --git a/lib/image_clipboard_paste/hooks.rb b/lib/image_clipboard_paste/hooks.rb new file mode 100644 index 0000000..66aa971 --- /dev/null +++ b/lib/image_clipboard_paste/hooks.rb @@ -0,0 +1,12 @@ +module ImageClipboardPaste + class Hooks < Redmine::Hook::ViewListener + def view_layouts_base_body_bottom(context={}) + controller = context[:controller] + if controller.is_a?(IssuesController) || controller.is_a?(BoardsController) || controller.is_a?(MessagesController) || controller.is_a?(WikiController) || controller.is_a?(NewsController) + + javascript_include_tag 'image_paste.js', :plugin => 'redmine_image_clipboard_paste' + + end + end + end +end \ No newline at end of file diff --git a/lib/issue_hooks.rb b/lib/issue_hooks.rb deleted file mode 100644 index d1f2a96..0000000 --- a/lib/issue_hooks.rb +++ /dev/null @@ -1,3 +0,0 @@ -class RedmineImageClipboardPasteHook < Redmine::Hook::ViewListener - render_on :view_issues_form_details_bottom, :partial => 'imagepaste' -end