From 0469c38619d37ff8d7fa50128f1418b002df5bea Mon Sep 17 00:00:00 2001 From: mdorf Date: Sun, 27 Sep 2020 07:28:07 -0700 Subject: [PATCH 1/7] implementing OntoloBridge/ontolobridge-project#76 and ncbo/bioportal-project#179 --- app/assets/javascripts/bp_ontolobridge.js | 123 ++++++++++++++++++--- app/assets/stylesheets/ontolobridge.scss | 11 ++ app/controllers/ontolobridge_controller.rb | 19 ++++ app/controllers/ontologies_controller.rb | 7 ++ app/views/concepts/_request_term.html.haml | 33 ++++++ config/locales/en.yml | 6 + config/routes.rb | 8 +- db/schema.rb | 21 +++- 8 files changed, 203 insertions(+), 25 deletions(-) diff --git a/app/assets/javascripts/bp_ontolobridge.js b/app/assets/javascripts/bp_ontolobridge.js index 02f67680b..e94d038e3 100644 --- a/app/assets/javascripts/bp_ontolobridge.js +++ b/app/assets/javascripts/bp_ontolobridge.js @@ -12,6 +12,90 @@ function bindCancelRequestTermClick() { }); } +function bindNewTermInstructionsFocus() { + jQuery("#new_term_instructions").live("focus", function() { + jQuery("#new_term_instructions_submit").show(); + jQuery("#new_term_instructions_cancel").show(); + }); +} + +function bindNewTermInstructionsSubmit() { + jQuery("#new_term_instructions_submit").live("click", function() { + saveNewTermInstructions(); + }); +} + +function bindNewTermInstructionsCancel() { + jQuery("#new_term_instructions_cancel").live("click", function() { + var oldVal = jQuery("#new_term_instructions_old").val().trim(); + var curVal = jQuery('#new_term_instructions').html().trim(); + + if (oldVal != curVal) { + if (confirm('Are you sure you want to discard your changes?')) { + jQuery('#new_term_instructions').html(oldVal); + hideButtons(); + } + } else { + hideButtons(); + } + }); +} + +function preventNewTermInstructionsFormSubmit() { + jQuery("#new_term_instructions_form").submit(function(e) { + e.preventDefault(e); + }); +} + +function clearProgressMessage() { + jQuery("#progress_message").hide(); + jQuery("#progress_message").html(""); +}; + +function showProgressMessage() { + clearProgressMessage(); + var msg = "Saving..."; + jQuery("#progress_message").text(msg).html(); + jQuery("#progress_message").show(); +} + +function saveNewTermInstructions() { + var params = jQuery('#new_term_instructions_form').serialize(); + var newInstructions = jQuery('#new_term_instructions').html().trim(); + params += '&new_term_instructions=' + newInstructions; + showProgressMessage(); + + jQuery.ajax({ + type: "POST", + url: "/ontolobridge/save_new_term_instructions", + dataType: "json", + data: params, + success: function(data) { + var status = data[1]; + + if (status && status >= 400 || data[0]['error'].length) { + showStatusMessages('new_term_instructions', '', data[0]['error']); + } else { + jQuery("#new_term_instructions_old").val(newInstructions); + showStatusMessages('new_term_instructions', data[0]["success"], ''); + setTimeout(function() { clearStatusMessages('new_term_instructions'); }, 5000); + } + }, + error: function(request, textStatus, errorThrown) { + showStatusMessages('new_term_instructions', '', errorThrown); + }, + complete: function(request, textStatus) { + clearProgressMessage(); + hideButtons(); + } + }); +} + +function hideButtons() { + jQuery("#new_term_instructions_submit").hide(); + jQuery("#new_term_instructions_cancel").hide(); +} + function bindRequestTermSaveClick() { var success = ""; var error = ""; @@ -33,7 +117,7 @@ function bindRequestTermSaveClick() { var status = data[1]; if (status && status >= 400) { - showStatusMessages(null, data[0]["error"]); + showStatusMessages('ob', '', data[0]["error"]); } else { var msg = "A new term request has been submitted successfully:

