From 60dd396b301976ec67fbddcc01466bd74b40738b Mon Sep 17 00:00:00 2001 From: Noah Date: Thu, 11 Aug 2022 23:03:06 -0700 Subject: [PATCH] Photo Upload & Profile Pictures Basic photo upload support. Square cropped images still buggy. --- .gitignore | 3 +- go.mod | 4 + go.sum | 10 + pkg/config/config.go | 13 + pkg/controller/photo/upload.go | 144 ++++ pkg/middleware/authentication.go | 2 + pkg/middleware/csrf.go | 2 +- pkg/models/models.go | 1 + pkg/models/photo.go | 51 ++ pkg/models/user.go | 5 +- pkg/photo/filenames.go | 62 ++ pkg/photo/upload.go | 173 ++++ pkg/router/router.go | 2 + web/static/css/theme.css | 21 + web/static/js/croppr/croppr.css | 58 ++ web/static/js/croppr/croppr.js | 1189 ++++++++++++++++++++++++++ web/static/js/croppr/croppr.min.css | 1 + web/static/js/croppr/croppr.min.js | 1 + web/templates/account/dashboard.html | 1 + web/templates/account/profile.html | 17 +- web/templates/base.html | 5 + web/templates/photo/upload.html | 342 ++++++++ 22 files changed, 2103 insertions(+), 4 deletions(-) create mode 100644 pkg/controller/photo/upload.go create mode 100644 pkg/models/photo.go create mode 100644 pkg/photo/filenames.go create mode 100644 pkg/photo/upload.go create mode 100644 web/static/css/theme.css create mode 100644 web/static/js/croppr/croppr.css create mode 100644 web/static/js/croppr/croppr.js create mode 100644 web/static/js/croppr/croppr.min.css create mode 100644 web/static/js/croppr/croppr.min.js create mode 100644 web/templates/photo/upload.html diff --git a/.gitignore b/.gitignore index 0e3dfc5..552e12e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /gosocial +/web/static/photos database.sqlite -settings.json \ No newline at end of file +settings.json diff --git a/go.mod b/go.mod index 64d0458..a6fbf86 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,8 @@ require ( github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/disintegration/imaging v1.6.2 // indirect + github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f // indirect github.com/go-redis/redis v6.15.9+incompatible // indirect github.com/gorilla/css v1.0.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect @@ -34,6 +36,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.19 // indirect github.com/russross/blackfriday v1.5.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 // indirect github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 // indirect @@ -44,6 +47,7 @@ require ( github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 // indirect golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect diff --git a/go.sum b/go.sum index 217479a..72d2b26 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA= +github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= @@ -113,6 +117,8 @@ github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNue github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= @@ -172,6 +178,10 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU= +golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= diff --git a/pkg/config/config.go b/pkg/config/config.go index c97390e..ba48f75 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,6 +17,12 @@ const ( TemplatePath = "./web/templates" StaticPath = "./web/static" SettingsPath = "./settings.json" + + // Web path where photos are kept. Photos in DB store only their filenames, this + // is the base URL that goes in front. TODO: support setting a CDN URL prefix. + JpegQuality = 90 + PhotoWebPath = "/static/photos/" + PhotoDiskPath = "./web/static/photos" ) // Security @@ -27,6 +33,7 @@ const ( CSRFInputName = "_csrf" // html input name SessionCookieMaxAge = 60 * 60 * 24 * 30 SessionRedisKeyFormat = "session/%s" + MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB ) // Authentication @@ -42,6 +49,12 @@ var ( UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`) ) +// Photo Galleries +const ( + MaxPhotoWidth = 1280 + ProfilePhotoWidth = 512 +) + // Variables set by main.go to make them readily available. var ( RuntimeVersion string diff --git a/pkg/controller/photo/upload.go b/pkg/controller/photo/upload.go new file mode 100644 index 0000000..d6b0c04 --- /dev/null +++ b/pkg/controller/photo/upload.go @@ -0,0 +1,144 @@ +package photo + +import ( + "bytes" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/photo" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Upload photos controller. +func Upload() http.HandlerFunc { + tmpl := templates.Must("photo/upload.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var vars = map[string]interface{}{ + "Intent": r.FormValue("intent"), + "NeedsCrop": false, + } + + // Query string parameters: what is the intent of this photo upload? + // - If profile picture, the user will crop their image before posting it. + // - If regular photo, user simply picks a picture and doesn't need to crop it. + if vars["Intent"] == "profile_pic" { + vars["NeedsCrop"] = true + } + + user, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser") + } + + // Are they POSTing? + if r.Method == http.MethodPost { + var ( + caption = r.PostFormValue("caption") + isExplicit = r.PostFormValue("explicit") == "true" + visibility = r.PostFormValue("visibility") + isGallery = r.PostFormValue("gallery") == "true" + cropCoords = r.PostFormValue("crop") + confirm1 = r.PostFormValue("confirm1") == "true" + confirm2 = r.PostFormValue("confirm2") == "true" + ) + + // They checked both boxes. The browser shouldn't allow them to + // post but validate it here anyway... + if !confirm1 || !confirm2 { + session.FlashError(w, r, "You must agree to the terms to upload this picture.") + templates.Redirect(w, r.URL.Path) + return + } + + // Parse and validate crop coordinates. + var crop []int + if len(cropCoords) > 0 { + aints := strings.Split(cropCoords, ",") + if len(aints) >= 4 { + crop = []int{} + for i, aint := range aints { + if number, err := strconv.Atoi(strings.TrimSpace(aint)); err == nil { + crop = append(crop, number) + } else { + log.Error("Failure to parse crop coordinates ('%s') at number %d: %s", cropCoords, i, err) + } + } + } + } + + log.Error("parsed crop coords: %+v", crop) + + // Get their file upload. + file, header, err := r.FormFile("file") + if err != nil { + session.FlashError(w, r, "Error receiving your file: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Read the file contents. + log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename) + var buf bytes.Buffer + io.Copy(&buf, file) + + filename, cropFilename, err := photo.UploadPhoto(photo.UploadConfig{ + User: user, + Extension: filepath.Ext(header.Filename), + Data: buf.Bytes(), + Crop: crop, + }) + if err != nil { + session.FlashError(w, r, "Error in UploadPhoto: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Configuration for the DB entry. + ptmpl := models.Photo{ + UserID: user.ID, + Filename: filename, + CroppedFilename: cropFilename, + Caption: caption, + Visibility: models.PhotoVisibility(visibility), + Gallery: isGallery, + Explicit: isExplicit, + } + + // Get the filesize. + if stat, err := os.Stat(photo.DiskPath(filename)); err == nil { + ptmpl.Filesize = stat.Size() + } + + // Create it in DB! + p, err := models.CreatePhoto(ptmpl) + if err != nil { + session.FlashError(w, r, "Couldn't create Photo in DB: %s", err) + } else { + log.Info("New photo! %+v", p) + } + + // Are we uploading a profile pic? If so, set the user's pic now. + if vars["Intent"] == "profile_pic" { + log.Info("User %s is setting their profile picture", user.Username) + user.ProfilePhotoID = p.ID + user.Save() + } + + session.Flash(w, r, "Your photo has been uploaded successfully.") + templates.Redirect(w, r.URL.Path) + return + } + + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go index 1e1c8c2..fbc9b56 100644 --- a/pkg/middleware/authentication.go +++ b/pkg/middleware/authentication.go @@ -3,6 +3,7 @@ package middleware import ( "net/http" + "git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/templates" ) @@ -13,6 +14,7 @@ func LoginRequired(handler http.Handler) http.Handler { // User must be logged in. if _, err := session.CurrentUser(r); err != nil { + log.Error("LoginRequired: %s", err) errhandler := templates.MakeErrorPage("Login Required", "You must be signed in to view this page.", http.StatusForbidden) errhandler.ServeHTTP(w, r) return diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go index 9763065..ef26bf3 100644 --- a/pkg/middleware/csrf.go +++ b/pkg/middleware/csrf.go @@ -20,7 +20,7 @@ func CSRF(handler http.Handler) http.Handler { // If we are running a POST request, validate the CSRF form value. if r.Method != http.MethodGet { - r.ParseForm() + r.ParseMultipartForm(config.MultipartMaxMemory) check := r.FormValue(config.CSRFInputName) if check != token { log.Error("CSRF mismatch! %s <> %s", check, token) diff --git a/pkg/models/models.go b/pkg/models/models.go index 1f94880..b80e327 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -10,4 +10,5 @@ var DB *gorm.DB func AutoMigrate() { DB.AutoMigrate(&User{}) DB.AutoMigrate(&ProfileField{}) + DB.AutoMigrate(&Photo{}) } diff --git a/pkg/models/photo.go b/pkg/models/photo.go new file mode 100644 index 0000000..4dbe96a --- /dev/null +++ b/pkg/models/photo.go @@ -0,0 +1,51 @@ +package models + +import ( + "errors" + "time" +) + +// Photo table. +type Photo struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` + Filename string + CroppedFilename string // if cropped, e.g. for profile photo + Filesize int64 + Caption string + Flagged bool // photo has been reported by the community + Visibility PhotoVisibility + Gallery bool // photo appears in the public gallery (if public) + Explicit bool // is an explicit photo + CreatedAt time.Time + UpdatedAt time.Time +} + +// PhotoVisibility settings. +type PhotoVisibility string + +const ( + PhotoPublic PhotoVisibility = "public" // on profile page and/or public gallery + PhotoFriends = "friends" // only friends can see it + PhotoPrivate = "private" // private +) + +// CreatePhoto with most of the settings you want (not ID or timestamps) in the database. +func CreatePhoto(tmpl Photo) (*Photo, error) { + if tmpl.UserID == 0 { + return nil, errors.New("UserID required") + } + + p := &Photo{ + UserID: tmpl.UserID, + Filename: tmpl.Filename, + CroppedFilename: tmpl.CroppedFilename, + Caption: tmpl.Caption, + Visibility: tmpl.Visibility, + Gallery: tmpl.Gallery, + Explicit: tmpl.Explicit, + } + + result := DB.Create(p) + return p, result.Error +} diff --git a/pkg/models/user.go b/pkg/models/user.go index ddaf49f..4c9fe8c 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -23,12 +23,15 @@ type User struct { Name *string Birthdate time.Time Certified bool + Explicit bool // user has opted-in to see explicit content CreatedAt time.Time `gorm:"index"` UpdatedAt time.Time `gorm:"index"` LastLoginAt time.Time `gorm:"index"` // Relational tables. - ProfileField []ProfileField + ProfileField []ProfileField + ProfilePhotoID uint64 + ProfilePhoto Photo } // UserStatus options. diff --git a/pkg/photo/filenames.go b/pkg/photo/filenames.go new file mode 100644 index 0000000..9faff3b --- /dev/null +++ b/pkg/photo/filenames.go @@ -0,0 +1,62 @@ +package photo + +import ( + "fmt" + "os" + "path/filepath" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/log" + "github.com/google/uuid" +) + +// Functions that deal with giving photos their: +// - Filename +// - URL prefix (/static/photos or maybe CDN?) + +/* +NewFilename generates a Filename with an extension (".jpg"). + +The filename is a random UUID string, with a couple of directory +paths in front consisting of the first few characters (to keep +directory sizes under control over time). Example: + +"91/b9/91b908db-4007-41b2-bbca-71a6526e59aa.jpg" +*/ +func NewFilename(ext string) string { + basename := uuid.New().String() + first2 := basename[:2] + next2 := basename[2:4] + log.Debug("photo.NewFilename: UUID %s first2 %d next2 %d", basename, first2, next2) + return fmt.Sprintf( + "%s/%s/%s%s", + first2, next2, basename, ext, + ) +} + +// DiskPath returns the local disk path to a photo Filename. +func DiskPath(filename string) string { + return config.PhotoDiskPath + "/" + filename +} + +/* +EnsurePath makes sure the local './web/static/photos/' path is ready +to write an image to, taking into account path parameters in the +image filename. + +The filename is like from NewFilename(), just the photo Filename portion. +It is appended to the PhotoDiskPath. + +Returns the full path ("./web/static/photos/...") ready for the caller +to use it for writing. +*/ +func EnsurePath(filename string) (string, error) { + fullpath := DiskPath(filename) + dir := filepath.Dir(fullpath) + log.Debug("photo.EnsurePath: check that %s exists", dir) + if err := os.MkdirAll(dir, 0755); err != nil { + return fullpath, fmt.Errorf("EnsurePath: %s", err) + } else { + return fullpath, nil + } +} diff --git a/pkg/photo/upload.go b/pkg/photo/upload.go new file mode 100644 index 0000000..f3fa198 --- /dev/null +++ b/pkg/photo/upload.go @@ -0,0 +1,173 @@ +package photo + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "os" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" + "github.com/edwvee/exiffix" + "golang.org/x/image/draw" +) + +type UploadConfig struct { + User *models.User + Extension string // 'jpg' or 'png' only. + Data []byte + Crop []int // x, y, w, h +} + +// UploadPhoto handles an incoming photo to add to a user's account. +// +// Returns: +// - NewFilename() of the created photo file on disk. +// - NewFilename() of the cropped version, or "" if not cropping. +// - error on errors +func UploadPhoto(cfg UploadConfig) (string, string, error) { + // Validate and normalize the extension. + var extension = cfg.Extension + switch cfg.Extension { + case ".jpg": + fallthrough + case ".jpe": + fallthrough + case ".jpeg": + extension = ".jpg" + case ".png": + extension = ".png" + default: + return "", "", errors.New("unsupported image extension, must be jpg or png") + } + + // Decide on a filename for this photo. + var ( + filename = NewFilename(extension) + cropFilename = NewFilename(extension) + ) + + // Decode the image using exiffix, which will auto-rotate jpeg images + // based on their EXIF tags. + reader := bytes.NewReader(cfg.Data) + origImage, _, err := exiffix.Decode(reader) + if err != nil { + return "", "", err + } + + // Read the config to get the image width. + reader.Seek(0, io.SeekStart) + var width, height int + if decoded, _, err := image.DecodeConfig(reader); err == nil { + width, height = decoded.Width, decoded.Height + } else { + return "", "", err + } + + // Find the longest edge, if it's too large (over 1280px) + // cap it to the max and scale the other dimension proportionally. + log.Debug("UploadPhoto: taking a %dx%d image to name it %s", width, height, filename) + if width >= height { + if width > config.MaxPhotoWidth { + newWidth := config.MaxPhotoWidth + log.Debug("(%d / %d) * %d", width, height, newWidth) + height = int((float64(height) / float64(width)) * float64(newWidth)) + width = newWidth + log.Debug("Its longest is width, scale to %sx%s", width, height) + } + } else { + if height > config.MaxPhotoWidth { + newHeight := config.MaxPhotoWidth + width = int((float64(width) / float64(height)) * float64(newHeight)) + height = newHeight + log.Debug("Its longest is height, scale to %sx%s", width, height) + } + } + + // Scale the image. + scaledImg := Scale(origImage, image.Rect(0, 0, width, height), draw.ApproxBiLinear) + + // Write the image to disk. + if err := ToDisk(filename, extension, scaledImg); err != nil { + return "", "", err + } + + // Are we producing a cropped image, too? + log.Error("Are we to crop? %+v", cfg.Crop) + if cfg.Crop != nil && len(cfg.Crop) >= 4 { + log.Debug("Also cropping this image to %+v", cfg.Crop) + var ( + x = cfg.Crop[0] + y = cfg.Crop[1] + w = cfg.Crop[2] + h = cfg.Crop[3] + ) + croppedImg := Crop(origImage, image.Rect( + x, + y, + w, + h, + )) + + // Write that to disk, too. + log.Debug("Writing cropped image to disk: %s", cropFilename) + if err := ToDisk(cropFilename, extension, croppedImg); err != nil { + return filename, "", err + } + + // Return both filenames! + return filename, cropFilename, nil + } + + // Not cropping, return only the first filename. + return filename, "", nil +} + +// Scale down an image. Example: +// +// scaled := Scale(src, image.Rect(0, 0, 200, 200), draw.ApproxBiLinear) +func Scale(src image.Image, rect image.Rectangle, scale draw.Scaler) image.Image { + dst := image.NewRGBA(rect) + scale.Scale(dst, rect, src, src.Bounds(), draw.Over, nil) + return dst +} + +// Crop an image, returning the new image. Example: +// +// cropped := Crop() +func Crop(src image.Image, rect image.Rectangle) image.Image { + dst := image.NewRGBA(rect) + draw.Copy(dst, image.Point{}, src, rect, draw.Over, nil) + return dst +} + +// ToDisk commits a photo image to disk in the right file format. +// +// Filename is like NewFilename() and it goes to e.g. "./web/static/photos/" +func ToDisk(filename string, extension string, img image.Image) error { + if path, err := EnsurePath(filename); err == nil { + fh, err := os.Create(path) + if err != nil { + return err + } + defer fh.Close() + + switch extension { + case ".jpg": + jpeg.Encode(fh, img, &jpeg.Options{ + Quality: config.JpegQuality, + }) + case ".png": + png.Encode(fh, img) + } + } else { + return fmt.Errorf("couldn't EnsurePath: %s", err) + } + + return nil +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 177e4f0..46dbd41 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -8,6 +8,7 @@ import ( "git.kirsle.net/apps/gosocial/pkg/controller/account" "git.kirsle.net/apps/gosocial/pkg/controller/api" "git.kirsle.net/apps/gosocial/pkg/controller/index" + "git.kirsle.net/apps/gosocial/pkg/controller/photo" "git.kirsle.net/apps/gosocial/pkg/middleware" ) @@ -24,6 +25,7 @@ func New() http.Handler { mux.Handle("/me", middleware.LoginRequired(account.Dashboard())) mux.Handle("/settings", middleware.LoginRequired(account.Settings())) mux.Handle("/u/", middleware.LoginRequired(account.Profile())) + mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload())) // JSON API endpoints. mux.HandleFunc("/v1/version", api.Version()) diff --git a/web/static/css/theme.css b/web/static/css/theme.css new file mode 100644 index 0000000..3cdd903 --- /dev/null +++ b/web/static/css/theme.css @@ -0,0 +1,21 @@ +/* Custom CSS styles */ + +/* Container for large profile pic on user pages */ +.profile-photo { + width: 150px; + height: 150px; + display: block; + border: 1px solid #000; + background-color: #fff; + padding: 4px; + position: relative; +} +.profile-photo img { + max-width: 100%; + height: auto; +} +.profile-photo .corner { + position: absolute; + top: 0; + right: 0; +} \ No newline at end of file diff --git a/web/static/js/croppr/croppr.css b/web/static/js/croppr/croppr.css new file mode 100644 index 0000000..19eb2f7 --- /dev/null +++ b/web/static/js/croppr/croppr.css @@ -0,0 +1,58 @@ +.croppr-container * { + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + box-sizing: border-box; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; +} + +.croppr-container img { + vertical-align: middle; + max-width: 100%; +} + +.croppr { + position: relative; + display: inline-block; +} + +.croppr-overlay { + background: rgba(0,0,0,0.5); + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + cursor: crosshair; +} + +.croppr-region { + border: 1px dashed rgba(0, 0, 0, 0.5); + position: absolute; + z-index: 3; + cursor: move; + top: 0; +} + +.croppr-imageClipped { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + pointer-events: none; +} + +.croppr-handle { + border: 1px solid black; + background-color: white; + width: 10px; + height: 10px; + position: absolute; + z-index: 4; + top: 0; +} \ No newline at end of file diff --git a/web/static/js/croppr/croppr.js b/web/static/js/croppr/croppr.js new file mode 100644 index 0000000..f5c3f3b --- /dev/null +++ b/web/static/js/croppr/croppr.js @@ -0,0 +1,1189 @@ +/** + * Croppr.js + * https://github.com/jamesssooi/Croppr.js + * + * A JavaScript image cropper that's lightweight, awesome, and has + * zero dependencies. + * + * (C) 2017 James Ooi. Released under the MIT License. + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.Croppr = factory()); +}(this, (function () { 'use strict'; + +(function () { + var lastTime = 0; + var vendors = ['ms', 'moz', 'webkit', 'o']; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame']; + window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame'] || window[vendors[x] + 'CancelRequestAnimationFrame']; + } + if (!window.requestAnimationFrame) window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; +})(); +(function () { + if (typeof window.CustomEvent === "function") return false; + function CustomEvent(event, params) { + params = params || { bubbles: false, cancelable: false, detail: undefined }; + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); + return evt; + } + CustomEvent.prototype = window.Event.prototype; + window.CustomEvent = CustomEvent; +})(); +(function (window) { + try { + new CustomEvent('test'); + return false; + } catch (e) {} + function MouseEvent(eventType, params) { + params = params || { bubbles: false, cancelable: false }; + var mouseEvent = document.createEvent('MouseEvent'); + mouseEvent.initMouseEvent(eventType, params.bubbles, params.cancelable, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); + return mouseEvent; + } + MouseEvent.prototype = Event.prototype; + window.MouseEvent = MouseEvent; +})(window); + +var classCallCheck = function (instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } +}; + +var createClass = function () { + function defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + return function (Constructor, protoProps, staticProps) { + if (protoProps) defineProperties(Constructor.prototype, protoProps); + if (staticProps) defineProperties(Constructor, staticProps); + return Constructor; + }; +}(); + + + + + + + +var get = function get(object, property, receiver) { + if (object === null) object = Function.prototype; + var desc = Object.getOwnPropertyDescriptor(object, property); + + if (desc === undefined) { + var parent = Object.getPrototypeOf(object); + + if (parent === null) { + return undefined; + } else { + return get(parent, property, receiver); + } + } else if ("value" in desc) { + return desc.value; + } else { + var getter = desc.get; + + if (getter === undefined) { + return undefined; + } + + return getter.call(receiver); + } +}; + +var inherits = function (subClass, superClass) { + if (typeof superClass !== "function" && superClass !== null) { + throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); + } + + subClass.prototype = Object.create(superClass && superClass.prototype, { + constructor: { + value: subClass, + enumerable: false, + writable: true, + configurable: true + } + }); + if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; +}; + + + + + + + + + + + +var possibleConstructorReturn = function (self, call) { + if (!self) { + throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); + } + + return call && (typeof call === "object" || typeof call === "function") ? call : self; +}; + + + + + +var slicedToArray = function () { + function sliceIterator(arr, i) { + var _arr = []; + var _n = true; + var _d = false; + var _e = undefined; + + try { + for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { + _arr.push(_s.value); + + if (i && _arr.length === i) break; + } + } catch (err) { + _d = true; + _e = err; + } finally { + try { + if (!_n && _i["return"]) _i["return"](); + } finally { + if (_d) throw _e; + } + } + + return _arr; + } + + return function (arr, i) { + if (Array.isArray(arr)) { + return arr; + } else if (Symbol.iterator in Object(arr)) { + return sliceIterator(arr, i); + } else { + throw new TypeError("Invalid attempt to destructure non-iterable instance"); + } + }; +}(); + +var Handle = +/** + * Creates a new Handle instance. + * @constructor + * @param {Array} position The x and y ratio position of the handle + * within the crop region. Accepts a value between 0 to 1 in the order + * of [X, Y]. + * @param {Array} constraints Define the side of the crop region that + * is to be affected by this handle. Accepts a value of 0 or 1 in the + * order of [TOP, RIGHT, BOTTOM, LEFT]. + * @param {String} cursor The CSS cursor of this handle. + * @param {Element} eventBus The element to dispatch events to. + */ +function Handle(position, constraints, cursor, eventBus) { + classCallCheck(this, Handle); + var self = this; + this.position = position; + this.constraints = constraints; + this.cursor = cursor; + this.eventBus = eventBus; + this.el = document.createElement('div'); + this.el.className = 'croppr-handle'; + this.el.style.cursor = cursor; + this.el.addEventListener('mousedown', onMouseDown); + function onMouseDown(e) { + e.stopPropagation(); + document.addEventListener('mouseup', onMouseUp); + document.addEventListener('mousemove', onMouseMove); + self.eventBus.dispatchEvent(new CustomEvent('handlestart', { + detail: { handle: self } + })); + } + function onMouseUp(e) { + e.stopPropagation(); + document.removeEventListener('mouseup', onMouseUp); + document.removeEventListener('mousemove', onMouseMove); + self.eventBus.dispatchEvent(new CustomEvent('handleend', { + detail: { handle: self } + })); + } + function onMouseMove(e) { + e.stopPropagation(); + self.eventBus.dispatchEvent(new CustomEvent('handlemove', { + detail: { mouseX: e.clientX, mouseY: e.clientY } + })); + } +}; + +var Box = function () { + /** + * Creates a new Box instance. + * @constructor + * @param {Number} x1 + * @param {Number} y1 + * @param {Number} x2 + * @param {Number} y2 + */ + function Box(x1, y1, x2, y2) { + classCallCheck(this, Box); + this.x1 = x1; + this.y1 = y1; + this.x2 = x2; + this.y2 = y2; + } + /** + * Sets the new dimensions of the box. + * @param {Number} x1 + * @param {Number} y1 + * @param {Number} x2 + * @param {Number} y2 + */ + createClass(Box, [{ + key: 'set', + value: function set$$1() { + var x1 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + var y1 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + var x2 = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null; + var y2 = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; + this.x1 = x1 == null ? this.x1 : x1; + this.y1 = y1 == null ? this.y1 : y1; + this.x2 = x2 == null ? this.x2 : x2; + this.y2 = y2 == null ? this.y2 : y2; + return this; + } + /** + * Calculates the width of the box. + * @returns {Number} + */ + }, { + key: 'width', + value: function width() { + return Math.abs(this.x2 - this.x1); + } + /** + * Calculates the height of the box. + * @returns {Number} + */ + }, { + key: 'height', + value: function height() { + return Math.abs(this.y2 - this.y1); + } + /** + * Resizes the box to a new size. + * @param {Number} newWidth + * @param {Number} newHeight + * @param {Array} [origin] The origin point to resize from. + * Defaults to [0, 0] (top left). + */ + }, { + key: 'resize', + value: function resize(newWidth, newHeight) { + var origin = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : [0, 0]; + var fromX = this.x1 + this.width() * origin[0]; + var fromY = this.y1 + this.height() * origin[1]; + this.x1 = fromX - newWidth * origin[0]; + this.y1 = fromY - newHeight * origin[1]; + this.x2 = this.x1 + newWidth; + this.y2 = this.y1 + newHeight; + return this; + } + /** + * Scale the box by a factor. + * @param {Number} factor + * @param {Array} [origin] The origin point to resize from. + * Defaults to [0, 0] (top left). + */ + }, { + key: 'scale', + value: function scale(factor) { + var origin = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [0, 0]; + var newWidth = this.width() * factor; + var newHeight = this.height() * factor; + this.resize(newWidth, newHeight, origin); + return this; + } + }, { + key: 'move', + value: function move() { + var x = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + var y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + var width = this.width(); + var height = this.height(); + x = x === null ? this.x1 : x; + y = y === null ? this.y1 : y; + this.x1 = x; + this.y1 = y; + this.x2 = x + width; + this.y2 = y + height; + return this; + } + /** + * Get relative x and y coordinates of a given point within the box. + * @param {Array} point The x and y ratio position within the box. + * @returns {Array} The x and y coordinates [x, y]. + */ + }, { + key: 'getRelativePoint', + value: function getRelativePoint() { + var point = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [0, 0]; + var x = this.width() * point[0]; + var y = this.height() * point[1]; + return [x, y]; + } + /** + * Get absolute x and y coordinates of a given point within the box. + * @param {Array} point The x and y ratio position within the box. + * @returns {Array} The x and y coordinates [x, y]. + */ + }, { + key: 'getAbsolutePoint', + value: function getAbsolutePoint() { + var point = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [0, 0]; + var x = this.x1 + this.width() * point[0]; + var y = this.y1 + this.height() * point[1]; + return [x, y]; + } + /** + * Constrain the box to a fixed ratio. + * @param {Number} ratio + * @param {Array} [origin] The origin point to resize from. + * Defaults to [0, 0] (top left). + * @param {String} [grow] The axis to grow to maintain the ratio. + * Defaults to 'height'. + */ + }, { + key: 'constrainToRatio', + value: function constrainToRatio(ratio) { + var origin = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [0, 0]; + var grow = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'height'; + if (ratio === null) { + return; + } + var width = this.width(); + var height = this.height(); + switch (grow) { + case 'height': + this.resize(this.width(), this.width() * ratio, origin); + break; + case 'width': + this.resize(this.height() * 1 / ratio, this.height(), origin); + break; + default: + this.resize(this.width(), this.width() * ratio, origin); + } + return this; + } + /** + * Constrain the box within a boundary. + * @param {Number} boundaryWidth + * @param {Number} boundaryHeight + * @param {Array} [origin] The origin point to resize from. + * Defaults to [0, 0] (top left). + */ + }, { + key: 'constrainToBoundary', + value: function constrainToBoundary(boundaryWidth, boundaryHeight) { + var origin = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : [0, 0]; + var _getAbsolutePoint = this.getAbsolutePoint(origin), + _getAbsolutePoint2 = slicedToArray(_getAbsolutePoint, 2), + originX = _getAbsolutePoint2[0], + originY = _getAbsolutePoint2[1]; + var maxIfLeft = originX; + var maxIfTop = originY; + var maxIfRight = boundaryWidth - originX; + var maxIfBottom = boundaryHeight - originY; + var directionX = -2 * origin[0] + 1; + var directionY = -2 * origin[1] + 1; + var maxWidth = null, + maxHeight = null; + switch (directionX) { + case -1: + maxWidth = maxIfLeft;break; + case 0: + maxWidth = Math.min(maxIfLeft, maxIfRight) * 2;break; + case +1: + maxWidth = maxIfRight;break; + } + switch (directionY) { + case -1: + maxHeight = maxIfTop;break; + case 0: + maxHeight = Math.min(maxIfTop, maxIfBottom) * 2;break; + case +1: + maxHeight = maxIfBottom;break; + } + if (this.width() > maxWidth) { + var factor = maxWidth / this.width(); + this.scale(factor, origin); + } + if (this.height() > maxHeight) { + var _factor = maxHeight / this.height(); + this.scale(_factor, origin); + } + return this; + } + /** + * Constrain the box to a maximum/minimum size. + * @param {Number} [maxWidth] + * @param {Number} [maxHeight] + * @param {Number} [minWidth] + * @param {Number} [minHeight] + * @param {Array} [origin] The origin point to resize from. + * Defaults to [0, 0] (top left). + * @param {Number} [ratio] Ratio to maintain. + */ + }, { + key: 'constrainToSize', + value: function constrainToSize() { + var maxWidth = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + var maxHeight = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + var minWidth = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null; + var minHeight = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; + var origin = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : [0, 0]; + var ratio = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : null; + if (ratio) { + if (ratio > 1) { + maxWidth = maxHeight * 1 / ratio; + minHeight = minHeight * ratio; + } else if (ratio < 1) { + maxHeight = maxWidth * ratio; + minWidth = minHeight * 1 / ratio; + } + } + if (maxWidth && this.width() > maxWidth) { + var newWidth = maxWidth, + newHeight = ratio === null ? this.height() : maxHeight; + this.resize(newWidth, newHeight, origin); + } + if (maxHeight && this.height() > maxHeight) { + var _newWidth = ratio === null ? this.width() : maxWidth, + _newHeight = maxHeight; + this.resize(_newWidth, _newHeight, origin); + } + if (minWidth && this.width() < minWidth) { + var _newWidth2 = minWidth, + _newHeight2 = ratio === null ? this.height() : minHeight; + this.resize(_newWidth2, _newHeight2, origin); + } + if (minHeight && this.height() < minHeight) { + var _newWidth3 = ratio === null ? this.width() : minWidth, + _newHeight3 = minHeight; + this.resize(_newWidth3, _newHeight3, origin); + } + return this; + } + }]); + return Box; +}(); + +/** + * Binds an element's touch events to be simulated as mouse events. + * @param {Element} element + */ +function enableTouch(element) { + element.addEventListener('touchstart', simulateMouseEvent); + element.addEventListener('touchend', simulateMouseEvent); + element.addEventListener('touchmove', simulateMouseEvent); +} +/** + * Translates a touch event to a mouse event. + * @param {Event} e + */ +function simulateMouseEvent(e) { + e.preventDefault(); + var touch = e.changedTouches[0]; + var eventMap = { + 'touchstart': 'mousedown', + 'touchmove': 'mousemove', + 'touchend': 'mouseup' + }; + touch.target.dispatchEvent(new MouseEvent(eventMap[e.type], { + bubbles: true, + cancelable: true, + view: window, + clientX: touch.clientX, + clientY: touch.clientY, + screenX: touch.screenX, + screenY: touch.screenY + })); +} + +/** + * Define a list of handles to create. + * + * @property {Array} position - The x and y ratio position of the handle within + * the crop region. Accepts a value between 0 to 1 in the order of [X, Y]. + * @property {Array} constraints - Define the side of the crop region that is to + * be affected by this handle. Accepts a value of 0 or 1 in the order of + * [TOP, RIGHT, BOTTOM, LEFT]. + * @property {String} cursor - The CSS cursor of this handle. + */ +var HANDLES = [{ position: [0.0, 0.0], constraints: [1, 0, 0, 1], cursor: 'nw-resize' }, { position: [0.5, 0.0], constraints: [1, 0, 0, 0], cursor: 'n-resize' }, { position: [1.0, 0.0], constraints: [1, 1, 0, 0], cursor: 'ne-resize' }, { position: [1.0, 0.5], constraints: [0, 1, 0, 0], cursor: 'e-resize' }, { position: [1.0, 1.0], constraints: [0, 1, 1, 0], cursor: 'se-resize' }, { position: [0.5, 1.0], constraints: [0, 0, 1, 0], cursor: 's-resize' }, { position: [0.0, 1.0], constraints: [0, 0, 1, 1], cursor: 'sw-resize' }, { position: [0.0, 0.5], constraints: [0, 0, 0, 1], cursor: 'w-resize' }]; +var CropprCore = function () { + function CropprCore(element, options) { + var _this = this; + var deferred = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + classCallCheck(this, CropprCore); + this.options = CropprCore.parseOptions(options || {}); + if (!element.nodeName) { + element = document.querySelector(element); + if (element == null) { + throw 'Unable to find element.'; + } + } + if (!element.getAttribute('src')) { + throw 'Image src not provided.'; + } + this._initialized = false; + this._restore = { + parent: element.parentNode, + element: element + }; + if (!deferred) { + if (element.width === 0 || element.height === 0) { + element.onload = function () { + _this.initialize(element); + }; + } else { + this.initialize(element); + } + } + } + createClass(CropprCore, [{ + key: 'initialize', + value: function initialize(element) { + this.createDOM(element); + this.options.convertToPixels(this.cropperEl); + this.attachHandlerEvents(); + this.attachRegionEvents(); + this.attachOverlayEvents(); + this.box = this.initializeBox(this.options); + this.redraw(); + this._initialized = true; + if (this.options.onInitialize !== null) { + this.options.onInitialize(this); + } + } + }, { + key: 'createDOM', + value: function createDOM(targetEl) { + this.containerEl = document.createElement('div'); + this.containerEl.className = 'croppr-container'; + this.eventBus = this.containerEl; + enableTouch(this.containerEl); + this.cropperEl = document.createElement('div'); + this.cropperEl.className = 'croppr'; + this.imageEl = document.createElement('img'); + this.imageEl.setAttribute('src', targetEl.getAttribute('src')); + this.imageEl.setAttribute('alt', targetEl.getAttribute('alt')); + this.imageEl.className = 'croppr-image'; + this.imageClippedEl = this.imageEl.cloneNode(); + this.imageClippedEl.className = 'croppr-imageClipped'; + this.regionEl = document.createElement('div'); + this.regionEl.className = 'croppr-region'; + this.overlayEl = document.createElement('div'); + this.overlayEl.className = 'croppr-overlay'; + var handleContainerEl = document.createElement('div'); + handleContainerEl.className = 'croppr-handleContainer'; + this.handles = []; + for (var i = 0; i < HANDLES.length; i++) { + var handle = new Handle(HANDLES[i].position, HANDLES[i].constraints, HANDLES[i].cursor, this.eventBus); + this.handles.push(handle); + handleContainerEl.appendChild(handle.el); + } + this.cropperEl.appendChild(this.imageEl); + this.cropperEl.appendChild(this.imageClippedEl); + this.cropperEl.appendChild(this.regionEl); + this.cropperEl.appendChild(this.overlayEl); + this.cropperEl.appendChild(handleContainerEl); + this.containerEl.appendChild(this.cropperEl); + targetEl.parentElement.replaceChild(this.containerEl, targetEl); + } + /** + * Changes the image src. + * @param {String} src + */ + }, { + key: 'setImage', + value: function setImage(src) { + var _this2 = this; + this.imageEl.onload = function () { + _this2.box = _this2.initializeBox(_this2.options); + _this2.redraw(); + }; + this.imageEl.src = src; + this.imageClippedEl.src = src; + return this; + } + }, { + key: 'destroy', + value: function destroy() { + this._restore.parent.replaceChild(this._restore.element, this.containerEl); + } + /** + * Create a new box region with a set of options. + * @param {Object} opts The options. + * @returns {Box} + */ + }, { + key: 'initializeBox', + value: function initializeBox(opts) { + var width = opts.startSize.width; + var height = opts.startSize.height; + var box = new Box(0, 0, width, height); + box.constrainToRatio(opts.aspectRatio, [0.5, 0.5]); + var min = opts.minSize; + var max = opts.maxSize; + box.constrainToSize(max.width, max.height, min.width, min.height, [0.5, 0.5], opts.aspectRatio); + var parentWidth = this.cropperEl.offsetWidth; + var parentHeight = this.cropperEl.offsetHeight; + box.constrainToBoundary(parentWidth, parentHeight, [0.5, 0.5]); + var x = this.cropperEl.offsetWidth / 2 - box.width() / 2; + var y = this.cropperEl.offsetHeight / 2 - box.height() / 2; + box.move(x, y); + return box; + } + }, { + key: 'redraw', + value: function redraw() { + var _this3 = this; + var width = Math.round(this.box.width()), + height = Math.round(this.box.height()), + x1 = Math.round(this.box.x1), + y1 = Math.round(this.box.y1), + x2 = Math.round(this.box.x2), + y2 = Math.round(this.box.y2); + window.requestAnimationFrame(function () { + _this3.regionEl.style.transform = 'translate(' + x1 + 'px, ' + y1 + 'px)'; + _this3.regionEl.style.width = width + 'px'; + _this3.regionEl.style.height = height + 'px'; + _this3.imageClippedEl.style.clip = 'rect(' + y1 + 'px, ' + x2 + 'px, ' + y2 + 'px, ' + x1 + 'px)'; + var center = _this3.box.getAbsolutePoint([.5, .5]); + var xSign = center[0] - _this3.cropperEl.offsetWidth / 2 >> 31; + var ySign = center[1] - _this3.cropperEl.offsetHeight / 2 >> 31; + var quadrant = (xSign ^ ySign) + ySign + ySign + 4; + var foregroundHandleIndex = -2 * quadrant + 8; + for (var i = 0; i < _this3.handles.length; i++) { + var handle = _this3.handles[i]; + var handleWidth = handle.el.offsetWidth; + var handleHeight = handle.el.offsetHeight; + var left = x1 + width * handle.position[0] - handleWidth / 2; + var top = y1 + height * handle.position[1] - handleHeight / 2; + handle.el.style.transform = 'translate(' + Math.round(left) + 'px, ' + Math.round(top) + 'px)'; + handle.el.style.zIndex = foregroundHandleIndex == i ? 5 : 4; + } + }); + } + }, { + key: 'attachHandlerEvents', + value: function attachHandlerEvents() { + var eventBus = this.eventBus; + eventBus.addEventListener('handlestart', this.onHandleMoveStart.bind(this)); + eventBus.addEventListener('handlemove', this.onHandleMoveMoving.bind(this)); + eventBus.addEventListener('handleend', this.onHandleMoveEnd.bind(this)); + } + }, { + key: 'attachRegionEvents', + value: function attachRegionEvents() { + var eventBus = this.eventBus; + var self = this; + this.regionEl.addEventListener('mousedown', onMouseDown); + eventBus.addEventListener('regionstart', this.onRegionMoveStart.bind(this)); + eventBus.addEventListener('regionmove', this.onRegionMoveMoving.bind(this)); + eventBus.addEventListener('regionend', this.onRegionMoveEnd.bind(this)); + function onMouseDown(e) { + e.stopPropagation(); + document.addEventListener('mouseup', onMouseUp); + document.addEventListener('mousemove', onMouseMove); + eventBus.dispatchEvent(new CustomEvent('regionstart', { + detail: { mouseX: e.clientX, mouseY: e.clientY } + })); + } + function onMouseMove(e) { + e.stopPropagation(); + eventBus.dispatchEvent(new CustomEvent('regionmove', { + detail: { mouseX: e.clientX, mouseY: e.clientY } + })); + } + function onMouseUp(e) { + e.stopPropagation(); + document.removeEventListener('mouseup', onMouseUp); + document.removeEventListener('mousemove', onMouseMove); + eventBus.dispatchEvent(new CustomEvent('regionend', { + detail: { mouseX: e.clientX, mouseY: e.clientY } + })); + } + } + }, { + key: 'attachOverlayEvents', + value: function attachOverlayEvents() { + var SOUTHEAST_HANDLE_IDX = 4; + var self = this; + var tmpBox = null; + this.overlayEl.addEventListener('mousedown', onMouseDown); + function onMouseDown(e) { + e.stopPropagation(); + document.addEventListener('mouseup', onMouseUp); + document.addEventListener('mousemove', onMouseMove); + var container = self.cropperEl.getBoundingClientRect(); + var mouseX = e.clientX - container.left; + var mouseY = e.clientY - container.top; + tmpBox = self.box; + self.box = new Box(mouseX, mouseY, mouseX + 1, mouseY + 1); + self.eventBus.dispatchEvent(new CustomEvent('handlestart', { + detail: { handle: self.handles[SOUTHEAST_HANDLE_IDX] } + })); + } + function onMouseMove(e) { + e.stopPropagation(); + self.eventBus.dispatchEvent(new CustomEvent('handlemove', { + detail: { mouseX: e.clientX, mouseY: e.clientY } + })); + } + function onMouseUp(e) { + e.stopPropagation(); + document.removeEventListener('mouseup', onMouseUp); + document.removeEventListener('mousemove', onMouseMove); + if (self.box.width() === 1 && self.box.height() === 1) { + self.box = tmpBox; + return; + } + self.eventBus.dispatchEvent(new CustomEvent('handleend', { + detail: { mouseX: e.clientX, mouseY: e.clientY } + })); + } + } + }, { + key: 'onHandleMoveStart', + value: function onHandleMoveStart(e) { + var handle = e.detail.handle; + var originPoint = [1 - handle.position[0], 1 - handle.position[1]]; + var _box$getAbsolutePoint = this.box.getAbsolutePoint(originPoint), + _box$getAbsolutePoint2 = slicedToArray(_box$getAbsolutePoint, 2), + originX = _box$getAbsolutePoint2[0], + originY = _box$getAbsolutePoint2[1]; + this.activeHandle = { handle: handle, originPoint: originPoint, originX: originX, originY: originY }; + if (this.options.onCropStart !== null) { + this.options.onCropStart(this.getValue()); + } + } + }, { + key: 'onHandleMoveMoving', + value: function onHandleMoveMoving(e) { + var _e$detail = e.detail, + mouseX = _e$detail.mouseX, + mouseY = _e$detail.mouseY; + var container = this.cropperEl.getBoundingClientRect(); + mouseX = mouseX - container.left; + mouseY = mouseY - container.top; + if (mouseX < 0) { + mouseX = 0; + } else if (mouseX > container.width) { + mouseX = container.width; + } + if (mouseY < 0) { + mouseY = 0; + } else if (mouseY > container.height) { + mouseY = container.height; + } + var origin = this.activeHandle.originPoint.slice(); + var originX = this.activeHandle.originX; + var originY = this.activeHandle.originY; + var handle = this.activeHandle.handle; + var TOP_MOVABLE = handle.constraints[0] === 1; + var RIGHT_MOVABLE = handle.constraints[1] === 1; + var BOTTOM_MOVABLE = handle.constraints[2] === 1; + var LEFT_MOVABLE = handle.constraints[3] === 1; + var MULTI_AXIS = (LEFT_MOVABLE || RIGHT_MOVABLE) && (TOP_MOVABLE || BOTTOM_MOVABLE); + var x1 = LEFT_MOVABLE || RIGHT_MOVABLE ? originX : this.box.x1; + var x2 = LEFT_MOVABLE || RIGHT_MOVABLE ? originX : this.box.x2; + var y1 = TOP_MOVABLE || BOTTOM_MOVABLE ? originY : this.box.y1; + var y2 = TOP_MOVABLE || BOTTOM_MOVABLE ? originY : this.box.y2; + x1 = LEFT_MOVABLE ? mouseX : x1; + x2 = RIGHT_MOVABLE ? mouseX : x2; + y1 = TOP_MOVABLE ? mouseY : y1; + y2 = BOTTOM_MOVABLE ? mouseY : y2; + var isFlippedX = false, + isFlippedY = false; + if (LEFT_MOVABLE || RIGHT_MOVABLE) { + isFlippedX = LEFT_MOVABLE ? mouseX > originX : mouseX < originX; + } + if (TOP_MOVABLE || BOTTOM_MOVABLE) { + isFlippedY = TOP_MOVABLE ? mouseY > originY : mouseY < originY; + } + if (isFlippedX) { + var tmp = x1;x1 = x2;x2 = tmp; + origin[0] = 1 - origin[0]; + } + if (isFlippedY) { + var _tmp = y1;y1 = y2;y2 = _tmp; + origin[1] = 1 - origin[1]; + } + var box = new Box(x1, y1, x2, y2); + if (this.options.aspectRatio) { + var ratio = this.options.aspectRatio; + var isVerticalMovement = false; + if (MULTI_AXIS) { + isVerticalMovement = mouseY > box.y1 + ratio * box.width() || mouseY < box.y2 - ratio * box.width(); + } else if (TOP_MOVABLE || BOTTOM_MOVABLE) { + isVerticalMovement = true; + } + var ratioMode = isVerticalMovement ? 'width' : 'height'; + box.constrainToRatio(ratio, origin, ratioMode); + } + var min = this.options.minSize; + var max = this.options.maxSize; + box.constrainToSize(max.width, max.height, min.width, min.height, origin, this.options.aspectRatio); + var parentWidth = this.cropperEl.offsetWidth; + var parentHeight = this.cropperEl.offsetHeight; + box.constrainToBoundary(parentWidth, parentHeight, origin); + this.box = box; + this.redraw(); + if (this.options.onCropMove !== null) { + this.options.onCropMove(this.getValue()); + } + } + }, { + key: 'onHandleMoveEnd', + value: function onHandleMoveEnd(e) { + if (this.options.onCropEnd !== null) { + this.options.onCropEnd(this.getValue()); + } + } + }, { + key: 'onRegionMoveStart', + value: function onRegionMoveStart(e) { + var _e$detail2 = e.detail, + mouseX = _e$detail2.mouseX, + mouseY = _e$detail2.mouseY; + var container = this.cropperEl.getBoundingClientRect(); + mouseX = mouseX - container.left; + mouseY = mouseY - container.top; + this.currentMove = { + offsetX: mouseX - this.box.x1, + offsetY: mouseY - this.box.y1 + }; + if (this.options.onCropStart !== null) { + this.options.onCropStart(this.getValue()); + } + } + }, { + key: 'onRegionMoveMoving', + value: function onRegionMoveMoving(e) { + var _e$detail3 = e.detail, + mouseX = _e$detail3.mouseX, + mouseY = _e$detail3.mouseY; + var _currentMove = this.currentMove, + offsetX = _currentMove.offsetX, + offsetY = _currentMove.offsetY; + var container = this.cropperEl.getBoundingClientRect(); + mouseX = mouseX - container.left; + mouseY = mouseY - container.top; + this.box.move(mouseX - offsetX, mouseY - offsetY); + if (this.box.x1 < 0) { + this.box.move(0, null); + } + if (this.box.x2 > container.width) { + this.box.move(container.width - this.box.width(), null); + } + if (this.box.y1 < 0) { + this.box.move(null, 0); + } + if (this.box.y2 > container.height) { + this.box.move(null, container.height - this.box.height()); + } + this.redraw(); + if (this.options.onCropMove !== null) { + this.options.onCropMove(this.getValue()); + } + } + }, { + key: 'onRegionMoveEnd', + value: function onRegionMoveEnd(e) { + if (this.options.onCropEnd !== null) { + this.options.onCropEnd(this.getValue()); + } + } + }, { + key: 'getValue', + value: function getValue() { + var mode = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + if (mode === null) { + mode = this.options.returnMode; + } + if (mode == 'real') { + var actualWidth = this.imageEl.naturalWidth; + var actualHeight = this.imageEl.naturalHeight; + var _imageEl$getBoundingC = this.imageEl.getBoundingClientRect(), + elementWidth = _imageEl$getBoundingC.width, + elementHeight = _imageEl$getBoundingC.height; + var factorX = actualWidth / elementWidth; + var factorY = actualHeight / elementHeight; + return { + x: Math.round(this.box.x1 * factorX), + y: Math.round(this.box.y1 * factorY), + width: Math.round(this.box.width() * factorX), + height: Math.round(this.box.height() * factorY) + }; + } else if (mode == 'ratio') { + var _imageEl$getBoundingC2 = this.imageEl.getBoundingClientRect(), + _elementWidth = _imageEl$getBoundingC2.width, + _elementHeight = _imageEl$getBoundingC2.height; + return { + x: round(this.box.x1 / _elementWidth, 3), + y: round(this.box.y1 / _elementHeight, 3), + width: round(this.box.width() / _elementWidth, 3), + height: round(this.box.height() / _elementHeight, 3) + }; + } else if (mode == 'raw') { + return { + x: Math.round(this.box.x1), + y: Math.round(this.box.y1), + width: Math.round(this.box.width()), + height: Math.round(this.box.height()) + }; + } + } + }], [{ + key: 'parseOptions', + value: function parseOptions(opts) { + var defaults$$1 = { + aspectRatio: null, + maxSize: { width: null, height: null }, + minSize: { width: null, height: null }, + startSize: { width: 100, height: 100, unit: '%' }, + returnMode: 'real', + onInitialize: null, + onCropStart: null, + onCropMove: null, + onCropEnd: null + }; + var aspectRatio = null; + if (opts.aspectRatio !== undefined) { + if (typeof opts.aspectRatio === 'number') { + aspectRatio = opts.aspectRatio; + } else if (opts.aspectRatio instanceof Array) { + aspectRatio = opts.aspectRatio[1] / opts.aspectRatio[0]; + } + } + var maxSize = null; + if (opts.maxSize !== undefined && opts.maxSize !== null) { + maxSize = { + width: opts.maxSize[0] || null, + height: opts.maxSize[1] || null, + unit: opts.maxSize[2] || 'px' + }; + } + var minSize = null; + if (opts.minSize !== undefined && opts.minSize !== null) { + minSize = { + width: opts.minSize[0] || null, + height: opts.minSize[1] || null, + unit: opts.minSize[2] || 'px' + }; + } + var startSize = null; + if (opts.startSize !== undefined && opts.startSize !== null) { + startSize = { + width: opts.startSize[0] || null, + height: opts.startSize[1] || null, + unit: opts.startSize[2] || '%' + }; + } + var onInitialize = null; + if (typeof opts.onInitialize === 'function') { + onInitialize = opts.onInitialize; + } + var onCropStart = null; + if (typeof opts.onCropStart === 'function') { + onCropStart = opts.onCropStart; + } + var onCropEnd = null; + if (typeof opts.onCropEnd === 'function') { + onCropEnd = opts.onCropEnd; + } + var onCropMove = null; + if (typeof opts.onUpdate === 'function') { + console.warn('Croppr.js: `onUpdate` is deprecated and will be removed in the next major release. Please use `onCropMove` or `onCropEnd` instead.'); + onCropMove = opts.onUpdate; + } + if (typeof opts.onCropMove === 'function') { + onCropMove = opts.onCropMove; + } + var returnMode = null; + if (opts.returnMode !== undefined) { + var s = opts.returnMode.toLowerCase(); + if (['real', 'ratio', 'raw'].indexOf(s) === -1) { + throw "Invalid return mode."; + } + returnMode = s; + } + var convertToPixels = function convertToPixels(container) { + var width = container.offsetWidth; + var height = container.offsetHeight; + var sizeKeys = ['maxSize', 'minSize', 'startSize']; + for (var i = 0; i < sizeKeys.length; i++) { + var key = sizeKeys[i]; + if (this[key] !== null) { + if (this[key].unit == '%') { + if (this[key].width !== null) { + this[key].width = this[key].width / 100 * width; + } + if (this[key].height !== null) { + this[key].height = this[key].height / 100 * height; + } + } + delete this[key].unit; + } + } + }; + var defaultValue = function defaultValue(v, d) { + return v !== null ? v : d; + }; + return { + aspectRatio: defaultValue(aspectRatio, defaults$$1.aspectRatio), + maxSize: defaultValue(maxSize, defaults$$1.maxSize), + minSize: defaultValue(minSize, defaults$$1.minSize), + startSize: defaultValue(startSize, defaults$$1.startSize), + returnMode: defaultValue(returnMode, defaults$$1.returnMode), + onInitialize: defaultValue(onInitialize, defaults$$1.onInitialize), + onCropStart: defaultValue(onCropStart, defaults$$1.onCropStart), + onCropMove: defaultValue(onCropMove, defaults$$1.onCropMove), + onCropEnd: defaultValue(onCropEnd, defaults$$1.onCropEnd), + convertToPixels: convertToPixels + }; + } + }]); + return CropprCore; +}(); +function round(value, decimals) { + return Number(Math.round(value + 'e' + decimals) + 'e-' + decimals); +} + +var Croppr$1 = function (_CropprCore) { + inherits(Croppr, _CropprCore); + /** + * @constructor + * Calls the CropprCore's constructor. + */ + function Croppr(element, options) { + var _deferred = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + classCallCheck(this, Croppr); + return possibleConstructorReturn(this, (Croppr.__proto__ || Object.getPrototypeOf(Croppr)).call(this, element, options, _deferred)); + } + /** + * Gets the value of the crop region. + * @param {String} [mode] Which mode of calculation to use: 'real', 'ratio' or + * 'raw'. + */ + createClass(Croppr, [{ + key: 'getValue', + value: function getValue(mode) { + return get(Croppr.prototype.__proto__ || Object.getPrototypeOf(Croppr.prototype), 'getValue', this).call(this, mode); + } + /** + * Changes the image src. + * @param {String} src + */ + }, { + key: 'setImage', + value: function setImage(src) { + return get(Croppr.prototype.__proto__ || Object.getPrototypeOf(Croppr.prototype), 'setImage', this).call(this, src); + } + }, { + key: 'destroy', + value: function destroy() { + return get(Croppr.prototype.__proto__ || Object.getPrototypeOf(Croppr.prototype), 'destroy', this).call(this); + } + /** + * Moves the crop region to a specified coordinate. + * @param {Number} x + * @param {Number} y + */ + }, { + key: 'moveTo', + value: function moveTo(x, y) { + this.box.move(x, y); + this.redraw(); + if (this.options.onCropEnd !== null) { + this.options.onCropEnd(this.getValue()); + } + return this; + } + /** + * Resizes the crop region to a specified width and height. + * @param {Number} width + * @param {Number} height + * @param {Array} origin The origin point to resize from. + * Defaults to [0.5, 0.5] (center). + */ + }, { + key: 'resizeTo', + value: function resizeTo(width, height) { + var origin = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : [.5, .5]; + this.box.resize(width, height, origin); + this.redraw(); + if (this.options.onCropEnd !== null) { + this.options.onCropEnd(this.getValue()); + } + return this; + } + /** + * Scale the crop region by a factor. + * @param {Number} factor + * @param {Array} origin The origin point to resize from. + * Defaults to [0.5, 0.5] (center). + */ + }, { + key: 'scaleBy', + value: function scaleBy(factor) { + var origin = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [.5, .5]; + this.box.scale(factor, origin); + this.redraw(); + if (this.options.onCropEnd !== null) { + this.options.onCropEnd(this.getValue()); + } + return this; + } + }, { + key: 'reset', + value: function reset() { + this.box = this.initializeBox(this.options); + this.redraw(); + if (this.options.onCropEnd !== null) { + this.options.onCropEnd(this.getValue()); + } + return this; + } + }]); + return Croppr; +}(CropprCore); + +return Croppr$1; + +}))); diff --git a/web/static/js/croppr/croppr.min.css b/web/static/js/croppr/croppr.min.css new file mode 100644 index 0000000..adcbc57 --- /dev/null +++ b/web/static/js/croppr/croppr.min.css @@ -0,0 +1 @@ +.croppr-container *{user-select:none;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;box-sizing:border-box;-webkit-box-sizing:border-box;-moz-box-sizing:border-box}.croppr-container img{vertical-align:middle;max-width:100%}.croppr{position:relative;display:inline-block}.croppr-handle,.croppr-imageClipped,.croppr-overlay,.croppr-region{position:absolute;top:0}.croppr-overlay{background:rgba(0,0,0,.5);right:0;bottom:0;left:0;z-index:1;cursor:crosshair}.croppr-region{border:1px dashed rgba(0,0,0,.5);z-index:3;cursor:move}.croppr-imageClipped{right:0;bottom:0;left:0;z-index:2;pointer-events:none}.croppr-handle{border:1px solid #000;background-color:#fff;width:10px;height:10px;z-index:4} \ No newline at end of file diff --git a/web/static/js/croppr/croppr.min.js b/web/static/js/croppr/croppr.min.js new file mode 100644 index 0000000..f5d01dc --- /dev/null +++ b/web/static/js/croppr/croppr.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):t.Croppr=e()}(this,function(){"use strict";function t(t){t.addEventListener("touchstart",e),t.addEventListener("touchend",e),t.addEventListener("touchmove",e)}function e(t){t.preventDefault();var e=t.changedTouches[0],i={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup"};e.target.dispatchEvent(new MouseEvent(i[t.type],{bubbles:!0,cancelable:!0,view:window,clientX:e.clientX,clientY:e.clientY,screenX:e.screenX,screenY:e.screenY}))}function i(t,e){return Number(Math.round(t+"e"+e)+"e-"+e)}!function(){for(var t=0,e=["ms","moz","webkit","o"],i=0;i0&&void 0!==arguments[0]?arguments[0]:null,e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null;return this.x1=null==t?this.x1:t,this.y1=null==e?this.y1:e,this.x2=null==i?this.x2:i,this.y2=null==n?this.y2:n,this}},{key:"width",value:function(){return Math.abs(this.x2-this.x1)}},{key:"height",value:function(){return Math.abs(this.y2-this.y1)}},{key:"resize",value:function(t,e){var i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[0,0],n=this.x1+this.width()*i[0],o=this.y1+this.height()*i[1];return this.x1=n-t*i[0],this.y1=o-e*i[1],this.x2=this.x1+t,this.y2=this.y1+e,this}},{key:"scale",value:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[0,0],i=this.width()*t,n=this.height()*t;return this.resize(i,n,e),this}},{key:"move",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,i=this.width(),n=this.height();return t=null===t?this.x1:t,e=null===e?this.y1:e,this.x1=t,this.y1=e,this.x2=t+i,this.y2=e+n,this}},{key:"getRelativePoint",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[0,0],e=this.width()*t[0],i=this.height()*t[1];return[e,i]}},{key:"getAbsolutePoint",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[0,0],e=this.x1+this.width()*t[0],i=this.y1+this.height()*t[1];return[e,i]}},{key:"constrainToRatio",value:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[0,0],i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"height";if(null!==t){this.width(),this.height();switch(i){case"height":this.resize(this.width(),this.width()*t,e);break;case"width":this.resize(1*this.height()/t,this.height(),e);break;default:this.resize(this.width(),this.width()*t,e)}return this}}},{key:"constrainToBoundary",value:function(t,e){var i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[0,0],n=this.getAbsolutePoint(i),o=h(n,2),s=o[0],r=o[1],a=s,l=r,u=t-s,d=e-r,c=-2*i[0]+1,p=-2*i[1]+1,v=null,m=null;switch(c){case-1:v=a;break;case 0:v=2*Math.min(a,u);break;case 1:v=u}switch(p){case-1:m=l;break;case 0:m=2*Math.min(l,d);break;case 1:m=d}if(this.width()>v){var f=v/this.width();this.scale(f,i)}if(this.height()>m){var g=m/this.height();this.scale(g,i)}return this}},{key:"constrainToSize",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null,o=arguments.length>4&&void 0!==arguments[4]?arguments[4]:[0,0],s=arguments.length>5&&void 0!==arguments[5]?arguments[5]:null;if(s&&(s>1?(t=1*e/s,n*=s):s<1&&(e=t*s,i=1*n/s)),t&&this.width()>t){var r=t,a=null===s?this.height():e;this.resize(r,a,o)}if(e&&this.height()>e){var h=null===s?this.width():t,l=e;this.resize(h,l,o)}if(i&&this.width()2&&void 0!==arguments[2]&&arguments[2];if(n(this,e),this.options=e.parseOptions(i||{}),!t.nodeName&&(t=document.querySelector(t),null==t))throw"Unable to find element.";if(!t.getAttribute("src"))throw"Image src not provided.";this._initialized=!1,this._restore={parent:t.parentNode,element:t},s||(0===t.width||0===t.height?t.onload=function(){o.initialize(t)}:this.initialize(t))}return o(e,[{key:"initialize",value:function(t){this.createDOM(t),this.options.convertToPixels(this.cropperEl),this.attachHandlerEvents(),this.attachRegionEvents(),this.attachOverlayEvents(),this.box=this.initializeBox(this.options),this.redraw(),this._initialized=!0,null!==this.options.onInitialize&&this.options.onInitialize(this)}},{key:"createDOM",value:function(e){this.containerEl=document.createElement("div"),this.containerEl.className="croppr-container",this.eventBus=this.containerEl,t(this.containerEl),this.cropperEl=document.createElement("div"),this.cropperEl.className="croppr",this.imageEl=document.createElement("img"),this.imageEl.setAttribute("src",e.getAttribute("src")),this.imageEl.setAttribute("alt",e.getAttribute("alt")),this.imageEl.className="croppr-image",this.imageClippedEl=this.imageEl.cloneNode(),this.imageClippedEl.className="croppr-imageClipped",this.regionEl=document.createElement("div"),this.regionEl.className="croppr-region",this.overlayEl=document.createElement("div"),this.overlayEl.className="croppr-overlay";var i=document.createElement("div");i.className="croppr-handleContainer",this.handles=[];for(var n=0;n>31,l=a[1]-t.cropperEl.offsetHeight/2>>31,u=(h^l)+l+l+4,d=-2*u+8,c=0;co.width&&(i=o.width),n<0?n=0:n>o.height&&(n=o.height);var s=this.activeHandle.originPoint.slice(),r=this.activeHandle.originX,a=this.activeHandle.originY,h=this.activeHandle.handle,l=1===h.constraints[0],d=1===h.constraints[1],c=1===h.constraints[2],p=1===h.constraints[3],v=(p||d)&&(l||c),m=p||d?r:this.box.x1,f=p||d?r:this.box.x2,g=l||c?a:this.box.y1,E=l||c?a:this.box.y2;m=p?i:m,f=d?i:f,g=l?n:g,E=c?n:E;var w=!1,y=!1;if((p||d)&&(w=p?i>r:ia:nC.y1+z*C.width()||na.width&&this.box.move(a.width-this.box.width(),null),this.box.y1<0&&this.box.move(null,0),this.box.y2>a.height&&this.box.move(null,a.height-this.box.height()),this.redraw(),null!==this.options.onCropMove&&this.options.onCropMove(this.getValue())}},{key:"onRegionMoveEnd",value:function(t){null!==this.options.onCropEnd&&this.options.onCropEnd(this.getValue())}},{key:"getValue",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null;if(null===t&&(t=this.options.returnMode),"real"==t){var e=this.imageEl.naturalWidth,n=this.imageEl.naturalHeight,o=this.imageEl.getBoundingClientRect(),s=o.width,r=o.height,a=e/s,h=n/r;return{x:Math.round(this.box.x1*a),y:Math.round(this.box.y1*h),width:Math.round(this.box.width()*a),height:Math.round(this.box.height()*h)}}if("ratio"==t){var l=this.imageEl.getBoundingClientRect(),u=l.width,d=l.height;return{x:i(this.box.x1/u,3),y:i(this.box.y1/d,3),width:i(this.box.width()/u,3),height:i(this.box.height()/d,3)}}if("raw"==t)return{x:Math.round(this.box.x1),y:Math.round(this.box.y1),width:Math.round(this.box.width()),height:Math.round(this.box.height())}}}],[{key:"parseOptions",value:function(t){var e={aspectRatio:null,maxSize:{width:null,height:null},minSize:{width:null,height:null},startSize:{width:100,height:100,unit:"%"},returnMode:"real",onInitialize:null,onCropStart:null,onCropMove:null,onCropEnd:null},i=null;void 0!==t.aspectRatio&&("number"==typeof t.aspectRatio?i=t.aspectRatio:t.aspectRatio instanceof Array&&(i=t.aspectRatio[1]/t.aspectRatio[0]));var n=null;void 0!==t.maxSize&&null!==t.maxSize&&(n={width:t.maxSize[0]||null,height:t.maxSize[1]||null,unit:t.maxSize[2]||"px"});var o=null;void 0!==t.minSize&&null!==t.minSize&&(o={width:t.minSize[0]||null,height:t.minSize[1]||null,unit:t.minSize[2]||"px"});var s=null;void 0!==t.startSize&&null!==t.startSize&&(s={width:t.startSize[0]||null,height:t.startSize[1]||null,unit:t.startSize[2]||"%"});var r=null;"function"==typeof t.onInitialize&&(r=t.onInitialize);var a=null;"function"==typeof t.onCropStart&&(a=t.onCropStart);var h=null;"function"==typeof t.onCropEnd&&(h=t.onCropEnd);var l=null;"function"==typeof t.onUpdate&&(console.warn("Croppr.js: `onUpdate` is deprecated and will be removed in the next major release. Please use `onCropMove` or `onCropEnd` instead."),l=t.onUpdate),"function"==typeof t.onCropMove&&(l=t.onCropMove);var u=null;if(void 0!==t.returnMode){var d=t.returnMode.toLowerCase();if(["real","ratio","raw"].indexOf(d)===-1)throw"Invalid return mode.";u=d}var c=function(t){for(var e=t.offsetWidth,i=t.offsetHeight,n=["maxSize","minSize","startSize"],o=0;o2&&void 0!==arguments[2]&&arguments[2];return n(this,e),a(this,(e.__proto__||Object.getPrototypeOf(e)).call(this,t,i,o))}return r(e,t),o(e,[{key:"getValue",value:function(t){return s(e.prototype.__proto__||Object.getPrototypeOf(e.prototype),"getValue",this).call(this,t)}},{key:"setImage",value:function(t){return s(e.prototype.__proto__||Object.getPrototypeOf(e.prototype),"setImage",this).call(this,t)}},{key:"destroy",value:function(){return s(e.prototype.__proto__||Object.getPrototypeOf(e.prototype),"destroy",this).call(this)}},{key:"moveTo",value:function(t,e){return this.box.move(t,e),this.redraw(),null!==this.options.onCropEnd&&this.options.onCropEnd(this.getValue()),this}},{key:"resizeTo",value:function(t,e){var i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[.5,.5];return this.box.resize(t,e,i),this.redraw(),null!==this.options.onCropEnd&&this.options.onCropEnd(this.getValue()),this}},{key:"scaleBy",value:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[.5,.5];return this.box.scale(t,e),this.redraw(),null!==this.options.onCropEnd&&this.options.onCropEnd(this.getValue()),this}},{key:"reset",value:function(){return this.box=this.initializeBox(this.options),this.redraw(),null!==this.options.onCropEnd&&this.options.onCropEnd(this.getValue()),this}}]),e}(c);return p}); diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 5e6b119..387c78c 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -20,6 +20,7 @@