diff options
81 files changed, 4365 insertions, 239 deletions
diff --git a/CHANGELOG b/CHANGELOG index cb95cdef042..9e897644af0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,13 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.6.0 (unreleased) - Improve the formatting for the user page bio (Connor Shea) + - Fix avatar stretching by providing a cropping feature (Johann Pardanaud) + - Strip leading and trailing spaces in URL validator (evuez) + - Update documentation to reflect Guest role not being enforced on internal projects + +v 8.5.2 + - Fix sidebar overlapping content when screen width was below 1200px + - Fix error 500 when commenting on a commit v 8.5.1 - Fix group projects styles @@ -22,9 +29,6 @@ v 8.5.1 - Update sentry-raven gem to 0.15.6 - Add build coverage in project's builds page (Steffen Köhler) -v 8.5.2 - - Fix error 500 when commenting on a commit - v 8.5.0 - Fix duplicate "me" in tooltip of the "thumbsup" awards Emoji (Stan Hu) - Cache various Repository methods to improve performance (Yorick Peterse) @@ -77,6 +77,9 @@ gem "haml-rails", '~> 0.9.0' # Files attachments gem "carrierwave", '~> 0.9.0' +# Image editing +gem "mini_magick", '~> 4.4.0' + # Drag and Drop UI gem 'dropzonejs-rails', '~> 0.7.1' @@ -210,7 +213,6 @@ gem 'jquery-atwho-rails', '~> 1.3.2' gem 'jquery-rails', '~> 4.0.0' gem 'jquery-scrollto-rails', '~> 1.4.3' gem 'jquery-ui-rails', '~> 5.0.0' -gem 'nprogress-rails', '~> 0.1.6.7' gem 'raphael-rails', '~> 2.1.2' gem 'request_store', '~> 1.2.0' gem 'select2-rails', '~> 3.5.9' diff --git a/Gemfile.lock b/Gemfile.lock index c48c733f5a1..1ba062dd0d6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -468,6 +468,7 @@ GEM method_source (0.8.2) mime-types (1.25.1) mimemagic (0.3.0) + mini_magick (4.4.0) mini_portile2 (2.0.0) minitest (5.7.0) mousetrap-rails (1.4.6) @@ -482,7 +483,6 @@ GEM newrelic_rpm (3.14.1.311) nokogiri (1.6.7.2) mini_portile2 (~> 2.0.0.rc2) - nprogress-rails (0.1.6.7) oauth (0.4.7) oauth2 (1.0.0) faraday (>= 0.8, < 0.10) @@ -955,6 +955,7 @@ DEPENDENCIES loofah (~> 2.0.3) mail_room (~> 0.6.1) method_source (~> 0.8) + mini_magick (~> 4.4.0) minitest (~> 5.7.0) mousetrap-rails (~> 1.4.6) mysql2 (~> 0.3.16) @@ -962,7 +963,6 @@ DEPENDENCIES net-ssh (~> 3.0.1) newrelic_rpm (~> 3.14) nokogiri (~> 1.6.7, >= 1.6.7.2) - nprogress-rails (~> 0.1.6.7) oauth2 (~> 1.0.0) octokit (~> 3.8.0) omniauth (~> 1.3.1) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 0651bd20d26..c17d2186e29 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -31,8 +31,6 @@ #= require ace/ace #= require ace/ext-searchbox #= require underscore -#= require nprogress -#= require nprogress-turbolinks #= require dropzone #= require mousetrap #= require mousetrap/pause @@ -44,6 +42,7 @@ #= require jquery.nicescroll #= require_tree . #= require fuzzaldrin-plus +#= require cropper.js window.slugify = (text) -> text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase() diff --git a/app/assets/javascripts/logo.js.coffee b/app/assets/javascripts/logo.js.coffee index 35b2fbbba07..d14b7139237 100644 --- a/app/assets/javascripts/logo.js.coffee +++ b/app/assets/javascripts/logo.js.coffee @@ -1,4 +1,4 @@ -NProgress.configure(showSpinner: false) +Turbolinks.enableProgressBar(); defaultClass = 'tanuki-shape' pieces = [ diff --git a/app/assets/javascripts/profile.js.coffee b/app/assets/javascripts/profile.js.coffee index bb0b66b86e1..69d590a7533 100644 --- a/app/assets/javascripts/profile.js.coffee +++ b/app/assets/javascripts/profile.js.coffee @@ -16,11 +16,50 @@ class @Profile $('.update-notifications').on 'ajax:complete', -> $(this).find('.btn-save').enable() - $('.js-choose-user-avatar-button').bind "click", -> - form = $(this).closest("form") - form.find(".js-user-avatar-input").click() + # Avatar management + + $avatarInput = $('.js-user-avatar-input') + $filename = $('.js-avatar-filename') + $modalCrop = $('.modal-profile-crop') + $modalCropImg = $('.modal-profile-crop-image') + + $('.js-choose-user-avatar-button').on "click", -> + $form = $(this).closest("form") + $form.find(".js-user-avatar-input").click() + + $modalCrop.on 'shown.bs.modal', -> + setTimeout ( -> # The cropper must be asynchronously initialized + $modalCropImg.cropper + aspectRatio: 1 + modal: false + scalable: false + rotatable: false + zoomable: false + + crop: (event) -> + ['x', 'y'].forEach (key) -> + $("#user_avatar_crop_#{key}").val(Math.floor(event[key])) + $("#user_avatar_crop_size").val(Math.floor(event.width)) + ), 0 + + $modalCrop.on 'hidden.bs.modal', -> + $modalCropImg.attr('src', '').cropper('destroy') + $avatarInput.val('') + $filename.text($filename.data('label')) - $('.js-user-avatar-input').bind "change", -> + $('.js-upload-user-avatar').on 'click', -> + $('.edit_user').submit() + + $avatarInput.on "change", -> form = $(this).closest("form") filename = $(this).val().replace(/^.*[\\\/]/, '') - form.find(".js-avatar-filename").text(filename) + $filename.data('label', $filename.text()).text(filename) + + reader = new FileReader + + reader.onload = (event) -> + $modalCrop.modal('show') + $modalCropImg.attr('src', event.target.result) + + fileData = reader.readAsDataURL(this.files[0]) + diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 0c0451fe4dd..e2d590f4df4 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -9,6 +9,7 @@ *= require_self *= require dropzone/basic *= require cal-heatmap + *= require cropper.css */ /* @@ -25,12 +26,6 @@ @import "framework"; /* - * NProgress load bar css - */ -@import 'nprogress'; -@import 'nprogress-bootstrap'; - -/* * Font icons */ @import "font-awesome"; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index fa7641b1676..e2a30f5ed34 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -26,6 +26,7 @@ @import "framework/mobile.scss"; @import "framework/nav.scss"; @import "framework/pagination.scss"; +@import "framework/progress.scss"; @import "framework/panels.scss"; @import "framework/selects.scss"; @import "framework/sidebar.scss"; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 1d5000fe388..368bbfe5355 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -41,6 +41,12 @@ transition: $transition; } +@mixin transform($transform) { + -webkit-transform: $transform; + -ms-transform: $transform; + transform: $transform; +} + /** * Prefilled mixins * Mixins with fixed values diff --git a/app/assets/stylesheets/framework/progress.scss b/app/assets/stylesheets/framework/progress.scss new file mode 100644 index 00000000000..e9800bd24b5 --- /dev/null +++ b/app/assets/stylesheets/framework/progress.scss @@ -0,0 +1,5 @@ +html.turbolinks-progress-bar::before { + background-color: $progress-color!important; + height: 2px!important; + box-shadow: 0 0 10px $progress-color, 0 0 5px $progress-color; +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 2706d031d7b..7834cb0bfa5 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -7,7 +7,7 @@ $gl-header-color: #323232; $gl-link-color: #333c48; $md-text-color: #444; $md-link-color: #3084bb; -$nprogress-color: #c0392b; +$progress-color: #c0392b; $gl-font-size: 15px; $list-font-size: 15px; $sidebar_collapsed_width: 62px; diff --git a/app/assets/stylesheets/pages/appearances.scss b/app/assets/stylesheets/pages/appearances.scss new file mode 100644 index 00000000000..e2070f17c3b --- /dev/null +++ b/app/assets/stylesheets/pages/appearances.scss @@ -0,0 +1,11 @@ +.appearance-logo-preview { + max-width: 400px; + margin-bottom: 20px; +} + +.appearance-light-logo-preview { + background-color: $background-color; + max-width: 72px; + padding: 10px; + margin-bottom: 10px; +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 374c66ef5af..b61d1f180b3 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -203,14 +203,7 @@ overflow: hidden; } - .issuable-count, - .issuable-nav, - .assignee > *, - .milestone > *, - .labels > *, - .participants > *, - .light > *, - .project-reference > * { + .hide-collapsed { display: none; } diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 4767c65d9a7..de4d9fd80fa 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -78,3 +78,39 @@ max-width: 750px; margin: auto; } + +.modal-profile-crop { + .modal-dialog { + width: 500px; + } + + .modal-body { + p { + display: table; + margin: auto; + overflow: hidden; + } + + img { + display: block; + max-width: 400px; + max-height: 400px; + } + + .cropper-bg { + background: none; + } + + .cropper-crop-box { + box-sizing: content-box; + border: 999px solid transparentize(#ccc, 0.5); + @include transform(translate(-999px, -999px)); + } + } +} + +@media (max-width: 520px) { + .modal-profile-crop .modal-dialog { + width: auto; + } +} diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 2f57f21963d..0dc5a905f99 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -12,29 +12,10 @@ } } -.todos { - .panel { - border-top: none; - margin-bottom: 0; - } -} - .todo-item { font-size: $gl-font-size; - padding: $gl-padding-top 0 $gl-padding-top ($gl-avatar-size + $gl-padding-top); - border-bottom: 1px solid $table-border-color; - color: #7f8fa4; - - &.todo-inline { - .avatar { - position: relative; - top: -2px; - } - - .todo-title { - line-height: 40px; - } - } + padding-left: $gl-avatar-size + $gl-padding-top; + color: $secondary-text; a { color: #4c4e54; @@ -48,7 +29,7 @@ @include str-truncated(calc(100% - 174px)); font-weight: 600; - .author_name { + .author-name { color: #333; } } @@ -88,17 +69,7 @@ margin-bottom: 0; } } - - .todo-note-icon { - color: #777; - float: left; - font-size: $gl-font-size; - line-height: 16px; - margin-right: 5px; - } } - - &:last-child { border:none } } @media (max-width: $screen-xs-max) { diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb new file mode 100644 index 00000000000..26cf74e4849 --- /dev/null +++ b/app/controllers/admin/appearances_controller.rb @@ -0,0 +1,57 @@ +class Admin::AppearancesController < Admin::ApplicationController + before_action :set_appearance, except: :create + + def show + end + + def preview + end + + def create + @appearance = Appearance.new(appearance_params) + + if @appearance.save + redirect_to admin_appearances_path, notice: 'Appearance was successfully created.' + else + render action: 'show' + end + end + + def update + if @appearance.update(appearance_params) + redirect_to admin_appearances_path, notice: 'Appearance was successfully updated.' + else + render action: 'show' + end + end + + def logo + @appearance.remove_logo! + + @appearance.save + + redirect_to admin_appearances_path, notice: 'Logo was succesfully removed.' + end + + def header_logos + @appearance.remove_header_logo! + @appearance.save + + redirect_to admin_appearances_path, notice: 'Header logo was succesfully removed.' + end + + private + + # Use callbacks to share common setup or constraints between actions. + def set_appearance + @appearance = Appearance.last || Appearance.new + end + + # Only allow a trusted parameter "white list" through. + def appearance_params + params.require(:appearance).permit( + :title, :description, :logo, :logo_cache, :header_logo, :header_logo_cache, + :updated_by + ) + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 28803164fcf..fa7a1148961 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -65,6 +65,9 @@ class ProfilesController < Profiles::ApplicationController def user_params params.require(:user).permit( + :avatar_crop_x, + :avatar_crop_y, + :avatar_crop_size, :avatar, :bio, :email, diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 868b05929d7..509f4f412ca 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -55,14 +55,15 @@ class UploadsController < ApplicationController "user" => User, "project" => Project, "note" => Note, - "group" => Group + "group" => Group, + "appearance" => Appearance } upload_models[params[:model]] end def upload_mount - upload_mounts = %w(avatar attachment file) + upload_mounts = %w(avatar attachment file logo header_logo) if upload_mounts.include?(params[:mounted_as]) params[:mounted_as] diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index c5820bf4c50..e0abc3a2869 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -1,21 +1,33 @@ module AppearancesHelper - def brand_item - nil - end - def brand_title - 'GitLab Community Edition' + if brand_item && brand_item.title + brand_item.title + else + 'GitLab Community Edition' + end end def brand_image - nil + if brand_item.logo? + image_tag brand_item.logo + else + nil + end end def brand_text - nil + markdown(brand_item.description) + end + + def brand_item + @appearance ||= Appearance.first end def brand_header_logo - render 'shared/logo.svg' + if brand_item && brand_item.header_logo? + image_tag brand_item.header_logo + else + render 'shared/logo.svg' + end end end diff --git a/app/models/appearance.rb b/app/models/appearance.rb new file mode 100644 index 00000000000..4cf8dd9a8ce --- /dev/null +++ b/app/models/appearance.rb @@ -0,0 +1,9 @@ +class Appearance < ActiveRecord::Base + validates :title, presence: true + validates :description, presence: true + validates :logo, file_size: { maximum: 1.megabyte } + validates :header_logo, file_size: { maximum: 1.megabyte } + + mount_uploader :logo, AttachmentUploader + mount_uploader :header_logo, AttachmentUploader +end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index cbe65d70997..8f99e3bef9b 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -27,7 +27,7 @@ class Milestone < ActiveRecord::Base belongs_to :project has_many :issues - has_many :labels, through: :issues + has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests has_many :participants, through: :issues, source: :assignee diff --git a/app/models/project.rb b/app/models/project.rb index 95ad88c76ae..6f5d592755a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -801,10 +801,7 @@ class Project < ActiveRecord::Base end def change_head(branch) - # Cached divergent commit counts are based on repository head - repository.expire_branch_cache - repository.expire_root_ref_cache - + repository.before_change_head gitlab_shell.update_repository_head(self.path_with_namespace, branch) reload_default_branch end diff --git a/app/models/repository.rb b/app/models/repository.rb index e050bd45254..a214a69d749 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -245,15 +245,6 @@ class Repository expire_emptiness_caches if empty? end - # Expires _all_ caches, including those that would normally only be expired - # under specific conditions. - def expire_all_caches! - expire_cache - expire_root_ref_cache - expire_emptiness_caches - expire_has_visible_content_cache - end - def expire_branch_cache(branch_name = nil) # When we push to the root branch we have to flush the cache for all other # branches as their statistics are based on the commits relative to the @@ -307,6 +298,46 @@ class Repository cache.expire(:branch_names) end + # Runs code just before a repository is deleted. + def before_delete + expire_cache if exists? + + expire_root_ref_cache + expire_emptiness_caches + end + + # Runs code just before the HEAD of a repository is changed. + def before_change_head + # Cached divergent commit counts are based on repository head + expire_branch_cache + expire_root_ref_cache + end + + # Runs code before creating a new tag. + def before_create_tag + expire_cache + end + + # Runs code after a repository has been forked/imported. + def after_import + expire_emptiness_caches + end + + # Runs code after a new commit has been pushed. + def after_push_commit(branch_name) + expire_cache(branch_name) + end + + # Runs code after a new branch has been created. + def after_create_branch + expire_has_visible_content_cache + end + + # Runs code after an existing branch has been removed. + def after_remove_branch + expire_has_visible_content_cache + end + def method_missing(m, *args, &block) if m == :lookup && !block_given? lookup_cache[m] ||= {} diff --git a/app/models/user.rb b/app/models/user.rb index 02ff2456f2b..6baf2468ade 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -98,6 +98,9 @@ class User < ActiveRecord::Base # Virtual attribute for authenticating by either username or email attr_accessor :login + # Virtual attributes to define avatar cropping + attr_accessor :avatar_crop_x, :avatar_crop_y, :avatar_crop_size + # # Relations # @@ -163,6 +166,11 @@ class User < ActiveRecord::Base validate :owns_public_email, if: ->(user) { user.public_email_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } + validates :avatar_crop_x, :avatar_crop_y, :avatar_crop_size, + numericality: { only_integer: true }, + presence: true, + if: ->(user) { user.avatar? } + before_validation :generate_password, on: :create before_validation :restricted_signup_domains, on: :create before_validation :sanitize_attrs diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index a1711d234ff..9ba200f7bde 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -16,13 +16,13 @@ class GitPushService < BaseService # 5. Executes the project's services # def execute - @project.repository.expire_cache(branch_name) + @project.repository.after_push_commit(branch_name) if push_remove_branch? - @project.repository.expire_has_visible_content_cache + @project.repository.after_remove_branch @push_commits = [] elsif push_to_new_branch? - @project.repository.expire_has_visible_content_cache + @project.repository.after_create_branch # Re-find the pushed commits. if is_default_branch? diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index 4144c7111d0..a62c5fc4fc4 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -2,7 +2,7 @@ class GitTagPushService attr_accessor :project, :user, :push_data def execute(project, user, oldrev, newrev, ref) - project.repository.expire_cache + project.repository.before_create_tag @project, @user = project, user @push_data = build_push_data(oldrev, newrev, ref) diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index f4dcb142850..df5054f08d7 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -76,11 +76,9 @@ module Projects end def flush_caches(project, wiki_path) - project.repository.expire_all_caches! if project.repository.exists? + project.repository.before_delete - wiki_repo = Repository.new(wiki_path, project) - - wiki_repo.expire_all_caches! if wiki_repo.exists? + Repository.new(wiki_path, project).before_delete end end end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 6135c3ad96f..2c72df44ff0 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -2,11 +2,22 @@ class AvatarUploader < CarrierWave::Uploader::Base include UploaderHelper + include CarrierWave::MiniMagick storage :file after :store, :reset_events_cache + process :cropper + + def cropper + return unless model.respond_to?(:avatar_crop_size) && model.valid? + + manipulate! do |img| + img.crop "#{model.avatar_crop_size}x#{model.avatar_crop_size}+#{model.avatar_crop_x}+#{model.avatar_crop_y}" + end + end + def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 2848b9cd33d..a77beb2683d 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -29,8 +29,11 @@ class UrlValidator < ActiveModel::EachValidator end def valid_url?(value) + return false if value.nil? + options = default_options.merge(self.options) + value.strip! value =~ /\A#{URI.regexp(options[:protocols])}\z/ end end diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml new file mode 100644 index 00000000000..6f325914d14 --- /dev/null +++ b/app/views/admin/appearances/_form.html.haml @@ -0,0 +1,58 @@ += form_for @appearance, url: admin_appearances_path, html: { class: 'form-horizontal'} do |f| + - if @appearance.errors.any? + .alert.alert-danger + - @appearance.errors.full_messages.each do |msg| + %p= msg + + %fieldset.sign-in + %legend + Sign in/Sign up pages: + .form-group + = f.label :title, class: 'control-label' + .col-sm-10 + = f.text_field :title, class: "form-control" + .form-group + = f.label :description, class: 'control-label' + .col-sm-10 + = f.text_area :description, class: "form-control", rows: 10 + .hint + Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('markdown', 'markdown'), target: '_blank'}. + .form-group + = f.label :logo, class: 'control-label' + .col-sm-10 + - if @appearance.logo? + = image_tag @appearance.logo_url, class: 'appearance-logo-preview' + - if @appearance.persisted? + %br + = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-small remove-logo" + %hr + = f.hidden_field :logo_cache + = f.file_field :logo, class: "" + .hint + Maximum file size is 1MB. Pages are optimized for a 640x360 px logo. + + %fieldset.app_logo + %legend + Navigation bar: + .form-group + = f.label :header_logo, 'Header logo', class: 'control-label' + .col-sm-10 + - if @appearance.header_logo? + = image_tag @appearance.header_logo_url, class: 'appearance-light-logo-preview' + - if @appearance.persisted? + %br + = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-small remove-logo" + %hr + = f.hidden_field :header_logo_cache + = f.file_field :header_logo, class: "" + .hint + Maximum file size is 1MB. Pages are optimized for a 72x72 px header logo + + .form-actions + = f.submit 'Save', class: 'btn btn-save' + - if @appearance.persisted? + = link_to 'Preview last save', preview_admin_appearances_path, class: 'btn', target: '_blank' + + - if @appearance.updated_at + %span.pull-right + Last edit #{time_ago_with_tooltip(@appearance.updated_at)} diff --git a/app/views/admin/appearances/preview.html.haml b/app/views/admin/appearances/preview.html.haml new file mode 100644 index 00000000000..dd4a64e80bc --- /dev/null +++ b/app/views/admin/appearances/preview.html.haml @@ -0,0 +1,29 @@ +- page_title "Preview | Appearance" +%h3.page-title + Appearance settings - Preview +%hr + +.ui-box + .title + Sign-in page + %div + .login-page + .container + .content + .login-title + %h1= brand_title + %hr + .container + .content + .row + .col-sm-7 + .brand-image + = brand_image + .brand_text + = brand_text + .col-sm-4 + .login-box + %h3.page-title Sign in + = text_field_tag :login, nil, class: "form-control top", placeholder: "Username or Email" + = password_field_tag :password, nil, class: "form-control bottom", placeholder: "Password" + = button_tag "Sign in", class: "btn-create btn" diff --git a/app/views/admin/appearances/show.html.haml b/app/views/admin/appearances/show.html.haml new file mode 100644 index 00000000000..089e8e4cb7a --- /dev/null +++ b/app/views/admin/appearances/show.html.haml @@ -0,0 +1,7 @@ +- page_title "Appearance" +%h3.page-title + Appearance settings +%p.light + You can modify the look and feel of GitLab here + += render 'form' diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 6975f6ed0db..f878d36e739 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -1,11 +1,11 @@ %li{class: "todo todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo) } - .todo-item{class: 'todo-block'} + .todo-item.todo-block = image_tag avatar_icon(todo.author_email, 40), class: 'avatar s40', alt:'' .todo-title - %span.author_name + %span.author-name = link_to_author todo - %span.todo_label + %span.todo-label = todo_action_name(todo) = todo_target_link(todo) diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index ac1d5429382..280a1b93729 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -56,6 +56,11 @@ = icon('cog fw') %span Background Jobs + = nav_link(controller: :appearances) do + = link_to admin_appearances_path, title: 'Appearances' do + = icon('image') + %span + Appearance = nav_link(controller: :applications) do = link_to admin_applications_path, title: 'Applications' do diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 5051c6bf83b..64c4bdceff9 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -90,6 +90,9 @@ %span.file_name.js-avatar-filename File name... = f.file_field :avatar, class: "js-user-avatar-input hidden" + = f.hidden_field :avatar_crop_x + = f.hidden_field :avatar_crop_y + = f.hidden_field :avatar_crop_size .light The maximum file size allowed is 200KB. - if @user.avatar? %hr @@ -99,3 +102,19 @@ .form-actions = f.submit 'Save changes', class: "btn btn-success" = link_to "Cancel", user_path(current_user), class: "btn btn-cancel" + +.modal.modal-profile-crop + .modal-dialog + .modal-content + .modal-header + %button.close{type: 'button', data: {dismiss: 'modal'}} + %span + × + %h4.modal-title + Crop your new profile picture + .modal-body + %p + %img.modal-profile-crop-image + .modal-footer + %button.btn.btn-primary.js-upload-user-avatar{:type => "button"} + Set new profile picture diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml index f99bc9a85eb..63ede71e6f1 100644 --- a/app/views/projects/diffs/_warning.html.haml +++ b/app/views/projects/diffs/_warning.html.haml @@ -3,17 +3,16 @@ Too many changes to show. .pull-right - unless diff_hard_limit_enabled? - = link_to "Reload with full diff", url_for(params.merge(force_show_diff: true, format: nil)), class: "btn btn-sm btn-warning" + = link_to "Reload with full diff", url_for(params.merge(force_show_diff: true, format: nil)), class: "btn btn-sm" - if current_controller?(:commit) or current_controller?(:merge_requests) - if current_controller?(:commit) - = link_to "Plain diff", namespace_project_commit_path(@project.namespace, @project, @commit, format: :diff), class: "btn btn-warning btn-sm" - = link_to "Email patch", namespace_project_commit_path(@project.namespace, @project, @commit, format: :patch), class: "btn btn-warning btn-sm" + = link_to "Plain diff", namespace_project_commit_path(@project.namespace, @project, @commit, format: :diff), class: "btn btn-sm" + = link_to "Email patch", namespace_project_commit_path(@project.namespace, @project, @commit, format: :patch), class: "btn btn-sm" - elsif @merge_request && @merge_request.persisted? - = link_to "Plain diff", merge_request_path(@merge_request, format: :diff), class: "btn btn-warning btn-sm" - = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-warning btn-sm" + = link_to "Plain diff", merge_request_path(@merge_request, format: :diff), class: "btn btn-sm" + = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-sm" %p To preserve performance only %strong #{shown_files_count} of #{diffs.size} files are displayed. - diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml index ea61935487c..f1d92ef48b2 100644 --- a/app/views/shared/issuable/_participants.html.haml +++ b/app/views/shared/issuable/_participants.html.haml @@ -3,7 +3,8 @@ = icon('users') %span = participants.count - .title + .title.hide-collapsed = pluralize participants.count, "participant" - participants.each do |participant| - = link_to_member(@project, participant, name: false, size: 24) + %span.hide-collapsed + = link_to_member(@project, participant, name: false, size: 24) diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index a45775f36b5..36f06377886 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,14 +1,14 @@ %aside.right-sidebar{ class: sidebar_gutter_collapsed_class } .issuable-sidebar .block - %span.issuable-count.pull-left + %span.issuable-count.hide-collapsed.pull-left = issuable.iid of = issuables_count(issuable) %span.pull-right %a.gutter-toggle{href: '#'} = sidebar_gutter_toggle_icon - .issuable-nav.pull-right.btn-group{role: 'group', "aria-label" => '...'} + .issuable-nav.hide-collapsed.pull-right.btn-group{role: 'group', "aria-label" => '...'} - if prev_issuable = prev_issuable_for(issuable) = link_to 'Prev', [@project.namespace.becomes(Namespace), @project, prev_issuable], class: 'btn btn-default prev-btn' - else @@ -27,13 +27,13 @@ = link_to_member_avatar(issuable.assignee, size: 24) - else = icon('user') - .title + .title.hide-collapsed %label Assignee - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .pull-right = link_to 'Edit', '#', class: 'edit-link' - .value + .value.hide-collapsed - if issuable.assignee %strong= link_to_member(@project, issuable.assignee, size: 24) - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) @@ -42,7 +42,7 @@ - else .light None - .selectbox + .selectbox.hide-collapsed = users_select_tag("#{issuable.class.table_name.singularize}[assignee_id]", placeholder: 'Select assignee', class: 'custom-form-control js-select2 js-assignee', selected: issuable.assignee_id, project: @target_project, null_user: true, current_user: true, first_user: true) .block.milestone @@ -53,13 +53,13 @@ = issuable.milestone.title - else No - .title + .title.hide-collapsed %label Milestone - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .pull-right = link_to 'Edit', '#', class: 'edit-link' - .value + .value.hide-collapsed - if issuable.milestone %span.back-to-milestone = link_to namespace_project_milestone_path(@project.namespace, @project, issuable.milestone) do @@ -68,7 +68,7 @@ = issuable.milestone.title - else .light None - .selectbox + .selectbox.hide-collapsed = f.select(:milestone_id, milestone_options(issuable), { include_blank: true }, { class: 'select2 select2-compact js-select2 js-milestone', data: { placeholder: 'Select milestone' }}) = hidden_field_tag :issuable_context = f.submit class: 'btn hide' @@ -79,18 +79,18 @@ = icon('tags') %span = issuable.labels.count - .title + .title.hide-collapsed %label Labels - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .pull-right = link_to 'Edit', '#', class: 'edit-link' - .value.issuable-show-labels + .value.issuable-show-labels.hide-collapsed - if issuable.labels.any? - issuable.labels.each do |label| = link_to_label(label, type: issuable.to_ability_name) - else .light None - .selectbox + .selectbox.hide-collapsed = f.collection_select :label_ids, issuable.project.labels.all, :id, :name, { selected: issuable.label_ids }, multiple: true, class: 'select2 js-select2', data: { placeholder: "Select labels" } @@ -101,12 +101,12 @@ .block.light .sidebar-collapsed-icon = icon('rss') - .title + .title.hide-collapsed %label.light Notifications - subscribtion_status = subscribed ? 'subscribed' : 'unsubscribed' - %button.btn.btn-block.btn-gray.subscribe-button{:type => 'button'} + %button.btn.btn-block.btn-gray.subscribe-button.hide-collapsed{:type => 'button'} %span= subscribed ? 'Unsubscribe' : 'Subscribe' - .subscription-status{data: {status: subscribtion_status}} + .subscription-status.hide-collapsed{data: {status: subscribtion_status}} .unsubscribed{class: ( 'hidden' if subscribed )} You're not receiving notifications from this thread. .subscribed{class: ( 'hidden' unless subscribed )} @@ -116,8 +116,7 @@ .block.project-reference .sidebar-collapsed-icon = clipboard_button(clipboard_text: project_ref) - .title - .cross-project-reference + .cross-project-reference.hide-collapsed %span Reference: %cite{title: project_ref} diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 2572b9d6d98..21d311579e3 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -27,7 +27,7 @@ class RepositoryForkWorker return end - project.repository.expire_emptiness_caches + project.repository.after_import project.import_finish end end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 0b6f746e118..2937493c614 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -18,7 +18,7 @@ class RepositoryImportWorker return end - project.repository.expire_emptiness_caches + project.repository.after_import project.import_finish end end diff --git a/config/routes.rb b/config/routes.rb index 1485b64da19..a2acf170a6b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -156,6 +156,11 @@ Rails.application.routes.draw do to: "uploads#show", constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /[^\/]+/ } + # Appearance + get ":model/:mounted_as/:id/:filename", + to: "uploads#show", + constraints: { model: /appearance/, mounted_as: /logo|header_logo/, filename: /.+/ } + # Project markdown uploads get ":namespace_id/:project_id/:secret/:filename", to: "projects/uploads#show", @@ -253,6 +258,14 @@ Rails.application.routes.draw do end end + resource :appearances, path: 'appearance' do + member do + get :preview + delete :logo + delete :header_logos + end + end + resource :application_settings, only: [:show, :update] do resources :services put :reset_runners_token diff --git a/db/migrate/20160222153918_create_appearances_ce.rb b/db/migrate/20160222153918_create_appearances_ce.rb new file mode 100644 index 00000000000..bec66bcc71e --- /dev/null +++ b/db/migrate/20160222153918_create_appearances_ce.rb @@ -0,0 +1,14 @@ +class CreateAppearancesCe < ActiveRecord::Migration + def change + unless table_exists?(:appearances) + create_table :appearances do |t| + t.string :title + t.text :description + t.string :header_logo + t.string :logo + + t.timestamps null: false + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 4708c29d9ae..53a941d30de 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160220123949) do +ActiveRecord::Schema.define(version: 20160222153918) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -24,6 +24,15 @@ ActiveRecord::Schema.define(version: 20160220123949) do t.datetime "updated_at" end + create_table "appearances", force: :cascade do |t| + t.string "title" + t.text "description" + t.string "header_logo" + t.string "logo" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "application_settings", force: :cascade do |t| t.integer "default_projects_limit" t.boolean "signup_enabled" diff --git a/doc/README.md b/doc/README.md index 5089e1e70f6..be6c5f96ea1 100644 --- a/doc/README.md +++ b/doc/README.md @@ -16,40 +16,42 @@ - [Web hooks](web_hooks/web_hooks.md) Let GitLab notify you when new code has been pushed to your project. - [Workflow](workflow/README.md) Using GitLab functionality and importing projects from GitHub and SVN. -## CI Documentation +## CI User documentation -- [Quick Start](ci/quick_start/README.md) -- [Enable or disable GitLab CI](ci/enable_or_disable_ci.md) -- [Configuring project (.gitlab-ci.yml)](ci/yaml/README.md) -- [Configuring runner](ci/runners/README.md) -- [Configuring deployment](ci/deployment/README.md) -- [Using Docker Images](ci/docker/using_docker_images.md) -- [Using Docker Build](ci/docker/using_docker_build.md) -- [Using Variables](ci/variables/README.md) -- [Using SSH keys](ci/ssh_keys/README.md) +- [Get started with GitLab CI](ci/quick_start/README.md) +- [Learn how to enable or disable GitLab CI](ci/enable_or_disable_ci.md) +- [Learn how `.gitlab-ci.yml` works](ci/yaml/README.md) +- [Configure a Runner, the application that runs your builds](ci/runners/README.md) +- [Use Docker images with GitLab Runner](ci/docker/using_docker_images.md) +- [Use CI to build Docker images](ci/docker/using_docker_build.md) +- [Use variables in your `.gitlab-ci.yml`](ci/variables/README.md) +- [Use SSH keys in your build environment](ci/ssh_keys/README.md) +- [Trigger builds through the API](ci/triggers/README.md) +- [Build artifacts](ci/build_artifacts/README.md) - [User permissions](ci/permissions/README.md) - [API](ci/api/README.md) -- [Triggering builds through the API](ci/triggers/README.md) -- [Build artifacts](ci/build_artifacts/README.md) -### CI Languages +### CI Examples -- [Testing PHP](ci/languages/php.md) +- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) +- [Test your PHP applications](ci/examples/php.md) +- [Test and deploy Ruby applications to Heroku](ci/examples/test-and-deploy-ruby-application-to-heroku.md) +- [Test and deploy Python applications to Heroku](ci/examples/test-and-deploy-python-application-to-heroku.md) +- [Test Clojure applications](ci/examples/test-clojure-application.md) +- [Using `dpl` as deployment tool](ci/deployment/README.md) +- Help your favorite programming language and GitLab by sending a merge request + with a guide for that language. ### CI Services +GitLab CI uses the `services` keyword to define what docker containers should +be linked with your base image. Below is a list of examples you may use: + - [Using MySQL](ci/services/mysql.md) - [Using PostgreSQL](ci/services/postgres.md) - [Using Redis](ci/services/redis.md) - [Using Other Services](ci/docker/using_docker_images.md#how-to-use-other-images-as-services) -### CI Examples - -- [Test and deploy Ruby applications to Heroku](ci/examples/test-and-deploy-ruby-application-to-heroku.md) -- [Test and deploy Python applications to Heroku](ci/examples/test-and-deploy-python-application-to-heroku.md) -- [Test Clojure applications](ci/examples/test-clojure-application.md) -- Help your favorite programming language and GitLab by sending a merge request with a guide for that language. - ## Administrator documentation - [Custom git hooks](hooks/custom_hooks.md) Custom git hooks (on the filesystem) for when web hooks aren't enough. diff --git a/doc/ci/README.md b/doc/ci/README.md index 5886829be51..2120b5b2850 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -1,39 +1,37 @@ ## GitLab CI Documentation -### User documentation - -* [Quick Start](quick_start/README.md) -* [Enable or disable GitLab CI](enable_or_disable_ci.md) -* [Configuring project (.gitlab-ci.yml)](yaml/README.md) -* [Configuring runner](runners/README.md) -* [Configuring deployment](deployment/README.md) -* [Using Docker Images](docker/using_docker_images.md) -* [Using Docker Build](docker/using_docker_build.md) -* [Using Variables](variables/README.md) -* [Using SSH keys](ssh_keys/README.md) -* [Triggering builds through the API](triggers/README.md) -* [Build artifacts](build_artifacts/README.md) - -### Languages - -* [Testing PHP](languages/php.md) - -### Services - -* [Using MySQL](services/mysql.md) -* [Using PostgreSQL](services/postgres.md) -* [Using Redis](services/redis.md) -* [Using Other Services](docker/using_docker_images.md#how-to-use-other-images-as-services) - -### Examples - -+ [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) -+ [Test and deploy Ruby applications to Heroku](examples/test-and-deploy-ruby-application-to-heroku.md) -+ [Test and deploy Python applications to Heroku](examples/test-and-deploy-python-application-to-heroku.md) -+ [Test Clojure applications](examples/test-clojure-application.md) -+ Help your favorite programming language and GitLab by sending a merge request with a guide for that language. - -### Administrator documentation - -* [User permissions](permissions/README.md) -* [API](api/README.md) +### CI User documentation + +- [Get started with GitLab CI](quick_start/README.md) +- [Learn how to enable or disable GitLab CI](enable_or_disable_ci.md) +- [Learn how `.gitlab-ci.yml` works](yaml/README.md) +- [Configure a Runner, the application that runs your builds](runners/README.md) +- [Use Docker images with GitLab Runner](docker/using_docker_images.md) +- [Use CI to build Docker images](docker/using_docker_build.md) +- [Use variables in your `.gitlab-ci.yml`](variables/README.md) +- [Use SSH keys in your build environment](ssh_keys/README.md) +- [Trigger builds through the API](triggers/README.md) +- [Build artifacts](build_artifacts/README.md) +- [User permissions](permissions/README.md) +- [API](api/README.md) + +### CI Examples + +- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) +- [Test your PHP applications](examples/php.md) +- [Test and deploy Ruby applications to Heroku](examples/test-and-deploy-ruby-application-to-heroku.md) +- [Test and deploy Python applications to Heroku](examples/test-and-deploy-python-application-to-heroku.md) +- [Test Clojure applications](examples/test-clojure-application.md) +- [Using `dpl` as deployment tool](deployment/README.md) +- Help your favorite programming language and GitLab by sending a merge request + with a guide for that language. + +### CI Services + +GitLab CI uses the `services` keyword to define what docker containers should +be linked with your base image. Below is a list of examples you may use: + +- [Using MySQL](services/mysql.md) +- [Using PostgreSQL](services/postgres.md) +- [Using Redis](services/redis.md) +- [Using Other Services](docker/using_docker_images.md#how-to-use-other-images-as-services) diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md index 1cf41aea391..31f29f4a082 100644 --- a/doc/ci/examples/README.md +++ b/doc/ci/examples/README.md @@ -1,5 +1,13 @@ -# Build script examples +## Build script examples -+ [Test and deploy a Ruby application to Heroku](test-and-deploy-ruby-application-to-heroku.md) -+ [Test and deploy a Python application to Heroku](test-and-deploy-python-application-to-heroku.md) -+ [Test a Clojure application](test-clojure-application.md) +- [Test and deploy a Ruby application to Heroku](test-and-deploy-ruby-application-to-heroku.md) +- [Test and deploy a Python application to Heroku](test-and-deploy-python-application-to-heroku.md) +- [Test a Clojure application](test-clojure-application.md) + +## Languages + +This is a list of languages you can test with GitLab CI. Each section has +comprehensive documentation and comes with a test repository hosted on +GitLab.com. + +- [Testing PHP](php.md) diff --git a/doc/ci/languages/php.md b/doc/ci/examples/php.md index aeadd6a448e..aeadd6a448e 100644 --- a/doc/ci/languages/php.md +++ b/doc/ci/examples/php.md diff --git a/doc/ci/languages/README.md b/doc/ci/languages/README.md deleted file mode 100644 index 54b2343e08b..00000000000 --- a/doc/ci/languages/README.md +++ /dev/null @@ -1,7 +0,0 @@ -### Languages - -This is a list of languages you can test with GitLab CI. Each section has -comprehensive documentation and comes with a test repository hosted on -GitLab.com - -+ [Testing PHP](php.md) diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 327c83bef72..5af7be5581e 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -201,6 +201,11 @@ You can access a builds badge image using following link: http://example.gitlab.com/namespace/project/badges/branch/build.svg ``` +## Examples + +Visit the [examples README][examples] to see a list of examples using GitLab +CI with various languages. + ## Next steps Awesome! You started using CI in GitLab! @@ -212,3 +217,4 @@ Visit our various languages examples at <https://gitlab.com/groups/gitlab-exampl [runner-install]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/tree/master#installation [blog-ci]: https://about.gitlab.com/2015/05/06/why-were-replacing-gitlab-ci-jobs-with-gitlab-ci-dot-yml/ +[examples]: ../examples/README.md diff --git a/doc/ci/services/README.md b/doc/ci/services/README.md index 1ebb0a4a250..4b79461d55c 100644 --- a/doc/ci/services/README.md +++ b/doc/ci/services/README.md @@ -1,9 +1,9 @@ ## GitLab CI Services -GitLab CI uses the `services` keyword to define what docker containers should be -linked with your base image. Below is a list of examples you may use. +GitLab CI uses the `services` keyword to define what docker containers should +be linked with your base image. Below is a list of examples you may use. -+ [Using MySQL](mysql.md) -+ [Using PostgreSQL](postgres.md) -+ [Using Redis](redis.md) -+ [Using Other Services](../docker/using_docker_images.md#how-to-use-other-images-as-services) +- [Using MySQL](mysql.md) +- [Using PostgreSQL](postgres.md) +- [Using Redis](redis.md) +- [Using Other Services](../docker/using_docker_images.md#how-to-use-other-images-as-services) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 0edb56dc20e..051eaa04152 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -518,3 +518,10 @@ You can find the link under `/ci/lint` of your gitlab instance. If your commit message contains `[ci skip]`, the commit will be created but the builds will be skipped. + +## Examples + +Visit the [examples README][examples] to see a list of examples using GitLab +CI with various languages. + +[examples]: ../examples/README.md diff --git a/doc/customization/branded_login_page.md b/doc/customization/branded_login_page.md new file mode 100644 index 00000000000..d4d9f5f7b5e --- /dev/null +++ b/doc/customization/branded_login_page.md @@ -0,0 +1,19 @@ +# Changing the appearance of the login page + +GitLab Community Edition offers a way to put your company's identity on the login page of your GitLab server and make it a branded login page. + +By default, the page shows the GitLab logo and description. + +![default_login_page](branded_login_page/default_login_page.png) + +## Changing the appearance of the login page + +Navigate to the **Admin** area and go to the **Appearance** page. + +Fill in the required details like Title, Description and upload the company logo. + +![appearance](branded_login_page/appearance.png) + +After saving the page, your GitLab login page will have the details you filled in: + +![company_login_page](branded_login_page/custom_sign_in.png) diff --git a/doc/customization/branded_login_page/appearance.png b/doc/customization/branded_login_page/appearance.png Binary files differnew file mode 100644 index 00000000000..6bce1f0a287 --- /dev/null +++ b/doc/customization/branded_login_page/appearance.png diff --git a/doc/customization/branded_login_page/custom_sign_in.png b/doc/customization/branded_login_page/custom_sign_in.png Binary files differnew file mode 100644 index 00000000000..d6020b029a2 --- /dev/null +++ b/doc/customization/branded_login_page/custom_sign_in.png diff --git a/doc/customization/branded_login_page/default_login_page.png b/doc/customization/branded_login_page/default_login_page.png Binary files differnew file mode 100644 index 00000000000..795c7954d8e --- /dev/null +++ b/doc/customization/branded_login_page/default_login_page.png diff --git a/doc/customization/welcome_message.md b/doc/customization/welcome_message.md index e993230bb88..a0cb234bea0 100644 --- a/doc/customization/welcome_message.md +++ b/doc/customization/welcome_message.md @@ -1,12 +1,12 @@ -# Customize the complete sign-in page (GitLab Enterprise Edition only) +# Customize the complete sign-in page -Please see [Branded login page](http://doc.gitlab.com/ee/customization/branded_login_page.html) +Please see [Branded login page](branded_login_page.md) # Add a welcome message to the sign-in page (GitLab Community Edition) It is possible to add a markdown-formatted welcome message to your GitLab sign-in page. Users of GitLab Enterprise Edition should use the [branded login -page feature](/ee/customization/branded_login_page.html) instead. +page feature](branded_login_page.md) instead. The welcome message (extra_sign_in_text) can now be set/changed in the Admin UI. -Admin area > Settings
\ No newline at end of file +Admin area > Settings diff --git a/doc/development/README.md b/doc/development/README.md index d5bf166ad32..b9a0d81e5ba 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -1,11 +1,12 @@ # Development - [Architecture](architecture.md) of GitLab -- [Shell commands](shell_commands.md) in the GitLab codebase -- [Rake tasks](rake_tasks.md) for development +- [Benchmarking](benchmarking.md) - [CI setup](ci_setup.md) for testing GitLab +- [Gotchas](gotchas.md) to avoid +- [How to dump production data to staging](db_dump.md) +- [Migration Style Guide](migration_style_guide.md) for creating safe migrations +- [Rake tasks](rake_tasks.md) for development +- [Shell commands](shell_commands.md) in the GitLab codebase - [Sidekiq debugging](sidekiq_debugging.md) - [UI guide](ui_guide.md) for building GitLab with existing css styles and elements -- [Migration Style Guide](migration_style_guide.md) for creating safe migrations -- [How to dump production data to staging](dump_db.md) -- [Benchmarking](benchmarking.md) diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md new file mode 100644 index 00000000000..21078c8d6f9 --- /dev/null +++ b/doc/development/gotchas.md @@ -0,0 +1,103 @@ +# Gotchas + +The purpose of this guide is to document potential "gotchas" that contributors +might encounter or should avoid during development of GitLab CE and EE. + +## Don't `describe` symbols + +Consider the following model spec: + +```ruby +require 'rails_helper' + +describe User do + describe :to_param do + it 'converts the username to a param' do + user = described_class.new(username: 'John Smith') + + expect(user.to_param).to eq 'john-smith' + end + end +end +``` + +When run, this spec doesn't do what we might expect: + +```sh +spec/models/user_spec.rb|6 error| Failure/Error: u = described_class.new NoMethodError: undefined method `new' for :to_param:Symbol +``` + +### Solution + +Except for the top-level `describe` block, always provide a String argument to +`describe`. + +## Don't `rescue Exception` + +See ["Why is it bad style to `rescue Exception => e` in Ruby?"][Exception]. + +_**Note:** This rule is [enforced automatically by +Rubocop](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-4-stable/.rubocop.yml#L911-914)._ + +[Exception]: http://stackoverflow.com/q/10048173/223897 + +## Don't use inline CoffeeScript in views + +Using the inline `:coffee` or `:coffeescript` Haml filters comes with a +performance overhead. + +_**Note:** We've [removed these two filters](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-5-stable/config/initializers/haml.rb) +in an initializer._ + +### Further reading + +- Pull Request: [Replace CoffeeScript block into JavaScript in Views](https://git.io/vztMu) +- Stack Overflow: [Performance implications of using :coffescript filter inside HAML templates?](http://stackoverflow.com/a/17571242/223897) + +## ID-based CSS selectors need to be a bit more specific + +Normally, because HTML `id` attributes need to be unique to the page, it's +perfectly fine to write some JavaScript like the following: + +```javascript +$('#js-my-selector').hide(); +``` + +However, there's a feature of GitLab's Markdown processing that [automatically +adds anchors to header elements][ToC Processing], with the `id` attribute being +automatically generated based on the content of the header. + +Unfortunately, this feature makes it possible for user-generated content to +create a header element with the same `id` attribute we're using in our +selector, potentially breaking the JavaScript behavior. A user could break the +above example with the following Markdown: + +```markdown +## JS My Selector +``` + +Which gets converted to the following HTML: + +```html +<h2> + <a id="js-my-selector" class="anchor" href="#js-my-selector" aria-hidden="true"></a> + JS My Selector +</h2> +``` + +[ToC Processing]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-4-stable/lib/banzai/filter/table_of_contents_filter.rb#L31-37 + +### Solution + +The current recommended fix for this is to make our selectors slightly more +specific: + +```javascript +$('div#js-my-selector').hide(); +``` + +### Further reading + +- Issue: [Merge request ToC anchor conflicts with tabs](https://gitlab.com/gitlab-org/gitlab-ce/issues/3908) +- Merge Request: [Make tab target selectors less naive](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2023) +- Merge Request: [Make cross-project reference's clipboard target less naive](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2024) diff --git a/doc/permissions/permissions.md b/doc/permissions/permissions.md index f717b30c12e..ac0fd3d1756 100644 --- a/doc/permissions/permissions.md +++ b/doc/permissions/permissions.md @@ -6,7 +6,7 @@ If a user is both in a project group and in the project itself, the highest perm If a user is a GitLab administrator they receive all permissions. -On public projects the Guest role is not enforced. +On public and internal projects the Guest role is not enforced. All users will be able to create issues, leave comments, and pull or download the project code. To add or import a user, you can follow the [project users and members diff --git a/features/admin/appearance.feature b/features/admin/appearance.feature new file mode 100644 index 00000000000..5c1dd7531c1 --- /dev/null +++ b/features/admin/appearance.feature @@ -0,0 +1,37 @@ +Feature: Admin Appearance + Scenario: Create new appearance + Given I sign in as an admin + And I visit admin appearance page + When submit form with new appearance + Then I should be redirected to admin appearance page + And I should see newly created appearance + + Scenario: Preview appearance + Given application has custom appearance + And I sign in as an admin + When I visit admin appearance page + And I click preview button + Then I should see a customized appearance + + Scenario: Custom sign-in page + Given application has custom appearance + When I visit login page + Then I should see a customized appearance + + Scenario: Appearance logo + Given application has custom appearance + And I sign in as an admin + And I visit admin appearance page + When I attach a logo + Then I should see a logo + And I remove the logo + Then I should see logo removed + + Scenario: Header logos + Given application has custom appearance + And I sign in as an admin + And I visit admin appearance page + When I attach header logos + Then I should see header logos + And I remove the header logos + Then I should see header logos removed diff --git a/features/project/milestone.feature b/features/project/milestone.feature index e0f4c0e9d7c..713f0f3b979 100644 --- a/features/project/milestone.feature +++ b/features/project/milestone.feature @@ -13,6 +13,7 @@ Feature: Project Milestone Given I visit project "Shop" milestones page And I click link "v2.2" Then I should see the labels "bug", "enhancement" and "feature" + And I should see the "bug" label listed only once @javascript Scenario: Listing labels from labels tab diff --git a/features/steps/admin/appearance.rb b/features/steps/admin/appearance.rb new file mode 100644 index 00000000000..0d1be46d11d --- /dev/null +++ b/features/steps/admin/appearance.rb @@ -0,0 +1,72 @@ +class Spinach::Features::AdminAppearance < Spinach::FeatureSteps + include SharedAuthentication + include SharedPaths + + step 'submit form with new appearance' do + fill_in 'appearance_title', with: 'MyCompany' + fill_in 'appearance_description', with: 'dev server' + click_button 'Save' + end + + step 'I should be redirected to admin appearance page' do + expect(current_path).to eq admin_appearances_path + expect(page).to have_content 'Appearance settings' + end + + step 'I should see newly created appearance' do + expect(page).to have_field('appearance_title', with: 'MyCompany') + expect(page).to have_field('appearance_description', with: 'dev server') + expect(page).to have_content 'Last edit' + end + + step 'I click preview button' do + click_link "Preview" + end + + step 'application has custom appearance' do + create(:appearance) + end + + step 'I should see a customized appearance' do + expect(page).to have_content appearance.title + expect(page).to have_content appearance.description + end + + step 'I attach a logo' do + attach_file(:appearance_logo, Rails.root.join('spec', 'fixtures', 'dk.png')) + click_button 'Save' + end + + step 'I attach header logos' do + attach_file(:appearance_header_logo, Rails.root.join('spec', 'fixtures', 'dk.png')) + click_button 'Save' + end + + step 'I should see a logo' do + expect(page).to have_xpath('//img[@src="/uploads/appearance/logo/1/dk.png"]') + end + + step 'I should see header logos' do + expect(page).to have_xpath('//img[@src="/uploads/appearance/header_logo/1/dk.png"]') + end + + step 'I remove the logo' do + click_link 'Remove logo' + end + + step 'I remove the header logos' do + click_link 'Remove header logo' + end + + step 'I should see logo removed' do + expect(page).not_to have_xpath('//img[@src="/uploads/appearance/logo/1/gitlab_logo.png"]') + end + + step 'I should see header logos removed' do + expect(page).not_to have_xpath('//img[@src="/uploads/appearance/header_logo/1/header_logo_light.png"]') + end + + def appearance + Appearance.last + end +end diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb index 6b0c1049ece..7895f643d0c 100644 --- a/features/steps/profile/profile.rb +++ b/features/steps/profile/profile.rb @@ -27,9 +27,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps end step 'I change my avatar' do - attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')) - click_button "Save changes" - @user.reload + attach_avatar end step 'I should see new avatar' do @@ -42,9 +40,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps end step 'I have an avatar' do - attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')) - click_button "Save changes" - @user.reload + attach_avatar end step 'I remove my avatar' do @@ -233,4 +229,16 @@ class Spinach::Features::Profile < Spinach::FeatureSteps step "I see that application is removed" do expect(page.find(".oauth-applications")).not_to have_content "test_changed" end + + def attach_avatar + attach_file :user_avatar, Rails.root.join(*%w(spec fixtures banana_sample.gif)) + + page.find('#user_avatar_crop_x', visible: false).set('0') + page.find('#user_avatar_crop_y', visible: false).set('0') + page.find('#user_avatar_crop_size', visible: false).set('256') + + click_button "Save changes" + + @user.reload + end end diff --git a/features/steps/project/project_milestone.rb b/features/steps/project/project_milestone.rb index ec881c0d8fc..2508c09e36d 100644 --- a/features/steps/project/project_milestone.rb +++ b/features/steps/project/project_milestone.rb @@ -41,6 +41,12 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps end end + step 'I should see the "bug" label listed only once' do + page.within('#tab-labels') do + expect(page).to have_content('bug', count: 1) + end + end + step 'I click link "v2.2"' do click_link "v2.2" end diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index 0f9835e7356..6432a786bfc 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -7,6 +7,10 @@ module SharedPaths visit new_project_path end + step 'I visit login page' do + visit new_user_session_path + end + # ---------------------------------------- # User # ---------------------------------------- @@ -187,6 +191,10 @@ module SharedPaths visit admin_groups_path end + step 'I visit admin appearance page' do + visit admin_appearances_path + end + step 'I visit admin teams page' do visit admin_teams_path end diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb index 04ddfe53ed6..abd79b329ae 100644 --- a/lib/banzai/filter/sanitization_filter.rb +++ b/lib/banzai/filter/sanitization_filter.rb @@ -7,6 +7,8 @@ module Banzai # # Extends HTML::Pipeline::SanitizationFilter with a custom whitelist. class SanitizationFilter < HTML::Pipeline::SanitizationFilter + UNSAFE_PROTOCOLS = %w(javascript :javascript data vbscript).freeze + def whitelist whitelist = super @@ -43,8 +45,8 @@ module Banzai # Allow any protocol in `a` elements... whitelist[:protocols].delete('a') - # ...but then remove links with the `javascript` protocol - whitelist[:transformers].push(remove_javascript_links) + # ...but then remove links with unsafe protocols + whitelist[:transformers].push(remove_unsafe_links) # Remove `rel` attribute from `a` elements whitelist[:transformers].push(remove_rel) @@ -55,14 +57,14 @@ module Banzai whitelist end - def remove_javascript_links + def remove_unsafe_links lambda do |env| node = env[:node] return unless node.name == 'a' return unless node.has_attribute?('href') - if node['href'].start_with?('javascript', ':javascript') + if node['href'].start_with?(*UNSAFE_PROTOCOLS) node.remove_attribute('href') end end diff --git a/spec/controllers/namespaces_controller_spec.rb b/spec/controllers/namespaces_controller_spec.rb index 77436958711..d4a380cc2ee 100644 --- a/spec/controllers/namespaces_controller_spec.rb +++ b/spec/controllers/namespaces_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe NamespacesController do - let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } + let!(:user) { create(:user, :with_avatar) } describe "GET show" do context "when the namespace belongs to a user" do diff --git a/spec/controllers/profiles/avatars_controller_spec.rb b/spec/controllers/profiles/avatars_controller_spec.rb index ad5855df0a4..85dff009bcf 100644 --- a/spec/controllers/profiles/avatars_controller_spec.rb +++ b/spec/controllers/profiles/avatars_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Profiles::AvatarsController do - let(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png")) } + let(:user) { create(:user, :with_avatar) } before do sign_in(user) diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index af5d043cf02..0d9f4b299bc 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe UploadsController do - let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } + let!(:user) { create(:user, :with_avatar) } describe "GET show" do context "when viewing a user avatar" do diff --git a/spec/factories.rb b/spec/factories.rb index 2a81684dfcf..264e3ed2c8d 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -36,6 +36,13 @@ FactoryGirl.define do end end + trait :with_avatar do + avatar { fixture_file_upload(Rails.root.join(*%w(spec fixtures dk.png)), 'image/png') } + avatar_crop_x 0 + avatar_crop_y 0 + avatar_crop_size 256 + end + factory :omniauth_user do ignore do extern_uid '123456' diff --git a/spec/factories/appearances.rb b/spec/factories/appearances.rb new file mode 100644 index 00000000000..cf2a2b76bcb --- /dev/null +++ b/spec/factories/appearances.rb @@ -0,0 +1,8 @@ +# Read about factories at https://github.com/thoughtbot/factory_girl + +FactoryGirl.define do + factory :appearance do + title "MepMep" + description "This is my Community Edition instance" + end +end diff --git a/spec/features/issues/filter_by_milestone_spec.rb b/spec/features/issues/filter_by_milestone_spec.rb index 38c8d343ce3..591866b40d4 100644 --- a/spec/features/issues/filter_by_milestone_spec.rb +++ b/spec/features/issues/filter_by_milestone_spec.rb @@ -13,7 +13,7 @@ feature 'Issue filtering by Milestone', feature: true do visit_issues(project) filter_by_milestone(Milestone::None.title) - expect(page).to have_css('.title', count: 1) + expect(page).to have_css('.issue .title', count: 1) end scenario 'filters by a specific Milestone', js: true do @@ -23,7 +23,7 @@ feature 'Issue filtering by Milestone', feature: true do visit_issues(project) filter_by_milestone(milestone.title) - expect(page).to have_css('.title', count: 1) + expect(page).to have_css('.issue .title', count: 1) end def visit_issues(project) diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index f6c1005d265..8013b31524f 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -77,7 +77,7 @@ describe ApplicationHelper do let(:avatar_file_path) { File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif') } it 'should return an url for the avatar' do - user = create(:user, avatar: File.open(avatar_file_path)) + user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user.email).to_s). to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") @@ -88,7 +88,7 @@ describe ApplicationHelper do # Must be stubbed after the stub above, and separately stub_config_setting(url: Settings.send(:build_gitlab_url)) - user = create(:user, avatar: File.open(avatar_file_path)) + user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user.email).to_s). to match("/gitlab/uploads/user/avatar/#{user.id}/banana_sample.gif") @@ -102,7 +102,7 @@ describe ApplicationHelper do describe 'using a User' do it 'should return an URL for the avatar' do - user = create(:user, avatar: File.open(avatar_file_path)) + user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user).to_s). to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index e14a6dbf922..4a7b00c7660 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -156,13 +156,27 @@ describe Banzai::Filter::SanitizationFilter, lib: true do } protocols.each do |name, data| - it "handles #{name}" do + it "disallows #{name}" do doc = filter(data[:input]) expect(doc.to_html).to eq data[:output] end end + it 'disallows data links' do + input = '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K">XSS</a>' + output = filter(input) + + expect(output.to_html).to eq '<a>XSS</a>' + end + + it 'disallows vbscript links' do + input = '<a href="vbscript:alert(document.domain)">XSS</a>' + output = filter(input) + + expect(output.to_html).to eq '<a>XSS</a>' + end + it 'allows non-standard anchor schemes' do exp = %q{<a href="irc://irc.freenode.net/git">IRC</a>} act = filter(exp) diff --git a/spec/models/appearance_spec.rb b/spec/models/appearance_spec.rb new file mode 100644 index 00000000000..c5658bd26e1 --- /dev/null +++ b/spec/models/appearance_spec.rb @@ -0,0 +1,10 @@ +require 'rails_helper' + +RSpec.describe Appearance, type: :model do + subject { create(:appearance) } + + it { is_expected.to be_valid } + + it { is_expected.to validate_presence_of(:title) } + it { is_expected.to validate_presence_of(:description) } +end diff --git a/spec/models/hooks/project_hook_spec.rb b/spec/models/hooks/project_hook_spec.rb index 645ee0b929a..983848392b7 100644 --- a/spec/models/hooks/project_hook_spec.rb +++ b/spec/models/hooks/project_hook_spec.rb @@ -19,6 +19,10 @@ require 'spec_helper' describe ProjectHook, models: true do + describe "Associations" do + it { is_expected.to belong_to :project } + end + describe '.push_hooks' do it 'should return hooks for push events only' do hook = create(:project_hook, push_events: true) diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb index 7070aa4ac62..6ea99952a8f 100644 --- a/spec/models/hooks/web_hook_spec.rb +++ b/spec/models/hooks/web_hook_spec.rb @@ -18,20 +18,14 @@ require 'spec_helper' -describe ProjectHook, models: true do - describe "Associations" do - it { is_expected.to belong_to :project } - end - - describe "Mass assignment" do - end - +describe WebHook, models: true do describe "Validations" do it { is_expected.to validate_presence_of(:url) } - context "url format" do + describe 'url' do it { is_expected.to allow_value("http://example.com").for(:url) } - it { is_expected.to allow_value("https://excample.com").for(:url) } + it { is_expected.to allow_value("https://example.com").for(:url) } + it { is_expected.to allow_value(" https://example.com ").for(:url) } it { is_expected.to allow_value("http://test.com/api").for(:url) } it { is_expected.to allow_value("http://test.com/api?key=abc").for(:url) } it { is_expected.to allow_value("http://test.com/api?key=abc&type=def").for(:url) } @@ -39,6 +33,12 @@ describe ProjectHook, models: true do it { is_expected.not_to allow_value("example.com").for(:url) } it { is_expected.not_to allow_value("ftp://example.com").for(:url) } it { is_expected.not_to allow_value("herp-and-derp").for(:url) } + + it 'strips :url before saving it' do + hook = create(:project_hook, url: ' https://example.com ') + + expect(hook.url).to eq('https://example.com') + end end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index b596782f4e1..51ae2c04ed0 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -362,14 +362,14 @@ describe Repository, models: true do repository.expire_cache('master') end - it 'expires the emptiness cache for an empty repository' do + it 'expires the emptiness caches for an empty repository' do expect(repository).to receive(:empty?).and_return(true) expect(repository).to receive(:expire_emptiness_caches) repository.expire_cache end - it 'does not expire the emptiness cache for a non-empty repository' do + it 'does not expire the emptiness caches for a non-empty repository' do expect(repository).to receive(:empty?).and_return(false) expect(repository).to_not receive(:expire_emptiness_caches) @@ -464,4 +464,108 @@ describe Repository, models: true do expect(repository.blob_at_branch('master', 'files/ruby/feature.rb')).not_to be_present end end + + describe '#before_delete' do + describe 'when a repository does not exist' do + before do + allow(repository).to receive(:exists?).and_return(false) + end + + it 'does not flush caches that depend on repository data' do + expect(repository).to_not receive(:expire_cache) + + repository.before_delete + end + + it 'flushes the root ref cache' do + expect(repository).to receive(:expire_root_ref_cache) + + repository.before_delete + end + + it 'flushes the emptiness caches' do + expect(repository).to receive(:expire_emptiness_caches) + + repository.before_delete + end + end + + describe 'when a repository exists' do + before do + allow(repository).to receive(:exists?).and_return(true) + end + + it 'flushes the caches that depend on repository data' do + expect(repository).to receive(:expire_cache) + + repository.before_delete + end + + it 'flushes the root ref cache' do + expect(repository).to receive(:expire_root_ref_cache) + + repository.before_delete + end + + it 'flushes the emptiness caches' do + expect(repository).to receive(:expire_emptiness_caches) + + repository.before_delete + end + end + end + + describe '#before_change_head' do + it 'flushes the branch cache' do + expect(repository).to receive(:expire_branch_cache) + + repository.before_change_head + end + + it 'flushes the root ref cache' do + expect(repository).to receive(:expire_root_ref_cache) + + repository.before_change_head + end + end + + describe '#before_create_tag' do + it 'flushes the cache' do + expect(repository).to receive(:expire_cache) + + repository.before_create_tag + end + end + + describe '#after_import' do + it 'flushes the emptiness cachess' do + expect(repository).to receive(:expire_emptiness_caches) + + repository.after_import + end + end + + describe '#after_push_commit' do + it 'flushes the cache' do + expect(repository).to receive(:expire_cache).with('master') + + repository.after_push_commit('master') + end + end + + describe '#after_create_branch' do + it 'flushes the visible content cache' do + expect(repository).to receive(:expire_has_visible_content_cache) + + repository.after_create_branch + end + end + + describe '#after_remove_branch' do + it 'flushes the visible content cache' do + expect(repository).to receive(:expire_has_visible_content_cache) + + repository.after_remove_branch + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 32d4f39b04a..88821dd0dad 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -174,6 +174,18 @@ describe User, models: true do end end end + + describe 'avatar' do + it 'only validates when avatar is present' do + user = build(:user, :with_avatar) + + user.avatar_crop_x = nil + user.avatar_crop_y = nil + user.avatar_crop_size = nil + + expect(user).not_to be_valid + end + end end describe "Respond to" do diff --git a/vendor/assets/javascripts/cropper.js b/vendor/assets/javascripts/cropper.js new file mode 100755 index 00000000000..84aa6119ec3 --- /dev/null +++ b/vendor/assets/javascripts/cropper.js @@ -0,0 +1,2972 @@ +/*! + * Cropper v2.2.5 + * https://github.com/fengyuanchen/cropper + * + * Copyright (c) 2014-2016 Fengyuan Chen and contributors + * Released under the MIT license + * + * Date: 2016-01-18T05:42:50.800Z + */ + +(function (factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as anonymous module. + define(['jquery'], factory); + } else if (typeof exports === 'object') { + // Node / CommonJS + factory(require('jquery')); + } else { + // Browser globals. + factory(jQuery); + } +})(function ($) { + + 'use strict'; + + // Globals + var $window = $(window); + var $document = $(document); + var location = window.location; + var ArrayBuffer = window.ArrayBuffer; + var Uint8Array = window.Uint8Array; + var DataView = window.DataView; + var btoa = window.btoa; + + // Constants + var NAMESPACE = 'cropper'; + + // Classes + var CLASS_MODAL = 'cropper-modal'; + var CLASS_HIDE = 'cropper-hide'; + var CLASS_HIDDEN = 'cropper-hidden'; + var CLASS_INVISIBLE = 'cropper-invisible'; + var CLASS_MOVE = 'cropper-move'; + var CLASS_CROP = 'cropper-crop'; + var CLASS_DISABLED = 'cropper-disabled'; + var CLASS_BG = 'cropper-bg'; + + // Events + var EVENT_MOUSE_DOWN = 'mousedown touchstart pointerdown MSPointerDown'; + var EVENT_MOUSE_MOVE = 'mousemove touchmove pointermove MSPointerMove'; + var EVENT_MOUSE_UP = 'mouseup touchend touchcancel pointerup pointercancel MSPointerUp MSPointerCancel'; + var EVENT_WHEEL = 'wheel mousewheel DOMMouseScroll'; + var EVENT_DBLCLICK = 'dblclick'; + var EVENT_LOAD = 'load.' + NAMESPACE; + var EVENT_ERROR = 'error.' + NAMESPACE; + var EVENT_RESIZE = 'resize.' + NAMESPACE; // Bind to window with namespace + var EVENT_BUILD = 'build.' + NAMESPACE; + var EVENT_BUILT = 'built.' + NAMESPACE; + var EVENT_CROP_START = 'cropstart.' + NAMESPACE; + var EVENT_CROP_MOVE = 'cropmove.' + NAMESPACE; + var EVENT_CROP_END = 'cropend.' + NAMESPACE; + var EVENT_CROP = 'crop.' + NAMESPACE; + var EVENT_ZOOM = 'zoom.' + NAMESPACE; + + // RegExps + var REGEXP_ACTIONS = /e|w|s|n|se|sw|ne|nw|all|crop|move|zoom/; + var REGEXP_DATA_URL = /^data\:/; + var REGEXP_DATA_URL_HEAD = /^data\:([^\;]+)\;base64,/; + var REGEXP_DATA_URL_JPEG = /^data\:image\/jpeg.*;base64,/; + + // Data keys + var DATA_PREVIEW = 'preview'; + var DATA_ACTION = 'action'; + + // Actions + var ACTION_EAST = 'e'; + var ACTION_WEST = 'w'; + var ACTION_SOUTH = 's'; + var ACTION_NORTH = 'n'; + var ACTION_SOUTH_EAST = 'se'; + var ACTION_SOUTH_WEST = 'sw'; + var ACTION_NORTH_EAST = 'ne'; + var ACTION_NORTH_WEST = 'nw'; + var ACTION_ALL = 'all'; + var ACTION_CROP = 'crop'; + var ACTION_MOVE = 'move'; + var ACTION_ZOOM = 'zoom'; + var ACTION_NONE = 'none'; + + // Supports + var SUPPORT_CANVAS = $.isFunction($('<canvas>')[0].getContext); + + // Maths + var num = Number; + var min = Math.min; + var max = Math.max; + var abs = Math.abs; + var sin = Math.sin; + var cos = Math.cos; + var sqrt = Math.sqrt; + var round = Math.round; + var floor = Math.floor; + + // Utilities + var fromCharCode = String.fromCharCode; + + function isNumber(n) { + return typeof n === 'number' && !isNaN(n); + } + + function isUndefined(n) { + return typeof n === 'undefined'; + } + + function toArray(obj, offset) { + var args = []; + + // This is necessary for IE8 + if (isNumber(offset)) { + args.push(offset); + } + + return args.slice.apply(obj, args); + } + + // Custom proxy to avoid jQuery's guid + function proxy(fn, context) { + var args = toArray(arguments, 2); + + return function () { + return fn.apply(context, args.concat(toArray(arguments))); + }; + } + + function isCrossOriginURL(url) { + var parts = url.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i); + + return parts && ( + parts[1] !== location.protocol || + parts[2] !== location.hostname || + parts[3] !== location.port + ); + } + + function addTimestamp(url) { + var timestamp = 'timestamp=' + (new Date()).getTime(); + + return (url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp); + } + + function getCrossOrigin(crossOrigin) { + return crossOrigin ? ' crossOrigin="' + crossOrigin + '"' : ''; + } + + function getImageSize(image, callback) { + var newImage; + + // Modern browsers + if (image.naturalWidth) { + return callback(image.naturalWidth, image.naturalHeight); + } + + // IE8: Don't use `new Image()` here (#319) + newImage = document.createElement('img'); + + newImage.onload = function () { + callback(this.width, this.height); + }; + + newImage.src = image.src; + } + + function getTransform(options) { + var transforms = []; + var rotate = options.rotate; + var scaleX = options.scaleX; + var scaleY = options.scaleY; + + if (isNumber(rotate)) { + transforms.push('rotate(' + rotate + 'deg)'); + } + + if (isNumber(scaleX) && isNumber(scaleY)) { + transforms.push('scale(' + scaleX + ',' + scaleY + ')'); + } + + return transforms.length ? transforms.join(' ') : 'none'; + } + + function getRotatedSizes(data, isReversed) { + var deg = abs(data.degree) % 180; + var arc = (deg > 90 ? (180 - deg) : deg) * Math.PI / 180; + var sinArc = sin(arc); + var cosArc = cos(arc); + var width = data.width; + var height = data.height; + var aspectRatio = data.aspectRatio; + var newWidth; + var newHeight; + + if (!isReversed) { + newWidth = width * cosArc + height * sinArc; + newHeight = width * sinArc + height * cosArc; + } else { + newWidth = width / (cosArc + sinArc / aspectRatio); + newHeight = newWidth / aspectRatio; + } + + return { + width: newWidth, + height: newHeight + }; + } + + function getSourceCanvas(image, data) { + var canvas = $('<canvas>')[0]; + var context = canvas.getContext('2d'); + var x = 0; + var y = 0; + var width = data.naturalWidth; + var height = data.naturalHeight; + var rotate = data.rotate; + var scaleX = data.scaleX; + var scaleY = data.scaleY; + var scalable = isNumber(scaleX) && isNumber(scaleY) && (scaleX !== 1 || scaleY !== 1); + var rotatable = isNumber(rotate) && rotate !== 0; + var advanced = rotatable || scalable; + var canvasWidth = width; + var canvasHeight = height; + var translateX; + var translateY; + var rotated; + + if (scalable) { + translateX = width / 2; + translateY = height / 2; + } + + if (rotatable) { + rotated = getRotatedSizes({ + width: width, + height: height, + degree: rotate + }); + + canvasWidth = rotated.width; + canvasHeight = rotated.height; + translateX = rotated.width / 2; + translateY = rotated.height / 2; + } + + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + if (advanced) { + x = -width / 2; + y = -height / 2; + + context.save(); + context.translate(translateX, translateY); + } + + if (rotatable) { + context.rotate(rotate * Math.PI / 180); + } + + // Should call `scale` after rotated + if (scalable) { + context.scale(scaleX, scaleY); + } + + context.drawImage(image, floor(x), floor(y), floor(width), floor(height)); + + if (advanced) { + context.restore(); + } + + return canvas; + } + + function getTouchesCenter(touches) { + var length = touches.length; + var pageX = 0; + var pageY = 0; + + if (length) { + $.each(touches, function (i, touch) { + pageX += touch.pageX; + pageY += touch.pageY; + }); + + pageX /= length; + pageY /= length; + } + + return { + pageX: pageX, + pageY: pageY + }; + } + + function getStringFromCharCode(dataView, start, length) { + var str = ''; + var i; + + for (i = start, length += start; i < length; i++) { + str += fromCharCode(dataView.getUint8(i)); + } + + return str; + } + + function getOrientation(arrayBuffer) { + var dataView = new DataView(arrayBuffer); + var length = dataView.byteLength; + var orientation; + var exifIDCode; + var tiffOffset; + var firstIFDOffset; + var littleEndian; + var endianness; + var app1Start; + var ifdStart; + var offset; + var i; + + // Only handle JPEG image (start by 0xFFD8) + if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) { + offset = 2; + + while (offset < length) { + if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) { + app1Start = offset; + break; + } + + offset++; + } + } + + if (app1Start) { + exifIDCode = app1Start + 4; + tiffOffset = app1Start + 10; + + if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') { + endianness = dataView.getUint16(tiffOffset); + littleEndian = endianness === 0x4949; + + if (littleEndian || endianness === 0x4D4D /* bigEndian */) { + if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) { + firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian); + + if (firstIFDOffset >= 0x00000008) { + ifdStart = tiffOffset + firstIFDOffset; + } + } + } + } + } + + if (ifdStart) { + length = dataView.getUint16(ifdStart, littleEndian); + + for (i = 0; i < length; i++) { + offset = ifdStart + i * 12 + 2; + + if (dataView.getUint16(offset, littleEndian) === 0x0112 /* Orientation */) { + + // 8 is the offset of the current tag's value + offset += 8; + + // Get the original orientation value + orientation = dataView.getUint16(offset, littleEndian); + + // Override the orientation with the default value: 1 + dataView.setUint16(offset, 1, littleEndian); + break; + } + } + } + + return orientation; + } + + function dataURLToArrayBuffer(dataURL) { + var base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, ''); + var binary = atob(base64); + var length = binary.length; + var arrayBuffer = new ArrayBuffer(length); + var dataView = new Uint8Array(arrayBuffer); + var i; + + for (i = 0; i < length; i++) { + dataView[i] = binary.charCodeAt(i); + } + + return arrayBuffer; + } + + // Only available for JPEG image + function arrayBufferToDataURL(arrayBuffer) { + var dataView = new Uint8Array(arrayBuffer); + var length = dataView.length; + var base64 = ''; + var i; + + for (i = 0; i < length; i++) { + base64 += fromCharCode(dataView[i]); + } + + return 'data:image/jpeg;base64,' + btoa(base64); + } + + function Cropper(element, options) { + this.$element = $(element); + this.options = $.extend({}, Cropper.DEFAULTS, $.isPlainObject(options) && options); + this.isLoaded = false; + this.isBuilt = false; + this.isCompleted = false; + this.isRotated = false; + this.isCropped = false; + this.isDisabled = false; + this.isReplaced = false; + this.isLimited = false; + this.wheeling = false; + this.isImg = false; + this.originalUrl = ''; + this.canvas = null; + this.cropBox = null; + this.init(); + } + + Cropper.prototype = { + constructor: Cropper, + + init: function () { + var $this = this.$element; + var url; + + if ($this.is('img')) { + this.isImg = true; + + // Should use `$.fn.attr` here. e.g.: "img/picture.jpg" + this.originalUrl = url = $this.attr('src'); + + // Stop when it's a blank image + if (!url) { + return; + } + + // Should use `$.fn.prop` here. e.g.: "http://example.com/img/picture.jpg" + url = $this.prop('src'); + } else if ($this.is('canvas') && SUPPORT_CANVAS) { + url = $this[0].toDataURL(); + } + + this.load(url); + }, + + // A shortcut for triggering custom events + trigger: function (type, data) { + var e = $.Event(type, data); + + this.$element.trigger(e); + + return e; + }, + + load: function (url) { + var options = this.options; + var $this = this.$element; + var read; + var xhr; + + if (!url) { + return; + } + + // Trigger build event first + $this.one(EVENT_BUILD, options.build); + + if (this.trigger(EVENT_BUILD).isDefaultPrevented()) { + return; + } + + this.url = url; + this.image = {}; + + if (!options.checkOrientation || !ArrayBuffer) { + return this.clone(); + } + + read = $.proxy(this.read, this); + + // XMLHttpRequest disallows to open a Data URL in some browsers like IE11 and Safari + if (REGEXP_DATA_URL.test(url)) { + return REGEXP_DATA_URL_JPEG.test(url) ? + read(dataURLToArrayBuffer(url)) : + this.clone(); + } + + xhr = new XMLHttpRequest(); + + xhr.onerror = xhr.onabort = $.proxy(function () { + this.clone(); + }, this); + + xhr.onload = function () { + read(this.response); + }; + + xhr.open('get', url); + xhr.responseType = 'arraybuffer'; + xhr.send(); + }, + + read: function (arrayBuffer) { + var options = this.options; + var orientation = getOrientation(arrayBuffer); + var image = this.image; + var rotate; + var scaleX; + var scaleY; + + if (orientation > 1) { + this.url = arrayBufferToDataURL(arrayBuffer); + + switch (orientation) { + + // flip horizontal + case 2: + scaleX = -1; + break; + + // rotate left 180° + case 3: + rotate = -180; + break; + + // flip vertical + case 4: + scaleY = -1; + break; + + // flip vertical + rotate right 90° + case 5: + rotate = 90; + scaleY = -1; + break; + + // rotate right 90° + case 6: + rotate = 90; + break; + + // flip horizontal + rotate right 90° + case 7: + rotate = 90; + scaleX = -1; + break; + + // rotate left 90° + case 8: + rotate = -90; + break; + } + } + + if (options.rotatable) { + image.rotate = rotate; + } + + if (options.scalable) { + image.scaleX = scaleX; + image.scaleY = scaleY; + } + + this.clone(); + }, + + clone: function () { + var options = this.options; + var $this = this.$element; + var url = this.url; + var crossOrigin = ''; + var crossOriginUrl; + var $clone; + + if (options.checkCrossOrigin && isCrossOriginURL(url)) { + crossOrigin = $this.prop('crossOrigin'); + + if (crossOrigin) { + crossOriginUrl = url; + } else { + crossOrigin = 'anonymous'; + + // Bust cache (#148) when there is not a "crossOrigin" property + crossOriginUrl = addTimestamp(url); + } + } + + this.crossOrigin = crossOrigin; + this.crossOriginUrl = crossOriginUrl; + this.$clone = $clone = $('<img' + getCrossOrigin(crossOrigin) + ' src="' + (crossOriginUrl || url) + '">'); + + if (this.isImg) { + if ($this[0].complete) { + this.start(); + } else { + $this.one(EVENT_LOAD, $.proxy(this.start, this)); + } + } else { + $clone. + one(EVENT_LOAD, $.proxy(this.start, this)). + one(EVENT_ERROR, $.proxy(this.stop, this)). + addClass(CLASS_HIDE). + insertAfter($this); + } + }, + + start: function () { + var $image = this.$element; + var $clone = this.$clone; + + if (!this.isImg) { + $clone.off(EVENT_ERROR, this.stop); + $image = $clone; + } + + getImageSize($image[0], $.proxy(function (naturalWidth, naturalHeight) { + $.extend(this.image, { + naturalWidth: naturalWidth, + naturalHeight: naturalHeight, + aspectRatio: naturalWidth / naturalHeight + }); + + this.isLoaded = true; + this.build(); + }, this)); + }, + + stop: function () { + this.$clone.remove(); + this.$clone = null; + }, + + build: function () { + var options = this.options; + var $this = this.$element; + var $clone = this.$clone; + var $cropper; + var $cropBox; + var $face; + + if (!this.isLoaded) { + return; + } + + // Unbuild first when replace + if (this.isBuilt) { + this.unbuild(); + } + + // Create cropper elements + this.$container = $this.parent(); + this.$cropper = $cropper = $(Cropper.TEMPLATE); + this.$canvas = $cropper.find('.cropper-canvas').append($clone); + this.$dragBox = $cropper.find('.cropper-drag-box'); + this.$cropBox = $cropBox = $cropper.find('.cropper-crop-box'); + this.$viewBox = $cropper.find('.cropper-view-box'); + this.$face = $face = $cropBox.find('.cropper-face'); + + // Hide the original image + $this.addClass(CLASS_HIDDEN).after($cropper); + + // Show the clone image if is hidden + if (!this.isImg) { + $clone.removeClass(CLASS_HIDE); + } + + this.initPreview(); + this.bind(); + + options.aspectRatio = max(0, options.aspectRatio) || NaN; + options.viewMode = max(0, min(3, round(options.viewMode))) || 0; + + if (options.autoCrop) { + this.isCropped = true; + + if (options.modal) { + this.$dragBox.addClass(CLASS_MODAL); + } + } else { + $cropBox.addClass(CLASS_HIDDEN); + } + + if (!options.guides) { + $cropBox.find('.cropper-dashed').addClass(CLASS_HIDDEN); + } + + if (!options.center) { + $cropBox.find('.cropper-center').addClass(CLASS_HIDDEN); + } + + if (options.cropBoxMovable) { + $face.addClass(CLASS_MOVE).data(DATA_ACTION, ACTION_ALL); + } + + if (!options.highlight) { + $face.addClass(CLASS_INVISIBLE); + } + + if (options.background) { + $cropper.addClass(CLASS_BG); + } + + if (!options.cropBoxResizable) { + $cropBox.find('.cropper-line, .cropper-point').addClass(CLASS_HIDDEN); + } + + this.setDragMode(options.dragMode); + this.render(); + this.isBuilt = true; + this.setData(options.data); + $this.one(EVENT_BUILT, options.built); + + // Trigger the built event asynchronously to keep `data('cropper')` is defined + setTimeout($.proxy(function () { + this.trigger(EVENT_BUILT); + this.isCompleted = true; + }, this), 0); + }, + + unbuild: function () { + if (!this.isBuilt) { + return; + } + + this.isBuilt = false; + this.isCompleted = false; + this.initialImage = null; + + // Clear `initialCanvas` is necessary when replace + this.initialCanvas = null; + this.initialCropBox = null; + this.container = null; + this.canvas = null; + + // Clear `cropBox` is necessary when replace + this.cropBox = null; + this.unbind(); + + this.resetPreview(); + this.$preview = null; + + this.$viewBox = null; + this.$cropBox = null; + this.$dragBox = null; + this.$canvas = null; + this.$container = null; + + this.$cropper.remove(); + this.$cropper = null; + }, + + render: function () { + this.initContainer(); + this.initCanvas(); + this.initCropBox(); + + this.renderCanvas(); + + if (this.isCropped) { + this.renderCropBox(); + } + }, + + initContainer: function () { + var options = this.options; + var $this = this.$element; + var $container = this.$container; + var $cropper = this.$cropper; + + $cropper.addClass(CLASS_HIDDEN); + $this.removeClass(CLASS_HIDDEN); + + $cropper.css((this.container = { + width: max($container.width(), num(options.minContainerWidth) || 200), + height: max($container.height(), num(options.minContainerHeight) || 100) + })); + + $this.addClass(CLASS_HIDDEN); + $cropper.removeClass(CLASS_HIDDEN); + }, + + // Canvas (image wrapper) + initCanvas: function () { + var viewMode = this.options.viewMode; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var image = this.image; + var imageNaturalWidth = image.naturalWidth; + var imageNaturalHeight = image.naturalHeight; + var is90Degree = abs(image.rotate) === 90; + var naturalWidth = is90Degree ? imageNaturalHeight : imageNaturalWidth; + var naturalHeight = is90Degree ? imageNaturalWidth : imageNaturalHeight; + var aspectRatio = naturalWidth / naturalHeight; + var canvasWidth = containerWidth; + var canvasHeight = containerHeight; + var canvas; + + if (containerHeight * aspectRatio > containerWidth) { + if (viewMode === 3) { + canvasWidth = containerHeight * aspectRatio; + } else { + canvasHeight = containerWidth / aspectRatio; + } + } else { + if (viewMode === 3) { + canvasHeight = containerWidth / aspectRatio; + } else { + canvasWidth = containerHeight * aspectRatio; + } + } + + canvas = { + naturalWidth: naturalWidth, + naturalHeight: naturalHeight, + aspectRatio: aspectRatio, + width: canvasWidth, + height: canvasHeight + }; + + canvas.oldLeft = canvas.left = (containerWidth - canvasWidth) / 2; + canvas.oldTop = canvas.top = (containerHeight - canvasHeight) / 2; + + this.canvas = canvas; + this.isLimited = (viewMode === 1 || viewMode === 2); + this.limitCanvas(true, true); + this.initialImage = $.extend({}, image); + this.initialCanvas = $.extend({}, canvas); + }, + + limitCanvas: function (isSizeLimited, isPositionLimited) { + var options = this.options; + var viewMode = options.viewMode; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var canvas = this.canvas; + var aspectRatio = canvas.aspectRatio; + var cropBox = this.cropBox; + var isCropped = this.isCropped && cropBox; + var minCanvasWidth; + var minCanvasHeight; + var newCanvasLeft; + var newCanvasTop; + + if (isSizeLimited) { + minCanvasWidth = num(options.minCanvasWidth) || 0; + minCanvasHeight = num(options.minCanvasHeight) || 0; + + if (viewMode) { + if (viewMode > 1) { + minCanvasWidth = max(minCanvasWidth, containerWidth); + minCanvasHeight = max(minCanvasHeight, containerHeight); + + if (viewMode === 3) { + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } else { + minCanvasHeight = minCanvasWidth / aspectRatio; + } + } + } else { + if (minCanvasWidth) { + minCanvasWidth = max(minCanvasWidth, isCropped ? cropBox.width : 0); + } else if (minCanvasHeight) { + minCanvasHeight = max(minCanvasHeight, isCropped ? cropBox.height : 0); + } else if (isCropped) { + minCanvasWidth = cropBox.width; + minCanvasHeight = cropBox.height; + + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } else { + minCanvasHeight = minCanvasWidth / aspectRatio; + } + } + } + } + + if (minCanvasWidth && minCanvasHeight) { + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasHeight = minCanvasWidth / aspectRatio; + } else { + minCanvasWidth = minCanvasHeight * aspectRatio; + } + } else if (minCanvasWidth) { + minCanvasHeight = minCanvasWidth / aspectRatio; + } else if (minCanvasHeight) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } + + canvas.minWidth = minCanvasWidth; + canvas.minHeight = minCanvasHeight; + canvas.maxWidth = Infinity; + canvas.maxHeight = Infinity; + } + + if (isPositionLimited) { + if (viewMode) { + newCanvasLeft = containerWidth - canvas.width; + newCanvasTop = containerHeight - canvas.height; + + canvas.minLeft = min(0, newCanvasLeft); + canvas.minTop = min(0, newCanvasTop); + canvas.maxLeft = max(0, newCanvasLeft); + canvas.maxTop = max(0, newCanvasTop); + + if (isCropped && this.isLimited) { + canvas.minLeft = min( + cropBox.left, + cropBox.left + cropBox.width - canvas.width + ); + canvas.minTop = min( + cropBox.top, + cropBox.top + cropBox.height - canvas.height + ); + canvas.maxLeft = cropBox.left; + canvas.maxTop = cropBox.top; + + if (viewMode === 2) { + if (canvas.width >= containerWidth) { + canvas.minLeft = min(0, newCanvasLeft); + canvas.maxLeft = max(0, newCanvasLeft); + } + + if (canvas.height >= containerHeight) { + canvas.minTop = min(0, newCanvasTop); + canvas.maxTop = max(0, newCanvasTop); + } + } + } + } else { + canvas.minLeft = -canvas.width; + canvas.minTop = -canvas.height; + canvas.maxLeft = containerWidth; + canvas.maxTop = containerHeight; + } + } + }, + + renderCanvas: function (isChanged) { + var canvas = this.canvas; + var image = this.image; + var rotate = image.rotate; + var naturalWidth = image.naturalWidth; + var naturalHeight = image.naturalHeight; + var aspectRatio; + var rotated; + + if (this.isRotated) { + this.isRotated = false; + + // Computes rotated sizes with image sizes + rotated = getRotatedSizes({ + width: image.width, + height: image.height, + degree: rotate + }); + + aspectRatio = rotated.width / rotated.height; + + if (aspectRatio !== canvas.aspectRatio) { + canvas.left -= (rotated.width - canvas.width) / 2; + canvas.top -= (rotated.height - canvas.height) / 2; + canvas.width = rotated.width; + canvas.height = rotated.height; + canvas.aspectRatio = aspectRatio; + canvas.naturalWidth = naturalWidth; + canvas.naturalHeight = naturalHeight; + + // Computes rotated sizes with natural image sizes + if (rotate % 180) { + rotated = getRotatedSizes({ + width: naturalWidth, + height: naturalHeight, + degree: rotate + }); + + canvas.naturalWidth = rotated.width; + canvas.naturalHeight = rotated.height; + } + + this.limitCanvas(true, false); + } + } + + if (canvas.width > canvas.maxWidth || canvas.width < canvas.minWidth) { + canvas.left = canvas.oldLeft; + } + + if (canvas.height > canvas.maxHeight || canvas.height < canvas.minHeight) { + canvas.top = canvas.oldTop; + } + + canvas.width = min(max(canvas.width, canvas.minWidth), canvas.maxWidth); + canvas.height = min(max(canvas.height, canvas.minHeight), canvas.maxHeight); + + this.limitCanvas(false, true); + + canvas.oldLeft = canvas.left = min(max(canvas.left, canvas.minLeft), canvas.maxLeft); + canvas.oldTop = canvas.top = min(max(canvas.top, canvas.minTop), canvas.maxTop); + + this.$canvas.css({ + width: canvas.width, + height: canvas.height, + left: canvas.left, + top: canvas.top + }); + + this.renderImage(); + + if (this.isCropped && this.isLimited) { + this.limitCropBox(true, true); + } + + if (isChanged) { + this.output(); + } + }, + + renderImage: function (isChanged) { + var canvas = this.canvas; + var image = this.image; + var reversed; + + if (image.rotate) { + reversed = getRotatedSizes({ + width: canvas.width, + height: canvas.height, + degree: image.rotate, + aspectRatio: image.aspectRatio + }, true); + } + + $.extend(image, reversed ? { + width: reversed.width, + height: reversed.height, + left: (canvas.width - reversed.width) / 2, + top: (canvas.height - reversed.height) / 2 + } : { + width: canvas.width, + height: canvas.height, + left: 0, + top: 0 + }); + + this.$clone.css({ + width: image.width, + height: image.height, + marginLeft: image.left, + marginTop: image.top, + transform: getTransform(image) + }); + + if (isChanged) { + this.output(); + } + }, + + initCropBox: function () { + var options = this.options; + var canvas = this.canvas; + var aspectRatio = options.aspectRatio; + var autoCropArea = num(options.autoCropArea) || 0.8; + var cropBox = { + width: canvas.width, + height: canvas.height + }; + + if (aspectRatio) { + if (canvas.height * aspectRatio > canvas.width) { + cropBox.height = cropBox.width / aspectRatio; + } else { + cropBox.width = cropBox.height * aspectRatio; + } + } + + this.cropBox = cropBox; + this.limitCropBox(true, true); + + // Initialize auto crop area + cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth); + cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight); + + // The width of auto crop area must large than "minWidth", and the height too. (#164) + cropBox.width = max(cropBox.minWidth, cropBox.width * autoCropArea); + cropBox.height = max(cropBox.minHeight, cropBox.height * autoCropArea); + cropBox.oldLeft = cropBox.left = canvas.left + (canvas.width - cropBox.width) / 2; + cropBox.oldTop = cropBox.top = canvas.top + (canvas.height - cropBox.height) / 2; + + this.initialCropBox = $.extend({}, cropBox); + }, + + limitCropBox: function (isSizeLimited, isPositionLimited) { + var options = this.options; + var aspectRatio = options.aspectRatio; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var canvas = this.canvas; + var cropBox = this.cropBox; + var isLimited = this.isLimited; + var minCropBoxWidth; + var minCropBoxHeight; + var maxCropBoxWidth; + var maxCropBoxHeight; + + if (isSizeLimited) { + minCropBoxWidth = num(options.minCropBoxWidth) || 0; + minCropBoxHeight = num(options.minCropBoxHeight) || 0; + + // The min/maxCropBoxWidth/Height must be less than containerWidth/Height + minCropBoxWidth = min(minCropBoxWidth, containerWidth); + minCropBoxHeight = min(minCropBoxHeight, containerHeight); + maxCropBoxWidth = min(containerWidth, isLimited ? canvas.width : containerWidth); + maxCropBoxHeight = min(containerHeight, isLimited ? canvas.height : containerHeight); + + if (aspectRatio) { + if (minCropBoxWidth && minCropBoxHeight) { + if (minCropBoxHeight * aspectRatio > minCropBoxWidth) { + minCropBoxHeight = minCropBoxWidth / aspectRatio; + } else { + minCropBoxWidth = minCropBoxHeight * aspectRatio; + } + } else if (minCropBoxWidth) { + minCropBoxHeight = minCropBoxWidth / aspectRatio; + } else if (minCropBoxHeight) { + minCropBoxWidth = minCropBoxHeight * aspectRatio; + } + + if (maxCropBoxHeight * aspectRatio > maxCropBoxWidth) { + maxCropBoxHeight = maxCropBoxWidth / aspectRatio; + } else { + maxCropBoxWidth = maxCropBoxHeight * aspectRatio; + } + } + + // The minWidth/Height must be less than maxWidth/Height + cropBox.minWidth = min(minCropBoxWidth, maxCropBoxWidth); + cropBox.minHeight = min(minCropBoxHeight, maxCropBoxHeight); + cropBox.maxWidth = maxCropBoxWidth; + cropBox.maxHeight = maxCropBoxHeight; + } + + if (isPositionLimited) { + if (isLimited) { + cropBox.minLeft = max(0, canvas.left); + cropBox.minTop = max(0, canvas.top); + cropBox.maxLeft = min(containerWidth, canvas.left + canvas.width) - cropBox.width; + cropBox.maxTop = min(containerHeight, canvas.top + canvas.height) - cropBox.height; + } else { + cropBox.minLeft = 0; + cropBox.minTop = 0; + cropBox.maxLeft = containerWidth - cropBox.width; + cropBox.maxTop = containerHeight - cropBox.height; + } + } + }, + + renderCropBox: function () { + var options = this.options; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var cropBox = this.cropBox; + + if (cropBox.width > cropBox.maxWidth || cropBox.width < cropBox.minWidth) { + cropBox.left = cropBox.oldLeft; + } + + if (cropBox.height > cropBox.maxHeight || cropBox.height < cropBox.minHeight) { + cropBox.top = cropBox.oldTop; + } + + cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth); + cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight); + + this.limitCropBox(false, true); + + cropBox.oldLeft = cropBox.left = min(max(cropBox.left, cropBox.minLeft), cropBox.maxLeft); + cropBox.oldTop = cropBox.top = min(max(cropBox.top, cropBox.minTop), cropBox.maxTop); + + if (options.movable && options.cropBoxMovable) { + + // Turn to move the canvas when the crop box is equal to the container + this.$face.data(DATA_ACTION, (cropBox.width === containerWidth && cropBox.height === containerHeight) ? ACTION_MOVE : ACTION_ALL); + } + + this.$cropBox.css({ + width: cropBox.width, + height: cropBox.height, + left: cropBox.left, + top: cropBox.top + }); + + if (this.isCropped && this.isLimited) { + this.limitCanvas(true, true); + } + + if (!this.isDisabled) { + this.output(); + } + }, + + output: function () { + this.preview(); + + if (this.isCompleted) { + this.trigger(EVENT_CROP, this.getData()); + } else if (!this.isBuilt) { + + // Only trigger one crop event before complete + this.$element.one(EVENT_BUILT, $.proxy(function () { + this.trigger(EVENT_CROP, this.getData()); + }, this)); + } + }, + + initPreview: function () { + var crossOrigin = getCrossOrigin(this.crossOrigin); + var url = crossOrigin ? this.crossOriginUrl : this.url; + + this.$preview = $(this.options.preview); + this.$viewBox.html('<img' + crossOrigin + ' src="' + url + '">'); + this.$preview.each(function () { + var $this = $(this); + + // Save the original size for recover + $this.data(DATA_PREVIEW, { + width: $this.width(), + height: $this.height(), + html: $this.html() + }); + + /** + * Override img element styles + * Add `display:block` to avoid margin top issue + * (Occur only when margin-top <= -height) + */ + $this.html( + '<img' + crossOrigin + ' src="' + url + '" style="' + + 'display:block;width:100%;height:auto;' + + 'min-width:0!important;min-height:0!important;' + + 'max-width:none!important;max-height:none!important;' + + 'image-orientation:0deg!important;">' + ); + }); + }, + + resetPreview: function () { + this.$preview.each(function () { + var $this = $(this); + var data = $this.data(DATA_PREVIEW); + + $this.css({ + width: data.width, + height: data.height + }).html(data.html).removeData(DATA_PREVIEW); + }); + }, + + preview: function () { + var image = this.image; + var canvas = this.canvas; + var cropBox = this.cropBox; + var cropBoxWidth = cropBox.width; + var cropBoxHeight = cropBox.height; + var width = image.width; + var height = image.height; + var left = cropBox.left - canvas.left - image.left; + var top = cropBox.top - canvas.top - image.top; + + if (!this.isCropped || this.isDisabled) { + return; + } + + this.$viewBox.find('img').css({ + width: width, + height: height, + marginLeft: -left, + marginTop: -top, + transform: getTransform(image) + }); + + this.$preview.each(function () { + var $this = $(this); + var data = $this.data(DATA_PREVIEW); + var originalWidth = data.width; + var originalHeight = data.height; + var newWidth = originalWidth; + var newHeight = originalHeight; + var ratio = 1; + + if (cropBoxWidth) { + ratio = originalWidth / cropBoxWidth; + newHeight = cropBoxHeight * ratio; + } + + if (cropBoxHeight && newHeight > originalHeight) { + ratio = originalHeight / cropBoxHeight; + newWidth = cropBoxWidth * ratio; + newHeight = originalHeight; + } + + $this.css({ + width: newWidth, + height: newHeight + }).find('img').css({ + width: width * ratio, + height: height * ratio, + marginLeft: -left * ratio, + marginTop: -top * ratio, + transform: getTransform(image) + }); + }); + }, + + bind: function () { + var options = this.options; + var $this = this.$element; + var $cropper = this.$cropper; + + if ($.isFunction(options.cropstart)) { + $this.on(EVENT_CROP_START, options.cropstart); + } + + if ($.isFunction(options.cropmove)) { + $this.on(EVENT_CROP_MOVE, options.cropmove); + } + + if ($.isFunction(options.cropend)) { + $this.on(EVENT_CROP_END, options.cropend); + } + + if ($.isFunction(options.crop)) { + $this.on(EVENT_CROP, options.crop); + } + + if ($.isFunction(options.zoom)) { + $this.on(EVENT_ZOOM, options.zoom); + } + + $cropper.on(EVENT_MOUSE_DOWN, $.proxy(this.cropStart, this)); + + if (options.zoomable && options.zoomOnWheel) { + $cropper.on(EVENT_WHEEL, $.proxy(this.wheel, this)); + } + + if (options.toggleDragModeOnDblclick) { + $cropper.on(EVENT_DBLCLICK, $.proxy(this.dblclick, this)); + } + + $document. + on(EVENT_MOUSE_MOVE, (this._cropMove = proxy(this.cropMove, this))). + on(EVENT_MOUSE_UP, (this._cropEnd = proxy(this.cropEnd, this))); + + if (options.responsive) { + $window.on(EVENT_RESIZE, (this._resize = proxy(this.resize, this))); + } + }, + + unbind: function () { + var options = this.options; + var $this = this.$element; + var $cropper = this.$cropper; + + if ($.isFunction(options.cropstart)) { + $this.off(EVENT_CROP_START, options.cropstart); + } + + if ($.isFunction(options.cropmove)) { + $this.off(EVENT_CROP_MOVE, options.cropmove); + } + + if ($.isFunction(options.cropend)) { + $this.off(EVENT_CROP_END, options.cropend); + } + + if ($.isFunction(options.crop)) { + $this.off(EVENT_CROP, options.crop); + } + + if ($.isFunction(options.zoom)) { + $this.off(EVENT_ZOOM, options.zoom); + } + + $cropper.off(EVENT_MOUSE_DOWN, this.cropStart); + + if (options.zoomable && options.zoomOnWheel) { + $cropper.off(EVENT_WHEEL, this.wheel); + } + + if (options.toggleDragModeOnDblclick) { + $cropper.off(EVENT_DBLCLICK, this.dblclick); + } + + $document. + off(EVENT_MOUSE_MOVE, this._cropMove). + off(EVENT_MOUSE_UP, this._cropEnd); + + if (options.responsive) { + $window.off(EVENT_RESIZE, this._resize); + } + }, + + resize: function () { + var restore = this.options.restore; + var $container = this.$container; + var container = this.container; + var canvasData; + var cropBoxData; + var ratio; + + // Check `container` is necessary for IE8 + if (this.isDisabled || !container) { + return; + } + + ratio = $container.width() / container.width; + + // Resize when width changed or height changed + if (ratio !== 1 || $container.height() !== container.height) { + if (restore) { + canvasData = this.getCanvasData(); + cropBoxData = this.getCropBoxData(); + } + + this.render(); + + if (restore) { + this.setCanvasData($.each(canvasData, function (i, n) { + canvasData[i] = n * ratio; + })); + this.setCropBoxData($.each(cropBoxData, function (i, n) { + cropBoxData[i] = n * ratio; + })); + } + } + }, + + dblclick: function () { + if (this.isDisabled) { + return; + } + + if (this.$dragBox.hasClass(CLASS_CROP)) { + this.setDragMode(ACTION_MOVE); + } else { + this.setDragMode(ACTION_CROP); + } + }, + + wheel: function (event) { + var e = event.originalEvent || event; + var ratio = num(this.options.wheelZoomRatio) || 0.1; + var delta = 1; + + if (this.isDisabled) { + return; + } + + event.preventDefault(); + + // Limit wheel speed to prevent zoom too fast + if (this.wheeling) { + return; + } + + this.wheeling = true; + + setTimeout($.proxy(function () { + this.wheeling = false; + }, this), 50); + + if (e.deltaY) { + delta = e.deltaY > 0 ? 1 : -1; + } else if (e.wheelDelta) { + delta = -e.wheelDelta / 120; + } else if (e.detail) { + delta = e.detail > 0 ? 1 : -1; + } + + this.zoom(-delta * ratio, event); + }, + + cropStart: function (event) { + var options = this.options; + var originalEvent = event.originalEvent; + var touches = originalEvent && originalEvent.touches; + var e = event; + var touchesLength; + var action; + + if (this.isDisabled) { + return; + } + + if (touches) { + touchesLength = touches.length; + + if (touchesLength > 1) { + if (options.zoomable && options.zoomOnTouch && touchesLength === 2) { + e = touches[1]; + this.startX2 = e.pageX; + this.startY2 = e.pageY; + action = ACTION_ZOOM; + } else { + return; + } + } + + e = touches[0]; + } + + action = action || $(e.target).data(DATA_ACTION); + + if (REGEXP_ACTIONS.test(action)) { + if (this.trigger(EVENT_CROP_START, { + originalEvent: originalEvent, + action: action + }).isDefaultPrevented()) { + return; + } + + event.preventDefault(); + + this.action = action; + this.cropping = false; + + // IE8 has `event.pageX/Y`, but not `event.originalEvent.pageX/Y` + // IE10 has `event.originalEvent.pageX/Y`, but not `event.pageX/Y` + this.startX = e.pageX || originalEvent && originalEvent.pageX; + this.startY = e.pageY || originalEvent && originalEvent.pageY; + + if (action === ACTION_CROP) { + this.cropping = true; + this.$dragBox.addClass(CLASS_MODAL); + } + } + }, + + cropMove: function (event) { + var options = this.options; + var originalEvent = event.originalEvent; + var touches = originalEvent && originalEvent.touches; + var e = event; + var action = this.action; + var touchesLength; + + if (this.isDisabled) { + return; + } + + if (touches) { + touchesLength = touches.length; + + if (touchesLength > 1) { + if (options.zoomable && options.zoomOnTouch && touchesLength === 2) { + e = touches[1]; + this.endX2 = e.pageX; + this.endY2 = e.pageY; + } else { + return; + } + } + + e = touches[0]; + } + + if (action) { + if (this.trigger(EVENT_CROP_MOVE, { + originalEvent: originalEvent, + action: action + }).isDefaultPrevented()) { + return; + } + + event.preventDefault(); + + this.endX = e.pageX || originalEvent && originalEvent.pageX; + this.endY = e.pageY || originalEvent && originalEvent.pageY; + + this.change(e.shiftKey, action === ACTION_ZOOM ? event : null); + } + }, + + cropEnd: function (event) { + var originalEvent = event.originalEvent; + var action = this.action; + + if (this.isDisabled) { + return; + } + + if (action) { + event.preventDefault(); + + if (this.cropping) { + this.cropping = false; + this.$dragBox.toggleClass(CLASS_MODAL, this.isCropped && this.options.modal); + } + + this.action = ''; + + this.trigger(EVENT_CROP_END, { + originalEvent: originalEvent, + action: action + }); + } + }, + + change: function (shiftKey, event) { + var options = this.options; + var aspectRatio = options.aspectRatio; + var action = this.action; + var container = this.container; + var canvas = this.canvas; + var cropBox = this.cropBox; + var width = cropBox.width; + var height = cropBox.height; + var left = cropBox.left; + var top = cropBox.top; + var right = left + width; + var bottom = top + height; + var minLeft = 0; + var minTop = 0; + var maxWidth = container.width; + var maxHeight = container.height; + var renderable = true; + var offset; + var range; + + // Locking aspect ratio in "free mode" by holding shift key (#259) + if (!aspectRatio && shiftKey) { + aspectRatio = width && height ? width / height : 1; + } + + if (this.limited) { + minLeft = cropBox.minLeft; + minTop = cropBox.minTop; + maxWidth = minLeft + min(container.width, canvas.width); + maxHeight = minTop + min(container.height, canvas.height); + } + + range = { + x: this.endX - this.startX, + y: this.endY - this.startY + }; + + if (aspectRatio) { + range.X = range.y * aspectRatio; + range.Y = range.x / aspectRatio; + } + + switch (action) { + // Move crop box + case ACTION_ALL: + left += range.x; + top += range.y; + break; + + // Resize crop box + case ACTION_EAST: + if (range.x >= 0 && (right >= maxWidth || aspectRatio && + (top <= minTop || bottom >= maxHeight))) { + + renderable = false; + break; + } + + width += range.x; + + if (aspectRatio) { + height = width / aspectRatio; + top -= range.Y / 2; + } + + if (width < 0) { + action = ACTION_WEST; + width = 0; + } + + break; + + case ACTION_NORTH: + if (range.y <= 0 && (top <= minTop || aspectRatio && + (left <= minLeft || right >= maxWidth))) { + + renderable = false; + break; + } + + height -= range.y; + top += range.y; + + if (aspectRatio) { + width = height * aspectRatio; + left += range.X / 2; + } + + if (height < 0) { + action = ACTION_SOUTH; + height = 0; + } + + break; + + case ACTION_WEST: + if (range.x <= 0 && (left <= minLeft || aspectRatio && + (top <= minTop || bottom >= maxHeight))) { + + renderable = false; + break; + } + + width -= range.x; + left += range.x; + + if (aspectRatio) { + height = width / aspectRatio; + top += range.Y / 2; + } + + if (width < 0) { + action = ACTION_EAST; + width = 0; + } + + break; + + case ACTION_SOUTH: + if (range.y >= 0 && (bottom >= maxHeight || aspectRatio && + (left <= minLeft || right >= maxWidth))) { + + renderable = false; + break; + } + + height += range.y; + + if (aspectRatio) { + width = height * aspectRatio; + left -= range.X / 2; + } + + if (height < 0) { + action = ACTION_NORTH; + height = 0; + } + + break; + + case ACTION_NORTH_EAST: + if (aspectRatio) { + if (range.y <= 0 && (top <= minTop || right >= maxWidth)) { + renderable = false; + break; + } + + height -= range.y; + top += range.y; + width = height * aspectRatio; + } else { + if (range.x >= 0) { + if (right < maxWidth) { + width += range.x; + } else if (range.y <= 0 && top <= minTop) { + renderable = false; + } + } else { + width += range.x; + } + + if (range.y <= 0) { + if (top > minTop) { + height -= range.y; + top += range.y; + } + } else { + height -= range.y; + top += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_SOUTH_WEST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_NORTH_WEST; + width = 0; + } else if (height < 0) { + action = ACTION_SOUTH_EAST; + height = 0; + } + + break; + + case ACTION_NORTH_WEST: + if (aspectRatio) { + if (range.y <= 0 && (top <= minTop || left <= minLeft)) { + renderable = false; + break; + } + + height -= range.y; + top += range.y; + width = height * aspectRatio; + left += range.X; + } else { + if (range.x <= 0) { + if (left > minLeft) { + width -= range.x; + left += range.x; + } else if (range.y <= 0 && top <= minTop) { + renderable = false; + } + } else { + width -= range.x; + left += range.x; + } + + if (range.y <= 0) { + if (top > minTop) { + height -= range.y; + top += range.y; + } + } else { + height -= range.y; + top += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_SOUTH_EAST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_NORTH_EAST; + width = 0; + } else if (height < 0) { + action = ACTION_SOUTH_WEST; + height = 0; + } + + break; + + case ACTION_SOUTH_WEST: + if (aspectRatio) { + if (range.x <= 0 && (left <= minLeft || bottom >= maxHeight)) { + renderable = false; + break; + } + + width -= range.x; + left += range.x; + height = width / aspectRatio; + } else { + if (range.x <= 0) { + if (left > minLeft) { + width -= range.x; + left += range.x; + } else if (range.y >= 0 && bottom >= maxHeight) { + renderable = false; + } + } else { + width -= range.x; + left += range.x; + } + + if (range.y >= 0) { + if (bottom < maxHeight) { + height += range.y; + } + } else { + height += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_NORTH_EAST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_SOUTH_EAST; + width = 0; + } else if (height < 0) { + action = ACTION_NORTH_WEST; + height = 0; + } + + break; + + case ACTION_SOUTH_EAST: + if (aspectRatio) { + if (range.x >= 0 && (right >= maxWidth || bottom >= maxHeight)) { + renderable = false; + break; + } + + width += range.x; + height = width / aspectRatio; + } else { + if (range.x >= 0) { + if (right < maxWidth) { + width += range.x; + } else if (range.y >= 0 && bottom >= maxHeight) { + renderable = false; + } + } else { + width += range.x; + } + + if (range.y >= 0) { + if (bottom < maxHeight) { + height += range.y; + } + } else { + height += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_NORTH_WEST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_SOUTH_WEST; + width = 0; + } else if (height < 0) { + action = ACTION_NORTH_EAST; + height = 0; + } + + break; + + // Move canvas + case ACTION_MOVE: + this.move(range.x, range.y); + renderable = false; + break; + + // Zoom canvas + case ACTION_ZOOM: + this.zoom((function (x1, y1, x2, y2) { + var z1 = sqrt(x1 * x1 + y1 * y1); + var z2 = sqrt(x2 * x2 + y2 * y2); + + return (z2 - z1) / z1; + })( + abs(this.startX - this.startX2), + abs(this.startY - this.startY2), + abs(this.endX - this.endX2), + abs(this.endY - this.endY2) + ), event); + this.startX2 = this.endX2; + this.startY2 = this.endY2; + renderable = false; + break; + + // Create crop box + case ACTION_CROP: + if (!range.x || !range.y) { + renderable = false; + break; + } + + offset = this.$cropper.offset(); + left = this.startX - offset.left; + top = this.startY - offset.top; + width = cropBox.minWidth; + height = cropBox.minHeight; + + if (range.x > 0) { + action = range.y > 0 ? ACTION_SOUTH_EAST : ACTION_NORTH_EAST; + } else if (range.x < 0) { + left -= width; + action = range.y > 0 ? ACTION_SOUTH_WEST : ACTION_NORTH_WEST; + } + + if (range.y < 0) { + top -= height; + } + + // Show the crop box if is hidden + if (!this.isCropped) { + this.$cropBox.removeClass(CLASS_HIDDEN); + this.isCropped = true; + + if (this.limited) { + this.limitCropBox(true, true); + } + } + + break; + + // No default + } + + if (renderable) { + cropBox.width = width; + cropBox.height = height; + cropBox.left = left; + cropBox.top = top; + this.action = action; + + this.renderCropBox(); + } + + // Override + this.startX = this.endX; + this.startY = this.endY; + }, + + // Show the crop box manually + crop: function () { + if (!this.isBuilt || this.isDisabled) { + return; + } + + if (!this.isCropped) { + this.isCropped = true; + this.limitCropBox(true, true); + + if (this.options.modal) { + this.$dragBox.addClass(CLASS_MODAL); + } + + this.$cropBox.removeClass(CLASS_HIDDEN); + } + + this.setCropBoxData(this.initialCropBox); + }, + + // Reset the image and crop box to their initial states + reset: function () { + if (!this.isBuilt || this.isDisabled) { + return; + } + + this.image = $.extend({}, this.initialImage); + this.canvas = $.extend({}, this.initialCanvas); + this.cropBox = $.extend({}, this.initialCropBox); + + this.renderCanvas(); + + if (this.isCropped) { + this.renderCropBox(); + } + }, + + // Clear the crop box + clear: function () { + if (!this.isCropped || this.isDisabled) { + return; + } + + $.extend(this.cropBox, { + left: 0, + top: 0, + width: 0, + height: 0 + }); + + this.isCropped = false; + this.renderCropBox(); + + this.limitCanvas(true, true); + + // Render canvas after crop box rendered + this.renderCanvas(); + + this.$dragBox.removeClass(CLASS_MODAL); + this.$cropBox.addClass(CLASS_HIDDEN); + }, + + /** + * Replace the image's src and rebuild the cropper + * + * @param {String} url + */ + replace: function (url) { + if (!this.isDisabled && url) { + if (this.isImg) { + this.isReplaced = true; + this.$element.attr('src', url); + } + + // Clear previous data + this.options.data = null; + this.load(url); + } + }, + + // Enable (unfreeze) the cropper + enable: function () { + if (this.isBuilt) { + this.isDisabled = false; + this.$cropper.removeClass(CLASS_DISABLED); + } + }, + + // Disable (freeze) the cropper + disable: function () { + if (this.isBuilt) { + this.isDisabled = true; + this.$cropper.addClass(CLASS_DISABLED); + } + }, + + // Destroy the cropper and remove the instance from the image + destroy: function () { + var $this = this.$element; + + if (this.isLoaded) { + if (this.isImg && this.isReplaced) { + $this.attr('src', this.originalUrl); + } + + this.unbuild(); + $this.removeClass(CLASS_HIDDEN); + } else { + if (this.isImg) { + $this.off(EVENT_LOAD, this.start); + } else if (this.$clone) { + this.$clone.remove(); + } + } + + $this.removeData(NAMESPACE); + }, + + /** + * Move the canvas with relative offsets + * + * @param {Number} offsetX + * @param {Number} offsetY (optional) + */ + move: function (offsetX, offsetY) { + var canvas = this.canvas; + + this.moveTo( + isUndefined(offsetX) ? offsetX : canvas.left + num(offsetX), + isUndefined(offsetY) ? offsetY : canvas.top + num(offsetY) + ); + }, + + /** + * Move the canvas to an absolute point + * + * @param {Number} x + * @param {Number} y (optional) + */ + moveTo: function (x, y) { + var canvas = this.canvas; + var isChanged = false; + + // If "y" is not present, its default value is "x" + if (isUndefined(y)) { + y = x; + } + + x = num(x); + y = num(y); + + if (this.isBuilt && !this.isDisabled && this.options.movable) { + if (isNumber(x)) { + canvas.left = x; + isChanged = true; + } + + if (isNumber(y)) { + canvas.top = y; + isChanged = true; + } + + if (isChanged) { + this.renderCanvas(true); + } + } + }, + + /** + * Zoom the canvas with a relative ratio + * + * @param {Number} ratio + * @param {jQuery Event} _event (private) + */ + zoom: function (ratio, _event) { + var canvas = this.canvas; + + ratio = num(ratio); + + if (ratio < 0) { + ratio = 1 / (1 - ratio); + } else { + ratio = 1 + ratio; + } + + this.zoomTo(canvas.width * ratio / canvas.naturalWidth, _event); + }, + + /** + * Zoom the canvas to an absolute ratio + * + * @param {Number} ratio + * @param {jQuery Event} _event (private) + */ + zoomTo: function (ratio, _event) { + var options = this.options; + var canvas = this.canvas; + var width = canvas.width; + var height = canvas.height; + var naturalWidth = canvas.naturalWidth; + var naturalHeight = canvas.naturalHeight; + var originalEvent; + var newWidth; + var newHeight; + var offset; + var center; + + ratio = num(ratio); + + if (ratio >= 0 && this.isBuilt && !this.isDisabled && options.zoomable) { + newWidth = naturalWidth * ratio; + newHeight = naturalHeight * ratio; + + if (_event) { + originalEvent = _event.originalEvent; + } + + if (this.trigger(EVENT_ZOOM, { + originalEvent: originalEvent, + oldRatio: width / naturalWidth, + ratio: newWidth / naturalWidth + }).isDefaultPrevented()) { + return; + } + + if (originalEvent) { + offset = this.$cropper.offset(); + center = originalEvent.touches ? getTouchesCenter(originalEvent.touches) : { + pageX: _event.pageX || originalEvent.pageX || 0, + pageY: _event.pageY || originalEvent.pageY || 0 + }; + + // Zoom from the triggering point of the event + canvas.left -= (newWidth - width) * ( + ((center.pageX - offset.left) - canvas.left) / width + ); + canvas.top -= (newHeight - height) * ( + ((center.pageY - offset.top) - canvas.top) / height + ); + } else { + + // Zoom from the center of the canvas + canvas.left -= (newWidth - width) / 2; + canvas.top -= (newHeight - height) / 2; + } + + canvas.width = newWidth; + canvas.height = newHeight; + this.renderCanvas(true); + } + }, + + /** + * Rotate the canvas with a relative degree + * + * @param {Number} degree + */ + rotate: function (degree) { + this.rotateTo((this.image.rotate || 0) + num(degree)); + }, + + /** + * Rotate the canvas to an absolute degree + * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#rotate() + * + * @param {Number} degree + */ + rotateTo: function (degree) { + degree = num(degree); + + if (isNumber(degree) && this.isBuilt && !this.isDisabled && this.options.rotatable) { + this.image.rotate = degree % 360; + this.isRotated = true; + this.renderCanvas(true); + } + }, + + /** + * Scale the image + * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#scale() + * + * @param {Number} scaleX + * @param {Number} scaleY (optional) + */ + scale: function (scaleX, scaleY) { + var image = this.image; + var isChanged = false; + + // If "scaleY" is not present, its default value is "scaleX" + if (isUndefined(scaleY)) { + scaleY = scaleX; + } + + scaleX = num(scaleX); + scaleY = num(scaleY); + + if (this.isBuilt && !this.isDisabled && this.options.scalable) { + if (isNumber(scaleX)) { + image.scaleX = scaleX; + isChanged = true; + } + + if (isNumber(scaleY)) { + image.scaleY = scaleY; + isChanged = true; + } + + if (isChanged) { + this.renderImage(true); + } + } + }, + + /** + * Scale the abscissa of the image + * + * @param {Number} scaleX + */ + scaleX: function (scaleX) { + var scaleY = this.image.scaleY; + + this.scale(scaleX, isNumber(scaleY) ? scaleY : 1); + }, + + /** + * Scale the ordinate of the image + * + * @param {Number} scaleY + */ + scaleY: function (scaleY) { + var scaleX = this.image.scaleX; + + this.scale(isNumber(scaleX) ? scaleX : 1, scaleY); + }, + + /** + * Get the cropped area position and size data (base on the original image) + * + * @param {Boolean} isRounded (optional) + * @return {Object} data + */ + getData: function (isRounded) { + var options = this.options; + var image = this.image; + var canvas = this.canvas; + var cropBox = this.cropBox; + var ratio; + var data; + + if (this.isBuilt && this.isCropped) { + data = { + x: cropBox.left - canvas.left, + y: cropBox.top - canvas.top, + width: cropBox.width, + height: cropBox.height + }; + + ratio = image.width / image.naturalWidth; + + $.each(data, function (i, n) { + n = n / ratio; + data[i] = isRounded ? round(n) : n; + }); + + } else { + data = { + x: 0, + y: 0, + width: 0, + height: 0 + }; + } + + if (options.rotatable) { + data.rotate = image.rotate || 0; + } + + if (options.scalable) { + data.scaleX = image.scaleX || 1; + data.scaleY = image.scaleY || 1; + } + + return data; + }, + + /** + * Set the cropped area position and size with new data + * + * @param {Object} data + */ + setData: function (data) { + var options = this.options; + var image = this.image; + var canvas = this.canvas; + var cropBoxData = {}; + var isRotated; + var isScaled; + var ratio; + + if ($.isFunction(data)) { + data = data.call(this.element); + } + + if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) { + if (options.rotatable) { + if (isNumber(data.rotate) && data.rotate !== image.rotate) { + image.rotate = data.rotate; + this.isRotated = isRotated = true; + } + } + + if (options.scalable) { + if (isNumber(data.scaleX) && data.scaleX !== image.scaleX) { + image.scaleX = data.scaleX; + isScaled = true; + } + + if (isNumber(data.scaleY) && data.scaleY !== image.scaleY) { + image.scaleY = data.scaleY; + isScaled = true; + } + } + + if (isRotated) { + this.renderCanvas(); + } else if (isScaled) { + this.renderImage(); + } + + ratio = image.width / image.naturalWidth; + + if (isNumber(data.x)) { + cropBoxData.left = data.x * ratio + canvas.left; + } + + if (isNumber(data.y)) { + cropBoxData.top = data.y * ratio + canvas.top; + } + + if (isNumber(data.width)) { + cropBoxData.width = data.width * ratio; + } + + if (isNumber(data.height)) { + cropBoxData.height = data.height * ratio; + } + + this.setCropBoxData(cropBoxData); + } + }, + + /** + * Get the container size data + * + * @return {Object} data + */ + getContainerData: function () { + return this.isBuilt ? this.container : {}; + }, + + /** + * Get the image position and size data + * + * @return {Object} data + */ + getImageData: function () { + return this.isLoaded ? this.image : {}; + }, + + /** + * Get the canvas position and size data + * + * @return {Object} data + */ + getCanvasData: function () { + var canvas = this.canvas; + var data = {}; + + if (this.isBuilt) { + $.each([ + 'left', + 'top', + 'width', + 'height', + 'naturalWidth', + 'naturalHeight' + ], function (i, n) { + data[n] = canvas[n]; + }); + } + + return data; + }, + + /** + * Set the canvas position and size with new data + * + * @param {Object} data + */ + setCanvasData: function (data) { + var canvas = this.canvas; + var aspectRatio = canvas.aspectRatio; + + if ($.isFunction(data)) { + data = data.call(this.$element); + } + + if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) { + if (isNumber(data.left)) { + canvas.left = data.left; + } + + if (isNumber(data.top)) { + canvas.top = data.top; + } + + if (isNumber(data.width)) { + canvas.width = data.width; + canvas.height = data.width / aspectRatio; + } else if (isNumber(data.height)) { + canvas.height = data.height; + canvas.width = data.height * aspectRatio; + } + + this.renderCanvas(true); + } + }, + + /** + * Get the crop box position and size data + * + * @return {Object} data + */ + getCropBoxData: function () { + var cropBox = this.cropBox; + var data; + + if (this.isBuilt && this.isCropped) { + data = { + left: cropBox.left, + top: cropBox.top, + width: cropBox.width, + height: cropBox.height + }; + } + + return data || {}; + }, + + /** + * Set the crop box position and size with new data + * + * @param {Object} data + */ + setCropBoxData: function (data) { + var cropBox = this.cropBox; + var aspectRatio = this.options.aspectRatio; + var isWidthChanged; + var isHeightChanged; + + if ($.isFunction(data)) { + data = data.call(this.$element); + } + + if (this.isBuilt && this.isCropped && !this.isDisabled && $.isPlainObject(data)) { + + if (isNumber(data.left)) { + cropBox.left = data.left; + } + + if (isNumber(data.top)) { + cropBox.top = data.top; + } + + if (isNumber(data.width)) { + isWidthChanged = true; + cropBox.width = data.width; + } + + if (isNumber(data.height)) { + isHeightChanged = true; + cropBox.height = data.height; + } + + if (aspectRatio) { + if (isWidthChanged) { + cropBox.height = cropBox.width / aspectRatio; + } else if (isHeightChanged) { + cropBox.width = cropBox.height * aspectRatio; + } + } + + this.renderCropBox(); + } + }, + + /** + * Get a canvas drawn the cropped image + * + * @param {Object} options (optional) + * @return {HTMLCanvasElement} canvas + */ + getCroppedCanvas: function (options) { + var originalWidth; + var originalHeight; + var canvasWidth; + var canvasHeight; + var scaledWidth; + var scaledHeight; + var scaledRatio; + var aspectRatio; + var canvas; + var context; + var data; + + if (!this.isBuilt || !this.isCropped || !SUPPORT_CANVAS) { + return; + } + + if (!$.isPlainObject(options)) { + options = {}; + } + + data = this.getData(); + originalWidth = data.width; + originalHeight = data.height; + aspectRatio = originalWidth / originalHeight; + + if ($.isPlainObject(options)) { + scaledWidth = options.width; + scaledHeight = options.height; + + if (scaledWidth) { + scaledHeight = scaledWidth / aspectRatio; + scaledRatio = scaledWidth / originalWidth; + } else if (scaledHeight) { + scaledWidth = scaledHeight * aspectRatio; + scaledRatio = scaledHeight / originalHeight; + } + } + + // The canvas element will use `Math.floor` on a float number, so floor first + canvasWidth = floor(scaledWidth || originalWidth); + canvasHeight = floor(scaledHeight || originalHeight); + + canvas = $('<canvas>')[0]; + canvas.width = canvasWidth; + canvas.height = canvasHeight; + context = canvas.getContext('2d'); + + if (options.fillColor) { + context.fillStyle = options.fillColor; + context.fillRect(0, 0, canvasWidth, canvasHeight); + } + + // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage + context.drawImage.apply(context, (function () { + var source = getSourceCanvas(this.$clone[0], this.image); + var sourceWidth = source.width; + var sourceHeight = source.height; + var args = [source]; + + // Source canvas + var srcX = data.x; + var srcY = data.y; + var srcWidth; + var srcHeight; + + // Destination canvas + var dstX; + var dstY; + var dstWidth; + var dstHeight; + + if (srcX <= -originalWidth || srcX > sourceWidth) { + srcX = srcWidth = dstX = dstWidth = 0; + } else if (srcX <= 0) { + dstX = -srcX; + srcX = 0; + srcWidth = dstWidth = min(sourceWidth, originalWidth + srcX); + } else if (srcX <= sourceWidth) { + dstX = 0; + srcWidth = dstWidth = min(originalWidth, sourceWidth - srcX); + } + + if (srcWidth <= 0 || srcY <= -originalHeight || srcY > sourceHeight) { + srcY = srcHeight = dstY = dstHeight = 0; + } else if (srcY <= 0) { + dstY = -srcY; + srcY = 0; + srcHeight = dstHeight = min(sourceHeight, originalHeight + srcY); + } else if (srcY <= sourceHeight) { + dstY = 0; + srcHeight = dstHeight = min(originalHeight, sourceHeight - srcY); + } + + // All the numerical parameters should be integer for `drawImage` (#476) + args.push(floor(srcX), floor(srcY), floor(srcWidth), floor(srcHeight)); + + // Scale destination sizes + if (scaledRatio) { + dstX *= scaledRatio; + dstY *= scaledRatio; + dstWidth *= scaledRatio; + dstHeight *= scaledRatio; + } + + // Avoid "IndexSizeError" in IE and Firefox + if (dstWidth > 0 && dstHeight > 0) { + args.push(floor(dstX), floor(dstY), floor(dstWidth), floor(dstHeight)); + } + + return args; + }).call(this)); + + return canvas; + }, + + /** + * Change the aspect ratio of the crop box + * + * @param {Number} aspectRatio + */ + setAspectRatio: function (aspectRatio) { + var options = this.options; + + if (!this.isDisabled && !isUndefined(aspectRatio)) { + + // 0 -> NaN + options.aspectRatio = max(0, aspectRatio) || NaN; + + if (this.isBuilt) { + this.initCropBox(); + + if (this.isCropped) { + this.renderCropBox(); + } + } + } + }, + + /** + * Change the drag mode + * + * @param {String} mode (optional) + */ + setDragMode: function (mode) { + var options = this.options; + var croppable; + var movable; + + if (this.isLoaded && !this.isDisabled) { + croppable = mode === ACTION_CROP; + movable = options.movable && mode === ACTION_MOVE; + mode = (croppable || movable) ? mode : ACTION_NONE; + + this.$dragBox. + data(DATA_ACTION, mode). + toggleClass(CLASS_CROP, croppable). + toggleClass(CLASS_MOVE, movable); + + if (!options.cropBoxMovable) { + + // Sync drag mode to crop box when it is not movable(#300) + this.$face. + data(DATA_ACTION, mode). + toggleClass(CLASS_CROP, croppable). + toggleClass(CLASS_MOVE, movable); + } + } + } + }; + + Cropper.DEFAULTS = { + + // Define the view mode of the cropper + viewMode: 0, // 0, 1, 2, 3 + + // Define the dragging mode of the cropper + dragMode: 'crop', // 'crop', 'move' or 'none' + + // Define the aspect ratio of the crop box + aspectRatio: NaN, + + // An object with the previous cropping result data + data: null, + + // A jQuery selector for adding extra containers to preview + preview: '', + + // Re-render the cropper when resize the window + responsive: true, + + // Restore the cropped area after resize the window + restore: true, + + // Check if the current image is a cross-origin image + checkCrossOrigin: true, + + // Check the current image's Exif Orientation information + checkOrientation: true, + + // Show the black modal + modal: true, + + // Show the dashed lines for guiding + guides: true, + + // Show the center indicator for guiding + center: true, + + // Show the white modal to highlight the crop box + highlight: true, + + // Show the grid background + background: true, + + // Enable to crop the image automatically when initialize + autoCrop: true, + + // Define the percentage of automatic cropping area when initializes + autoCropArea: 0.8, + + // Enable to move the image + movable: true, + + // Enable to rotate the image + rotatable: true, + + // Enable to scale the image + scalable: true, + + // Enable to zoom the image + zoomable: true, + + // Enable to zoom the image by dragging touch + zoomOnTouch: true, + + // Enable to zoom the image by wheeling mouse + zoomOnWheel: true, + + // Define zoom ratio when zoom the image by wheeling mouse + wheelZoomRatio: 0.1, + + // Enable to move the crop box + cropBoxMovable: true, + + // Enable to resize the crop box + cropBoxResizable: true, + + // Toggle drag mode between "crop" and "move" when click twice on the cropper + toggleDragModeOnDblclick: true, + + // Size limitation + minCanvasWidth: 0, + minCanvasHeight: 0, + minCropBoxWidth: 0, + minCropBoxHeight: 0, + minContainerWidth: 200, + minContainerHeight: 100, + + // Shortcuts of events + build: null, + built: null, + cropstart: null, + cropmove: null, + cropend: null, + crop: null, + zoom: null + }; + + Cropper.setDefaults = function (options) { + $.extend(Cropper.DEFAULTS, options); + }; + + Cropper.TEMPLATE = ( + '<div class="cropper-container">' + + '<div class="cropper-wrap-box">' + + '<div class="cropper-canvas"></div>' + + '</div>' + + '<div class="cropper-drag-box"></div>' + + '<div class="cropper-crop-box">' + + '<span class="cropper-view-box"></span>' + + '<span class="cropper-dashed dashed-h"></span>' + + '<span class="cropper-dashed dashed-v"></span>' + + '<span class="cropper-center"></span>' + + '<span class="cropper-face"></span>' + + '<span class="cropper-line line-e" data-action="e"></span>' + + '<span class="cropper-line line-n" data-action="n"></span>' + + '<span class="cropper-line line-w" data-action="w"></span>' + + '<span class="cropper-line line-s" data-action="s"></span>' + + '<span class="cropper-point point-e" data-action="e"></span>' + + '<span class="cropper-point point-n" data-action="n"></span>' + + '<span class="cropper-point point-w" data-action="w"></span>' + + '<span class="cropper-point point-s" data-action="s"></span>' + + '<span class="cropper-point point-ne" data-action="ne"></span>' + + '<span class="cropper-point point-nw" data-action="nw"></span>' + + '<span class="cropper-point point-sw" data-action="sw"></span>' + + '<span class="cropper-point point-se" data-action="se"></span>' + + '</div>' + + '</div>' + ); + + // Save the other cropper + Cropper.other = $.fn.cropper; + + // Register as jQuery plugin + $.fn.cropper = function (option) { + var args = toArray(arguments, 1); + var result; + + this.each(function () { + var $this = $(this); + var data = $this.data(NAMESPACE); + var options; + var fn; + + if (!data) { + if (/destroy/.test(option)) { + return; + } + + options = $.extend({}, $this.data(), $.isPlainObject(option) && option); + $this.data(NAMESPACE, (data = new Cropper(this, options))); + } + + if (typeof option === 'string' && $.isFunction(fn = data[option])) { + result = fn.apply(data, args); + } + }); + + return isUndefined(result) ? this : result; + }; + + $.fn.cropper.Constructor = Cropper; + $.fn.cropper.setDefaults = Cropper.setDefaults; + + // No conflict + $.fn.cropper.noConflict = function () { + $.fn.cropper = Cropper.other; + return this; + }; + +}); diff --git a/vendor/assets/stylesheets/cropper.css b/vendor/assets/stylesheets/cropper.css new file mode 100755 index 00000000000..41ee4bd546c --- /dev/null +++ b/vendor/assets/stylesheets/cropper.css @@ -0,0 +1,379 @@ +/*! + * Cropper v2.2.5 + * https://github.com/fengyuanchen/cropper + * + * Copyright (c) 2014-2016 Fengyuan Chen and contributors + * Released under the MIT license + * + * Date: 2016-01-18T05:42:29.639Z + */ +.cropper-container { + font-size: 0; + line-height: 0; + + position: relative; + + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + + direction: ltr !important; + -ms-touch-action: none; + touch-action: none; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; +} + +.cropper-container img { + display: block; + + width: 100%; + min-width: 0 !important; + max-width: none !important; + height: 100%; + min-height: 0 !important; + max-height: none !important; + + image-orientation: 0deg !important; +} + +.cropper-wrap-box, +.cropper-canvas, +.cropper-drag-box, +.cropper-crop-box, +.cropper-modal { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +.cropper-wrap-box { + overflow: hidden; +} + +.cropper-drag-box { + opacity: 0; + background-color: #fff; + + filter: alpha(opacity=0); +} + +.cropper-modal { + opacity: .5; + background-color: #000; + + filter: alpha(opacity=50); +} + +.cropper-view-box { + display: block; + overflow: hidden; + + width: 100%; + height: 100%; + + outline: 1px solid #39f; + outline-color: rgba(51, 153, 255, .75); +} + +.cropper-dashed { + position: absolute; + + display: block; + + opacity: .5; + border: 0 dashed #eee; + + filter: alpha(opacity=50); +} + +.cropper-dashed.dashed-h { + top: 33.33333%; + left: 0; + + width: 100%; + height: 33.33333%; + + border-top-width: 1px; + border-bottom-width: 1px; +} + +.cropper-dashed.dashed-v { + top: 0; + left: 33.33333%; + + width: 33.33333%; + height: 100%; + + border-right-width: 1px; + border-left-width: 1px; +} + +.cropper-center { + position: absolute; + top: 50%; + left: 50%; + + display: block; + + width: 0; + height: 0; + + opacity: .75; + + filter: alpha(opacity=75); +} + +.cropper-center:before, +.cropper-center:after { + position: absolute; + + display: block; + + content: ' '; + + background-color: #eee; +} + +.cropper-center:before { + top: 0; + left: -3px; + + width: 7px; + height: 1px; +} + +.cropper-center:after { + top: -3px; + left: 0; + + width: 1px; + height: 7px; +} + +.cropper-face, +.cropper-line, +.cropper-point { + position: absolute; + + display: block; + + width: 100%; + height: 100%; + + opacity: .1; + + filter: alpha(opacity=10); +} + +.cropper-face { + top: 0; + left: 0; + + background-color: #fff; +} + +.cropper-line { + background-color: #39f; +} + +.cropper-line.line-e { + top: 0; + right: -3px; + + width: 5px; + + cursor: e-resize; +} + +.cropper-line.line-n { + top: -3px; + left: 0; + + height: 5px; + + cursor: n-resize; +} + +.cropper-line.line-w { + top: 0; + left: -3px; + + width: 5px; + + cursor: w-resize; +} + +.cropper-line.line-s { + bottom: -3px; + left: 0; + + height: 5px; + + cursor: s-resize; +} + +.cropper-point { + width: 5px; + height: 5px; + + opacity: .75; + background-color: #39f; + + filter: alpha(opacity=75); +} + +.cropper-point.point-e { + top: 50%; + right: -3px; + + margin-top: -3px; + + cursor: e-resize; +} + +.cropper-point.point-n { + top: -3px; + left: 50%; + + margin-left: -3px; + + cursor: n-resize; +} + +.cropper-point.point-w { + top: 50%; + left: -3px; + + margin-top: -3px; + + cursor: w-resize; +} + +.cropper-point.point-s { + bottom: -3px; + left: 50%; + + margin-left: -3px; + + cursor: s-resize; +} + +.cropper-point.point-ne { + top: -3px; + right: -3px; + + cursor: ne-resize; +} + +.cropper-point.point-nw { + top: -3px; + left: -3px; + + cursor: nw-resize; +} + +.cropper-point.point-sw { + bottom: -3px; + left: -3px; + + cursor: sw-resize; +} + +.cropper-point.point-se { + right: -3px; + bottom: -3px; + + width: 20px; + height: 20px; + + cursor: se-resize; + + opacity: 1; + + filter: alpha(opacity=100); +} + +.cropper-point.point-se:before { + position: absolute; + right: -50%; + bottom: -50%; + + display: block; + + width: 200%; + height: 200%; + + content: ' '; + + opacity: 0; + background-color: #39f; + + filter: alpha(opacity=0); +} + +@media (min-width: 768px) { + .cropper-point.point-se { + width: 15px; + height: 15px; + } +} + +@media (min-width: 992px) { + .cropper-point.point-se { + width: 10px; + height: 10px; + } +} + +@media (min-width: 1200px) { + .cropper-point.point-se { + width: 5px; + height: 5px; + + opacity: .75; + + filter: alpha(opacity=75); + } +} + +.cropper-invisible { + opacity: 0; + + filter: alpha(opacity=0); +} + +.cropper-bg { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC'); +} + +.cropper-hide { + position: absolute; + + display: block; + + width: 0; + height: 0; +} + +.cropper-hidden { + display: none !important; +} + +.cropper-move { + cursor: move; +} + +.cropper-crop { + cursor: crosshair; +} + +.cropper-disabled .cropper-drag-box, +.cropper-disabled .cropper-face, +.cropper-disabled .cropper-line, +.cropper-disabled .cropper-point { + cursor: not-allowed; +} |