"; var button = jQuery(".request_term_form_div .save"); @@ -42,13 +126,12 @@ function bindRequestTermSaveClick() { for (var i in data[0]) { msg += i + ": " + data[0][i] + "
"; } - - showStatusMessages(msg, error); + showStatusMessages('ob', msg, error); } }, error: function(request, textStatus, errorThrown) { error = "The following error has occurred: " + errorThrown + ". Please try again."; - showStatusMessages(success, error); + showStatusMessages('ob', success, error); } }); } @@ -58,29 +141,28 @@ function removeRequestTermBox(button) { } function addRequestTermBox(id, type, button) { - clearStatusMessages(); - + clearStatusMessages('ob'); var formContainer = jQuery(button).parents(".notes_list_container").children(".request_term_form_div"); requestTermFields(id, formContainer); formContainer.show(); } -function clearStatusMessages() { - jQuery("#ob_success_message").hide(); - jQuery("#ob_error_message").hide(); - jQuery("#ob_success_message").html(""); - jQuery("#ob_error_message").html(""); +function clearStatusMessages(prefix) { + jQuery('#' + prefix + '_success_message').hide(); + jQuery('#' + prefix + '_error_message').hide(); + jQuery('#' + prefix + '_success_message').html(""); + jQuery('#' + prefix + '_error_message').html(""); } -function showStatusMessages(success, error) { +function showStatusMessages(prefix, success, error) { if (success.length > 0) { - jQuery("#ob_success_message").html(success); - jQuery("#ob_success_message").show(); + jQuery('#' + prefix + '_success_message').html(success); + jQuery('#' + prefix + '_success_message').show(); } if (error.length > 0) { - jQuery("#ob_error_message").text(error).html(); - jQuery("#ob_error_message").show(); + jQuery('#' + prefix + '_error_message').text(error).html(); + jQuery('#' + prefix + '_error_message').show(); } } @@ -128,7 +210,6 @@ function appendTextArea(id, placeholder, div, isRequired, invalidMessage) { txtArea.prop('required', true); txtArea.attr("class", "req"); } - div.append(txtArea); div.append("
"); } @@ -192,7 +273,13 @@ function requestTermFields(id, container) { } jQuery(document).ready(function() { - clearStatusMessages(); + clearStatusMessages('ob'); + clearStatusMessages('new_term_instructions'); bindAddRequestTermClick(); bindCancelRequestTermClick(); + + preventNewTermInstructionsFormSubmit(); + bindNewTermInstructionsSubmit(); + bindNewTermInstructionsCancel(); + bindNewTermInstructionsFocus(); }); diff --git a/app/assets/stylesheets/ontolobridge.scss b/app/assets/stylesheets/ontolobridge.scss index 1c1688ee2..41d1bbb99 100644 --- a/app/assets/stylesheets/ontolobridge.scss +++ b/app/assets/stylesheets/ontolobridge.scss @@ -27,3 +27,14 @@ input.req, textarea.req { border-color: darksalmon !important; } + +div.editable { + width: 300px; + height: 200px; + border: 1px solid #ccc; + padding: 5px; +} + +strong { + font-weight: bold; +} diff --git a/app/controllers/ontolobridge_controller.rb b/app/controllers/ontolobridge_controller.rb index 57cf860fb..9083c9c00 100644 --- a/app/controllers/ontolobridge_controller.rb +++ b/app/controllers/ontolobridge_controller.rb @@ -41,4 +41,23 @@ def request_term render json: [response, code], status: code end + def save_new_term_instructions + code = 200 + response = {error: '', success: ''} + response[:success] = "New term request instructions for #{params['acronym']} saved" + ont_data = Ontology.find_by(acronym: params['acronym']) + ont_data ||= Ontology.new + ont_data.acronym = params['acronym'] + ont_data.new_term_instructions = params['new_term_instructions'] + + begin + ont_data.save + rescue Exception => e + code = 500 + response[:error] = "Unable to save new term instructions for #{params['acronym']} due to a server error" + end + sleep(1) + render json: [response, code], status: code + end + end diff --git a/app/controllers/ontologies_controller.rb b/app/controllers/ontologies_controller.rb index f3adb4fe1..7bf8d3c15 100644 --- a/app/controllers/ontologies_controller.rb +++ b/app/controllers/ontologies_controller.rb @@ -277,6 +277,8 @@ def show @ontology = LinkedData::Client::Models::Ontology.find_by_acronym(params[:ontology]).first not_found if @ontology.nil? + @ob_instructions = ontolobridge_instructions_template(@ontology) + # Retrieve submissions in descending submissionId order (should be reverse chronological order) @submissions = @ontology.explore.submissions.sort {|a,b| b.submissionId.to_i <=> a.submissionId.to_i } || [] LOG.add :error, "No submissions for ontology: #{@ontology.id}" if @submissions.empty? @@ -321,6 +323,11 @@ def show end end + def ontolobridge_instructions_template(ontology) + ont_data = Ontology.find_by(acronym: ontology.acronym) + ont_data.nil? || ont_data.new_term_instructions.empty? ? t('concepts.request_term.new_term_instructions') : ont_data.new_term_instructions + end + def submit_success @acronym = params[:id] # Force the list of ontologies to be fresh by adding a param with current time diff --git a/app/views/concepts/_request_term.html.haml b/app/views/concepts/_request_term.html.haml index 807d8a244..7e1162f51 100644 --- a/app/views/concepts/_request_term.html.haml +++ b/app/views/concepts/_request_term.html.haml @@ -4,6 +4,39 @@ = link_to "Request New Term", login_index_path, style: "font-size: .9em;" - else = link_to "Request New Term", "javascript:void(0);", class: "add_request_term", style: "font-size: .9em;", data: { parent_id: "#{@concept.id}", parent_type: "class" } + + + + + - if @ontology.admin?(session[:user]) + %p + The text below is displayed to the user as the instructions on how to request new terms for your ontology. + You can edit this text by clicking anywhere inside it. Click 'Submit Changes' when done editing or 'Cancel' to discard your changes. + + %div#new_term_instructions_container{style: 'clear:both'} + %div#new_term_instructions_success_message{:class => "message-box success-msg", style: "display: none; clear: both;"} + %div#new_term_instructions_error_message{:class => "message-box error-msg", style: "display: none; clear: both;"} + %form#new_term_instructions_form + = hidden_field nil, :acronym, value: @ontology.acronym + = hidden_field nil, :new_term_instructions_old, value: @ob_instructions.html_safe + %div#new_term_instructions{contenteditable: "true"} + = @ob_instructions.html_safe + %button#new_term_instructions_submit{class: "btn", title: "Submit Changes", alt: "Submit Changes", style: 'display:none'} + Submit Changes + %button#new_term_instructions_cancel{class: "btn", title: "Cancel", alt: "Cancel", style: 'display:none'} + Cancel + %div#progress_message{style: 'display:none; clear: both;'} + + + + + - else + %div + = @ob_instructions.html_safe + + + + %div#ob_success_message{:class => "message-box success-msg", style: "display: none; clear: both;"} %div#ob_error_message{:class => "message-box error-msg", style: "display: none; clear: both;"} %div.request_term_form_div{style: "display: none; clear: both; padding-top: 1em;"} diff --git a/config/locales/en.yml b/config/locales/en.yml index 8bd6aa3f4..98907c18a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -87,6 +87,12 @@ en: ontologies: intro: Browse the library of ontologies + concepts: + request_term: + new_term_instructions: > +

Please click on 'Request New Term' link to submit your new term request

+

Please be sure to specify an accurate term description and the label

+ mappings: intro: Browse mappings between classes in different ontologies diff --git a/config/routes.rb b/config/routes.rb index 2b9377e76..0b70139da 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,7 +4,9 @@ resources :notes, constraints: { id: /.+/ } - resources :ontolobridge + resources :ontolobridge do + post :save_new_term_instructions, on: :collection + end resources :projects, constraints: { id: /[^\/]+/ } @@ -126,6 +128,10 @@ match '/admin/update_info' => 'admin#update_info', via: [:get] match '/admin/update_check_enabled' => 'admin#update_check_enabled', via: [:get] + + # Ontolobridge + # post '/ontolobridge/:save_new_term_instructions' => 'ontolobridge#save_new_term_instructions' + ########################################################################################################### # Install the default route as the lowest priority. get '/:controller(/:action(/:id))' diff --git a/db/schema.rb b/db/schema.rb index 0ad08dd7d..8d059b148 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,9 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20200220191815) do +ActiveRecord::Schema.define(version: 2020_09_21_120918) do - create_table "analytics", id: :integer, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| + create_table "analytics", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| t.string "segment" t.string "action" t.string "bp_slice" @@ -23,21 +23,30 @@ t.datetime "updated_at" end - create_table "licenses", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| + create_table "licenses", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| t.text "encrypted_key" t.datetime "created_at", null: false t.datetime "updated_at", null: false end - create_table "timeouts", id: :integer, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| + create_table "ontologies", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.string "acronym", null: false + t.text "new_term_instructions" + t.text "custom_message" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["acronym"], name: "index_ontologies_on_acronym", unique: true + end + + create_table "timeouts", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| t.string "path" t.integer "ontology_id" t.text "concept_id" t.text "params" - t.timestamp "created" + t.datetime "created" end - create_table "virtual_appliance_users", id: :integer, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| + create_table "virtual_appliance_users", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| t.string "user_id" t.datetime "created_at" t.datetime "updated_at" From e8c207b146f1e12a65ff38d5068af75029775698 Mon Sep 17 00:00:00 2001 From: mdorf Date: Sun, 27 Sep 2020 07:30:50 -0700 Subject: [PATCH 2/7] working on ncbo/bioportal-project#179 --- app/models/ontology.rb | 2 ++ db/migrate/20200921120918_create_ontologies.rb | 13 +++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 app/models/ontology.rb create mode 100644 db/migrate/20200921120918_create_ontologies.rb diff --git a/app/models/ontology.rb b/app/models/ontology.rb new file mode 100644 index 000000000..04662e128 --- /dev/null +++ b/app/models/ontology.rb @@ -0,0 +1,2 @@ +class Ontology < ApplicationRecord +end \ No newline at end of file diff --git a/db/migrate/20200921120918_create_ontologies.rb b/db/migrate/20200921120918_create_ontologies.rb new file mode 100644 index 000000000..d37219230 --- /dev/null +++ b/db/migrate/20200921120918_create_ontologies.rb @@ -0,0 +1,13 @@ +class CreateOntologies < ActiveRecord::Migration[5.1] + def change + create_table :ontologies do |t| + t.string :acronym, null: false + t.text :new_term_instructions + t.text :custom_message + + t.timestamps + end + + add_index :ontologies, :acronym, unique: true + end +end From ae757cd50a9127425408539855a9bf4f5326e61b Mon Sep 17 00:00:00 2001 From: mdorf Date: Mon, 28 Sep 2020 14:23:57 -0700 Subject: [PATCH 3/7] implemented rich text editor for ncbo/bioportal-project#179 --- app/assets/javascripts/bp_ontolobridge.js | 25 +- app/assets/javascripts/vendor.js | 1 + app/assets/stylesheets/application.css.scss | 1 + app/controllers/concepts_controller.rb | 2 + app/controllers/ontologies_controller.rb | 7 +- app/helpers/application_helper.rb | 5 + app/views/concepts/_request_term.html.haml | 10 +- vendor/assets/javascripts/trumbowyg.js | 1927 +++++++++++++++++++ vendor/assets/stylesheets/trumbowyg.css | 606 ++++++ 9 files changed, 2572 insertions(+), 12 deletions(-) create mode 100755 vendor/assets/javascripts/trumbowyg.js create mode 100755 vendor/assets/stylesheets/trumbowyg.css diff --git a/app/assets/javascripts/bp_ontolobridge.js b/app/assets/javascripts/bp_ontolobridge.js index e94d038e3..ccde501d1 100644 --- a/app/assets/javascripts/bp_ontolobridge.js +++ b/app/assets/javascripts/bp_ontolobridge.js @@ -1,3 +1,5 @@ + + function bindAddRequestTermClick() { jQuery("a.add_request_term").live('click', function(){ var id = jQuery(this).attr("data-parent-id"); @@ -12,8 +14,14 @@ function bindCancelRequestTermClick() { }); } -function bindNewTermInstructionsFocus() { - jQuery("#new_term_instructions").live("focus", function() { +function bindNewTermInstructionsClick() { + jQuery("#new_term_instructions").live("click", function() { + + + console.log("focusing"); + jQuery(this).trumbowyg(); + + jQuery("#new_term_instructions_submit").show(); jQuery("#new_term_instructions_cancel").show(); }); @@ -32,10 +40,20 @@ function bindNewTermInstructionsCancel() { if (oldVal != curVal) { if (confirm('Are you sure you want to discard your changes?')) { + + jQuery('#new_term_instructions').trumbowyg('destroy'); + + + jQuery('#new_term_instructions').html(oldVal); + hideButtons(); } } else { + + jQuery('#new_term_instructions').trumbowyg('destroy'); + + hideButtons(); } }); @@ -76,6 +94,7 @@ function saveNewTermInstructions() { if (status && status >= 400 || data[0]['error'].length) { showStatusMessages('new_term_instructions', '', data[0]['error']); } else { + jQuery('#new_term_instructions').trumbowyg('destroy'); jQuery("#new_term_instructions_old").val(newInstructions); showStatusMessages('new_term_instructions', data[0]["success"], ''); setTimeout(function() { clearStatusMessages('new_term_instructions'); }, 5000); @@ -281,5 +300,5 @@ jQuery(document).ready(function() { preventNewTermInstructionsFormSubmit(); bindNewTermInstructionsSubmit(); bindNewTermInstructionsCancel(); - bindNewTermInstructionsFocus(); + bindNewTermInstructionsClick(); }); diff --git a/app/assets/javascripts/vendor.js b/app/assets/javascripts/vendor.js index bddf673b4..5eb106f47 100644 --- a/app/assets/javascripts/vendor.js +++ b/app/assets/javascripts/vendor.js @@ -35,4 +35,5 @@ //= require Chart.min //= require select2 //= require jquery.readyselector +//= require trumbowyg diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index 38e8e254c..5f757e2ad 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -19,6 +19,7 @@ *= require jquery.tooltip *= require thickbox *= require select2 + *= require trumbowyg * */ diff --git a/app/controllers/concepts_controller.rb b/app/controllers/concepts_controller.rb index a86641031..a96c02ceb 100644 --- a/app/controllers/concepts_controller.rb +++ b/app/controllers/concepts_controller.rb @@ -16,6 +16,8 @@ def show # Note that find_by_acronym includes views by default @ontology = LinkedData::Client::Models::Ontology.find_by_acronym(params[:ontology]).first + @ob_instructions = helpers.ontolobridge_instructions_template(@ontology) + if request.xhr? display = params[:callback].eql?('load') ? {full: true} : {display: "prefLabel"} @concept = @ontology.explore.single_class(display, params[:id]) diff --git a/app/controllers/ontologies_controller.rb b/app/controllers/ontologies_controller.rb index 7bf8d3c15..8e9ad8114 100644 --- a/app/controllers/ontologies_controller.rb +++ b/app/controllers/ontologies_controller.rb @@ -277,7 +277,7 @@ def show @ontology = LinkedData::Client::Models::Ontology.find_by_acronym(params[:ontology]).first not_found if @ontology.nil? - @ob_instructions = ontolobridge_instructions_template(@ontology) + @ob_instructions = helpers.ontolobridge_instructions_template(@ontology) # Retrieve submissions in descending submissionId order (should be reverse chronological order) @submissions = @ontology.explore.submissions.sort {|a,b| b.submissionId.to_i <=> a.submissionId.to_i } || [] @@ -323,11 +323,6 @@ def show end end - def ontolobridge_instructions_template(ontology) - ont_data = Ontology.find_by(acronym: ontology.acronym) - ont_data.nil? || ont_data.new_term_instructions.empty? ? t('concepts.request_term.new_term_instructions') : ont_data.new_term_instructions - end - def submit_success @acronym = params[:id] # Force the list of ontologies to be fresh by adding a param with current time diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index bb2e81923..18eeebf5e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -363,6 +363,11 @@ def subscribed_to_ontology?(ontology_acronym, user) return false end + def ontolobridge_instructions_template(ontology) + ont_data = Ontology.find_by(acronym: ontology.acronym) + ont_data.nil? || ont_data.new_term_instructions.empty? ? t('concepts.request_term.new_term_instructions') : ont_data.new_term_instructions + end + # http://stackoverflow.com/questions/1293573/rails-smart-text-truncation def smart_truncate(s, opts = {}) opts = {:words => 20}.merge(opts) diff --git a/app/views/concepts/_request_term.html.haml b/app/views/concepts/_request_term.html.haml index 7e1162f51..e3657765e 100644 --- a/app/views/concepts/_request_term.html.haml +++ b/app/views/concepts/_request_term.html.haml @@ -1,3 +1,7 @@ +:plain +
+ +
.notes_list_container .add_request_term{style: "float: left; margin-right: 1em;"} - if session[:user].nil? @@ -13,7 +17,7 @@ The text below is displayed to the user as the instructions on how to request new terms for your ontology. You can edit this text by clicking anywhere inside it. Click 'Submit Changes' when done editing or 'Cancel' to discard your changes. - %div#new_term_instructions_container{style: 'clear:both'} + %div#new_term_instructions_container{style: 'clear: both'} %div#new_term_instructions_success_message{:class => "message-box success-msg", style: "display: none; clear: both;"} %div#new_term_instructions_error_message{:class => "message-box error-msg", style: "display: none; clear: both;"} %form#new_term_instructions_form @@ -21,9 +25,9 @@ = hidden_field nil, :new_term_instructions_old, value: @ob_instructions.html_safe %div#new_term_instructions{contenteditable: "true"} = @ob_instructions.html_safe - %button#new_term_instructions_submit{class: "btn", title: "Submit Changes", alt: "Submit Changes", style: 'display:none'} + %button#new_term_instructions_submit{class: "btn", title: "Submit Changes", alt: "Submit Changes", style: 'display: none'} Submit Changes - %button#new_term_instructions_cancel{class: "btn", title: "Cancel", alt: "Cancel", style: 'display:none'} + %button#new_term_instructions_cancel{class: "btn", title: "Cancel", alt: "Cancel", style: 'display: none'} Cancel %div#progress_message{style: 'display:none; clear: both;'} diff --git a/vendor/assets/javascripts/trumbowyg.js b/vendor/assets/javascripts/trumbowyg.js new file mode 100755 index 000000000..a572fa3b9 --- /dev/null +++ b/vendor/assets/javascripts/trumbowyg.js @@ -0,0 +1,1927 @@ +/** + * Trumbowyg v2.21.0 - A lightweight WYSIWYG editor + * Trumbowyg core file + * ------------------------ + * @link http://alex-d.github.io/Trumbowyg + * @license MIT + * @author Alexandre Demode (Alex-D) + * Twitter : @AlexandreDemode + * Website : alex-d.fr + */ + +jQuery.trumbowyg = { + langs: { + en: { + viewHTML: 'View HTML', + + undo: 'Undo', + redo: 'Redo', + + formatting: 'Formatting', + p: 'Paragraph', + blockquote: 'Quote', + code: 'Code', + header: 'Header', + + bold: 'Bold', + italic: 'Italic', + strikethrough: 'Strikethrough', + underline: 'Underline', + + strong: 'Strong', + em: 'Emphasis', + del: 'Deleted', + + superscript: 'Superscript', + subscript: 'Subscript', + + unorderedList: 'Unordered list', + orderedList: 'Ordered list', + + insertImage: 'Insert Image', + link: 'Link', + createLink: 'Insert link', + unlink: 'Remove link', + + justifyLeft: 'Align Left', + justifyCenter: 'Align Center', + justifyRight: 'Align Right', + justifyFull: 'Align Justify', + + horizontalRule: 'Insert horizontal rule', + removeformat: 'Remove format', + + fullscreen: 'Fullscreen', + + close: 'Close', + + submit: 'Confirm', + reset: 'Cancel', + + required: 'Required', + description: 'Description', + title: 'Title', + text: 'Text', + target: 'Target', + width: 'Width' + } + }, + + // Plugins + plugins: {}, + + // SVG Path globally + svgPath: null, + + hideButtonTexts: null +}; + +// Makes default options read-only +Object.defineProperty(jQuery.trumbowyg, 'defaultOptions', { + value: { + lang: 'en', + + fixedBtnPane: false, + fixedFullWidth: false, + autogrow: false, + autogrowOnEnter: false, + imageWidthModalEdit: false, + + prefix: 'trumbowyg-', + + semantic: true, + semanticKeepAttributes: false, + resetCss: false, + removeformatPasted: false, + tabToIndent: false, + tagsToRemove: [], + tagsToKeep: ['hr', 'img', 'embed', 'iframe', 'input'], + btns: [ + ['viewHTML'], + ['undo', 'redo'], // Only supported in Blink browsers + ['formatting'], + ['strong', 'em', 'del'], + ['superscript', 'subscript'], + ['link'], + ['insertImage'], + ['justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull'], + ['unorderedList', 'orderedList'], + ['horizontalRule'], + ['removeformat'], + ['fullscreen'] + ], + // For custom button definitions + btnsDef: {}, + changeActiveDropdownIcon: false, + + inlineElementsSelector: 'a,abbr,acronym,b,caption,cite,code,col,dfn,dir,dt,dd,em,font,hr,i,kbd,li,q,span,strikeout,strong,sub,sup,u', + + pasteHandlers: [], + + // imgDblClickHandler: default is defined in constructor + + plugins: {}, + urlProtocol: false, + minimalLinks: false, + defaultLinkTarget: undefined + }, + writable: false, + enumerable: true, + configurable: false +}); + +(function (navigator, window, document, $) { + 'use strict'; + + var CONFIRM_EVENT = 'tbwconfirm', + CANCEL_EVENT = 'tbwcancel'; + + $.fn.trumbowyg = function (options, params) { + var trumbowygDataName = 'trumbowyg'; + if (options === Object(options) || !options) { + return this.each(function () { + if (!$(this).data(trumbowygDataName)) { + $(this).data(trumbowygDataName, new Trumbowyg(this, options)); + } + }); + } + if (this.length === 1) { + try { + var t = $(this).data(trumbowygDataName); + switch (options) { + // Exec command + case 'execCmd': + return t.execCmd(params.cmd, params.param, params.forceCss, params.skipTrumbowyg); + + // Modal box + case 'openModal': + return t.openModal(params.title, params.content); + case 'closeModal': + return t.closeModal(); + case 'openModalInsert': + return t.openModalInsert(params.title, params.fields, params.callback); + + // Range + case 'saveRange': + return t.saveRange(); + case 'getRange': + return t.range; + case 'getRangeText': + return t.getRangeText(); + case 'restoreRange': + return t.restoreRange(); + + // Enable/disable + case 'enable': + return t.setDisabled(false); + case 'disable': + return t.setDisabled(true); + + // Toggle + case 'toggle': + return t.toggle(); + + // Destroy + case 'destroy': + return t.destroy(); + + // Empty + case 'empty': + return t.empty(); + + // HTML + case 'html': + return t.html(params); + } + } catch (c) { + } + } + + return false; + }; + + // @param: editorElem is the DOM element + var Trumbowyg = function (editorElem, options) { + var t = this, + trumbowygIconsId = 'trumbowyg-icons', + $trumbowyg = $.trumbowyg; + + // Get the document of the element. It use to makes the plugin + // compatible on iframes. + t.doc = editorElem.ownerDocument || document; + + // jQuery object of the editor + t.$ta = $(editorElem); // $ta : Textarea + t.$c = $(editorElem); // $c : creator + + options = options || {}; + + // Localization management + if (options.lang != null || $trumbowyg.langs[options.lang] != null) { + t.lang = $.extend(true, {}, $trumbowyg.langs.en, $trumbowyg.langs[options.lang]); + } else { + t.lang = $trumbowyg.langs.en; + } + + t.hideButtonTexts = $trumbowyg.hideButtonTexts != null ? $trumbowyg.hideButtonTexts : options.hideButtonTexts; + + // SVG path + var svgPathOption = $trumbowyg.svgPath != null ? $trumbowyg.svgPath : options.svgPath; + t.hasSvg = svgPathOption !== false; + t.svgPath = !!t.doc.querySelector('base') ? window.location.href.split('#')[0] : ''; + if ($('#' + trumbowygIconsId, t.doc).length === 0 && svgPathOption !== false) { + if (svgPathOption == null) { + // Hack to get svgPathOption based on trumbowyg.js path + var scriptElements = document.getElementsByTagName('script'); + for (var i = 0; i < scriptElements.length; i += 1) { + var source = scriptElements[i].src; + var matches = source.match('trumbowyg(\.min)?\.js'); + if (matches != null) { + svgPathOption = source.substring(0, source.indexOf(matches[0])) + 'ui/icons.svg'; + } + } + if (svgPathOption == null) { + console.warn('You must define svgPath: https://goo.gl/CfTY9U'); // jshint ignore:line + } + } + + var div = t.doc.createElement('div'); + div.id = trumbowygIconsId; + t.doc.body.insertBefore(div, t.doc.body.childNodes[0]); + $.ajax({ + async: true, + type: 'GET', + contentType: 'application/x-www-form-urlencoded; charset=UTF-8', + dataType: 'xml', + crossDomain: true, + url: svgPathOption, + data: null, + beforeSend: null, + complete: null, + success: function (data) { + div.innerHTML = new XMLSerializer().serializeToString(data.documentElement); + } + }); + } + + + /** + * When the button is associated to a empty object + * fn and title attributes are defined from the button key value + * + * For example + * foo: {} + * is equivalent to : + * foo: { + * fn: 'foo', + * title: this.lang.foo + * } + */ + var h = t.lang.header, // Header translation + isBlinkFunction = function () { + return (window.chrome || (window.Intl && Intl.v8BreakIterator)) && 'CSS' in window; + }; + t.btnsDef = { + viewHTML: { + fn: 'toggle', + class: 'trumbowyg-not-disable', + }, + + undo: { + isSupported: isBlinkFunction, + key: 'Z' + }, + redo: { + isSupported: isBlinkFunction, + key: 'Y' + }, + + p: { + fn: 'formatBlock' + }, + blockquote: { + fn: 'formatBlock' + }, + h1: { + fn: 'formatBlock', + title: h + ' 1' + }, + h2: { + fn: 'formatBlock', + title: h + ' 2' + }, + h3: { + fn: 'formatBlock', + title: h + ' 3' + }, + h4: { + fn: 'formatBlock', + title: h + ' 4' + }, + h5: { + fn: 'formatBlock', + title: h + ' 5' + }, + h6: { + fn: 'formatBlock', + title: h + ' 6' + }, + subscript: { + tag: 'sub' + }, + superscript: { + tag: 'sup' + }, + + bold: { + key: 'B', + tag: 'b' + }, + italic: { + key: 'I', + tag: 'i' + }, + underline: { + tag: 'u' + }, + strikethrough: { + tag: 'strike' + }, + + strong: { + fn: 'bold', + key: 'B' + }, + em: { + fn: 'italic', + key: 'I' + }, + del: { + fn: 'strikethrough' + }, + + createLink: { + key: 'K', + tag: 'a' + }, + unlink: {}, + + insertImage: {}, + + justifyLeft: { + tag: 'left', + forceCss: true + }, + justifyCenter: { + tag: 'center', + forceCss: true + }, + justifyRight: { + tag: 'right', + forceCss: true + }, + justifyFull: { + tag: 'justify', + forceCss: true + }, + + unorderedList: { + fn: 'insertUnorderedList', + tag: 'ul' + }, + orderedList: { + fn: 'insertOrderedList', + tag: 'ol' + }, + + horizontalRule: { + fn: 'insertHorizontalRule' + }, + + removeformat: {}, + + fullscreen: { + class: 'trumbowyg-not-disable' + }, + close: { + fn: 'destroy', + class: 'trumbowyg-not-disable' + }, + + // Dropdowns + formatting: { + dropdown: ['p', 'blockquote', 'h1', 'h2', 'h3', 'h4'], + ico: 'p' + }, + link: { + dropdown: ['createLink', 'unlink'] + } + }; + + // Defaults Options + t.o = $.extend(true, {}, $trumbowyg.defaultOptions, options); + if (!t.o.hasOwnProperty('imgDblClickHandler')) { + t.o.imgDblClickHandler = t.getDefaultImgDblClickHandler(); + } + + t.urlPrefix = t.setupUrlPrefix(); + + t.disabled = t.o.disabled || (editorElem.nodeName === 'TEXTAREA' && editorElem.disabled); + + if (options.btns) { + t.o.btns = options.btns; + } else if (!t.o.semantic) { + t.o.btns[3] = ['bold', 'italic', 'underline', 'strikethrough']; + } + + $.each(t.o.btnsDef, function (btnName, btnDef) { + t.addBtnDef(btnName, btnDef); + }); + + // put this here in the event it would be merged in with options + t.eventNamespace = 'trumbowyg-event'; + + // Keyboard shortcuts are load in this array + t.keys = []; + + // Tag to button dynamically hydrated + t.tagToButton = {}; + t.tagHandlers = []; + + // Admit multiple paste handlers + t.pasteHandlers = [].concat(t.o.pasteHandlers); + + // Check if browser is IE + t.isIE = navigator.userAgent.indexOf('MSIE') !== -1 || navigator.appVersion.indexOf('Trident/') !== -1; + + // Check if we are on macOs + t.isMac = navigator.platform.toUpperCase().indexOf('MAC') !== -1; + + t.init(); + }; + + Trumbowyg.prototype = { + DEFAULT_SEMANTIC_MAP: { + 'b': 'strong', + 'i': 'em', + 's': 'del', + 'strike': 'del', + 'div': 'p' + }, + + init: function () { + var t = this; + t.height = t.$ta.height(); + + t.initPlugins(); + + try { + // Disable image resize, try-catch for old IE + t.doc.execCommand('enableObjectResizing', false, false); + t.doc.execCommand('defaultParagraphSeparator', false, 'p'); + } catch (e) { + } + + t.buildEditor(); + t.buildBtnPane(); + + t.fixedBtnPaneEvents(); + + t.buildOverlay(); + + setTimeout(function () { + if (t.disabled) { + t.setDisabled(true); + } + t.$c.trigger('tbwinit'); + }); + }, + + addBtnDef: function (btnName, btnDef) { + this.btnsDef[btnName] = $.extend(btnDef, this.btnsDef[btnName] || {}); + }, + + setupUrlPrefix: function () { + var protocol = this.o.urlProtocol; + if (!protocol) { + return; + } + + if (typeof(protocol) !== 'string') { + return 'https://'; + } + return protocol.replace('://', '') + '://'; + }, + + buildEditor: function () { + var t = this, + prefix = t.o.prefix, + html = ''; + + t.$box = $('
', { + class: prefix + 'box ' + prefix + 'editor-visible ' + prefix + t.o.lang + ' trumbowyg' + }); + + // $ta = Textarea + // $ed = Editor + t.isTextarea = t.$ta.is('textarea'); + if (t.isTextarea) { + html = t.$ta.val(); + t.$ed = $('
'); + t.$box + .insertAfter(t.$ta) + .append(t.$ed, t.$ta); + } else { + t.$ed = t.$ta; + html = t.$ed.html(); + + t.$ta = $('