studio-engine 0.97.0 → 0.98.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/app/javascript/studio/alpine_scopes.js +66 -0
  4. data/app/javascript/studio/alpine_shims.js +4 -0
  5. data/app/javascript/studio/application.js +4 -1
  6. data/app/javascript/studio/birthday.js +351 -0
  7. data/app/javascript/studio/confirm_interstitial.js +44 -0
  8. data/app/javascript/studio/controllers/identity_mini_controller.js +32 -0
  9. data/app/javascript/studio/controllers/style_modals_controller.js +41 -0
  10. data/app/javascript/studio/cropper.js +281 -0
  11. data/app/javascript/studio/identity_mini.js +74 -0
  12. data/app/javascript/studio/image_upload.js +189 -0
  13. data/app/javascript/studio/profile_form.js +88 -0
  14. data/app/javascript/studio/stimulus.js +4 -0
  15. data/app/javascript/studio/style_guide.js +13 -0
  16. data/app/javascript/studio/style_modals.js +214 -0
  17. data/app/views/components/_avatar_cropper.html.erb +3 -38
  18. data/app/views/layouts/studio/_head.html.erb +12 -2
  19. data/app/views/studio/_alpine_scopes_guard.html.erb +178 -0
  20. data/app/views/studio/_birthday_assets.html.erb +6 -240
  21. data/app/views/studio/_confirm_interstitial.html.erb +31 -18
  22. data/app/views/studio/_cropper_assets.html.erb +9 -8
  23. data/app/views/studio/modals/_crop_photo.html.erb +1 -1
  24. data/app/views/studio/modals/_image_upload.html.erb +8 -321
  25. data/app/views/studio/modals/blocks/_birthday.html.erb +2 -3
  26. data/app/views/studio/profiles/_birthday_fields.html.erb +0 -1
  27. data/app/views/studio/profiles/_identity_mini.html.erb +5 -62
  28. data/app/views/studio/profiles/_plain_save.html.erb +11 -0
  29. data/app/views/studio/profiles/edit.html.erb +1 -6
  30. data/app/views/style/_modals.html.erb +36 -420
  31. data/lib/studio/version.rb +1 -1
  32. metadata +14 -3
  33. data/app/views/studio/profiles/_birthday_picker_script.html.erb +0 -146
  34. data/app/views/studio/profiles/_form_script.html.erb +0 -65
@@ -0,0 +1,41 @@
1
+ // style-modals: the living style guide's Modals section, on the engine's
2
+ // Stimulus application (studio/stimulus, which reads data-studio-controller).
3
+ // It sits on the section style/_modals renders:
4
+ //
5
+ // <section id="modals" data-studio-controller="style-modals" x-data="{}">
6
+ // <button data-studio-action="click->style-modals#demo"
7
+ // data-style-modals-name-param="stackTwo">Open two</button>
8
+ //
9
+ // The demos and the simulator's controls are studio/style_modals. connect()
10
+ // installs the wallet stubs, publishes the drivers as window.dsModalDemos (for
11
+ // the console) and builds the simulator's controls from the live animation
12
+ // registry; they are built again on turbo:load, so a key registered since then
13
+ // grows a control.
14
+ //
15
+ // Registered by studio/style_guide, which the section loads by its own module
16
+ // tag, so only the style guide fetches it.
17
+ import { Controller } from "@hotwired/stimulus"
18
+ import { createModalDemos, installWalletStubs } from "studio/style_modals"
19
+
20
+ export default class extends Controller {
21
+ static values = { store: { type: String, default: "dsModals" } }
22
+
23
+ connect() {
24
+ installWalletStubs(window)
25
+ this.demos = createModalDemos({ win: window, doc: document, store: this.storeValue })
26
+ window.dsModalDemos = this.demos
27
+ this.build = () => this.demos.buildAnimControls()
28
+ this.build()
29
+ document.addEventListener("turbo:load", this.build)
30
+ }
31
+
32
+ disconnect() {
33
+ document.removeEventListener("turbo:load", this.build)
34
+ }
35
+
36
+ // click->style-modals#demo, with the driver's name as the action's param.
37
+ demo(event) {
38
+ const run = this.demos && this.demos[event.params.name]
39
+ if (typeof run === "function") run()
40
+ }
41
+ }
@@ -0,0 +1,281 @@
1
+ // studio/cropper: the crop-photo modal (studio/modals/_crop_photo), the one
2
+ // cropper every upload in the engine and its hosts goes through.
3
+ //
4
+ // x-data="cropPhotoModal({ store: 'modals' })"
5
+ //
6
+ // The modal hands the result to its opener by the `crop-photo-confirmed` window
7
+ // event; the opener owns the upload (studio/image_upload: imageUploadHost saves
8
+ // at once, avatarCropperHost stages the file on its form). The image gets in
9
+ // two ways:
10
+ //
11
+ // imageUrl prop the opener already picked a file and passes its data URL;
12
+ // the modal goes straight to the cropper.
13
+ // no imageUrl the modal is the picker: click or drop a file, then crop.
14
+ //
15
+ // Open it with the crop a page wants:
16
+ //
17
+ // Alpine.store('modals').open('crop-photo',
18
+ // { imageUrl, aspectRatio: 3, maxWidth: 900, transparent: false })
19
+ //
20
+ // Cropper.js is the page's own global (studio/_cropper_assets loads it from
21
+ // cdnjs on a page that can open the modal). A page where it never arrives says
22
+ // so in the modal's error line; the button never takes a press that does
23
+ // nothing.
24
+ //
25
+ // The factory is published by studio/alpine_shims as window.cropPhotoModal and
26
+ // Alpine.data("cropPhotoModal"), statically, because Alpine evaluates the
27
+ // x-data when the card mounts and a card whose factory is missing is dead.
28
+ // It imports nothing; test/javascript/cropper.test.mjs loads it as a data:
29
+ // module.
30
+
31
+ // The crop an opener gets when it names nothing: a square 256px avatar on a
32
+ // transparent ground.
33
+ export const CROP_DEFAULTS = {
34
+ aspectRatio: 1,
35
+ maxWidth: 256,
36
+ maxHeight: 256,
37
+ transparent: true,
38
+ allowGifs: false,
39
+ autoCropArea: 0.9,
40
+ dispatch: false,
41
+ owner: null
42
+ }
43
+
44
+ export const NOT_AN_IMAGE = "Please choose an image file."
45
+ export const GIF_REFUSED = "GIFs aren't accepted here. Use a PNG, JPG or WebP."
46
+ export const CROPPER_MISSING = "The photo cropper did not load. Reload the page and try again."
47
+
48
+ // How long a mount waits for Cropper.js before it reports the library missing,
49
+ // and how often it looks. The script is deferred, so on a Turbo visit it can
50
+ // land a moment after the card opens.
51
+ export const LIBRARY_WAIT_MS = 3000
52
+ export const LIBRARY_POLL_MS = 100
53
+
54
+ // The crop a modal entry's props ask for, over the defaults.
55
+ // dispatch the modal stays open after confirm, and the opener's host runs
56
+ // its own saving card in its place. Without it the modal closes.
57
+ // owner carried through and echoed on confirm, so only the host that
58
+ // opened the cropper acts on the result.
59
+ export function cropSettings(props) {
60
+ props = props || {}
61
+ const settings = { ...CROP_DEFAULTS }
62
+ if (props.aspectRatio) settings.aspectRatio = props.aspectRatio
63
+ if (props.maxWidth) settings.maxWidth = props.maxWidth
64
+ if (props.maxHeight) settings.maxHeight = props.maxHeight
65
+ if (typeof props.transparent === "boolean") settings.transparent = props.transparent
66
+ if (typeof props.allowGifs === "boolean") settings.allowGifs = props.allowGifs
67
+ if (props.dispatch) settings.dispatch = true
68
+ settings.owner = props.owner || null
69
+ if (props.autoCropArea) settings.autoCropArea = props.autoCropArea
70
+ return settings
71
+ }
72
+
73
+ // What Cropper.js is constructed with: the crop box holds the asked ratio.
74
+ export function cropperOptions(settings) {
75
+ return {
76
+ aspectRatio: settings.aspectRatio,
77
+ viewMode: 1,
78
+ dragMode: "move",
79
+ autoCropArea: settings.autoCropArea,
80
+ cropBoxResizable: true,
81
+ cropBoxMovable: true,
82
+ background: false,
83
+ guides: true
84
+ }
85
+ }
86
+
87
+ // What getCroppedCanvas is asked for. The WIDTH alone is capped: a fixed ratio
88
+ // already bounds the height (width / aspectRatio), and passing maxHeight too
89
+ // makes Cropper.js scale the whole source down to fit it when the source is
90
+ // taller, which costs the crop its resolution.
91
+ export function canvasOptions(settings) {
92
+ const options = { maxWidth: settings.maxWidth, imageSmoothingQuality: "high" }
93
+ if (!settings.transparent) options.fillColor = "#ffffff"
94
+ return options
95
+ }
96
+
97
+ // By type, not extension: a file renamed to .gif is still a PNG and belongs in
98
+ // the cropper, and a .GIF from a Windows machine is still a GIF.
99
+ export function isAnimatedCandidate(file) {
100
+ return ((file && file.type) || "").toLowerCase() === "image/gif"
101
+ }
102
+
103
+ // What a picked file does: "crop" it, "pass" it through untouched, or the
104
+ // error line it earns.
105
+ //
106
+ // A GIF never goes through the cropper: the crop is painted on a canvas and
107
+ // exported as a PNG, which keeps frame one and drops the animation. Where GIFs
108
+ // are allowed the original bytes pass through, uncropped; where they are not,
109
+ // the file is refused.
110
+ export function fileRoute(file, settings) {
111
+ if (!file.type || file.type.indexOf("image/") !== 0) return { error: NOT_AN_IMAGE }
112
+ if (!isAnimatedCandidate(file)) return { route: "crop" }
113
+ return settings.allowGifs ? { route: "pass" } : { error: GIF_REFUSED }
114
+ }
115
+
116
+ // The detail of `crop-photo-confirmed`. The owner rides with the blob: the
117
+ // event is a window event, so every upload host on the page hears it, and a
118
+ // page can mount one host per row. A passed-through file carries its own name
119
+ // and says it is uncropped.
120
+ export function confirmedDetail(blob, owner, original) {
121
+ const detail = { blob, owner: owner || null }
122
+ if (original) {
123
+ detail.filename = original.name
124
+ detail.uncropped = true
125
+ }
126
+ return detail
127
+ }
128
+
129
+ // Calls `ready(Cropper)` once the page has Cropper.js, now or within
130
+ // LIBRARY_WAIT_MS, and `missing()` when it never arrives. Returns a function
131
+ // that stops the wait.
132
+ export function whenCropperLoads(win, ready, missing) {
133
+ if (typeof win.Cropper !== "undefined") {
134
+ ready(win.Cropper)
135
+ return () => {}
136
+ }
137
+ let waited = 0
138
+ let stopped = false
139
+ const look = () => {
140
+ if (stopped) return
141
+ if (typeof win.Cropper !== "undefined") { ready(win.Cropper); return }
142
+ waited += LIBRARY_POLL_MS
143
+ if (waited >= LIBRARY_WAIT_MS) { missing(); return }
144
+ win.setTimeout(look, LIBRARY_POLL_MS)
145
+ }
146
+ win.setTimeout(look, LIBRARY_POLL_MS)
147
+ return () => { stopped = true }
148
+ }
149
+
150
+ // x-data for studio/modals/_crop_photo. opts.store names the Alpine store the
151
+ // modal is mounted in: "modals" for the shared host, a page-scoped host's own
152
+ // name otherwise. env.win is the window (the tests pass their own).
153
+ export function cropPhotoModal(opts, env) {
154
+ opts = opts || {}
155
+ const win = (env && env.win) || window
156
+
157
+ return {
158
+ _storeName: opts.store || "modals",
159
+ cropper: null,
160
+ imageUrl: null,
161
+ fromParent: false,
162
+ dragging: false,
163
+ error: null,
164
+ aspectRatio: CROP_DEFAULTS.aspectRatio,
165
+ maxWidth: CROP_DEFAULTS.maxWidth,
166
+ maxHeight: CROP_DEFAULTS.maxHeight,
167
+ transparent: CROP_DEFAULTS.transparent,
168
+ allowGifs: CROP_DEFAULTS.allowGifs,
169
+ autoCropArea: CROP_DEFAULTS.autoCropArea,
170
+ dispatch: CROP_DEFAULTS.dispatch,
171
+ owner: null,
172
+
173
+ init() {
174
+ const current = this.$store[this._storeName].current()
175
+ const props = (current && current.props) || {}
176
+ Object.assign(this, cropSettings(props))
177
+ if (props.imageUrl) {
178
+ this.fromParent = true
179
+ this.imageUrl = props.imageUrl
180
+ this.mountCropper()
181
+ }
182
+ },
183
+
184
+ destroy() {
185
+ this.releaseCropper()
186
+ },
187
+
188
+ releaseCropper() {
189
+ if (this._stopWaiting) { this._stopWaiting(); this._stopWaiting = null }
190
+ if (this.cropper) { this.cropper.destroy(); this.cropper = null }
191
+ },
192
+
193
+ mountCropper() {
194
+ const self = this
195
+ this.$nextTick(function () {
196
+ self.releaseCropper()
197
+ self._stopWaiting = whenCropperLoads(win, function (Cropper) {
198
+ self._stopWaiting = null
199
+ if (!self.$refs.cropImage) return
200
+ self.cropper = new Cropper(self.$refs.cropImage, cropperOptions(self))
201
+ // A crop in progress locks the modal (no click-outside, no Escape), so
202
+ // a stray click does not discard it; Cancel still closes. cropReady
203
+ // tells an observer of the store the picker has become the cropper:
204
+ // imageUrl is this scope's own and never reaches the entry's props.
205
+ const entry = self.$store[self._storeName].current()
206
+ if (entry && entry.props) { entry.props.dismissible = false; entry.props.cropReady = true }
207
+ }, function () {
208
+ self._stopWaiting = null
209
+ self.error = CROPPER_MISSING
210
+ })
211
+ })
212
+ },
213
+
214
+ readFile(file) {
215
+ if (!file) return
216
+ const verdict = fileRoute(file, this)
217
+ if (verdict.error) { this.error = verdict.error; return }
218
+ if (verdict.route === "pass") {
219
+ this.error = null
220
+ this.passThrough(file)
221
+ return
222
+ }
223
+
224
+ const self = this
225
+ const reader = new win.FileReader()
226
+ reader.onload = function (event) {
227
+ self.error = null
228
+ self.imageUrl = event.target.result
229
+ self.mountCropper()
230
+ }
231
+ reader.readAsDataURL(file)
232
+ },
233
+
234
+ isAnimatedCandidate(file) {
235
+ return isAnimatedCandidate(file)
236
+ },
237
+
238
+ announce(detail) {
239
+ try {
240
+ win.dispatchEvent(new win.CustomEvent("crop-photo-confirmed", { detail }))
241
+ } catch (_) {}
242
+ },
243
+
244
+ // The original file goes straight to the opener, with no canvas in the path.
245
+ passThrough(file) {
246
+ this.announce(confirmedDetail(file, this.owner, file))
247
+ this.releaseCropper()
248
+ if (!this.dispatch) this.$store[this._storeName].close()
249
+ },
250
+
251
+ onFilePicked(event) {
252
+ const file = event.target.files[0]
253
+ event.target.value = ""
254
+ this.readFile(file)
255
+ },
256
+
257
+ onDrop(event) {
258
+ this.dragging = false
259
+ const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]
260
+ this.readFile(file)
261
+ },
262
+
263
+ cancel() {
264
+ this.releaseCropper()
265
+ this.$store[this._storeName].close()
266
+ },
267
+
268
+ confirm() {
269
+ if (!this.cropper) return
270
+ const self = this
271
+ const canvas = this.cropper.getCroppedCanvas(canvasOptions(this))
272
+ canvas.toBlob(function (blob) {
273
+ self.announce(confirmedDetail(blob, self.owner))
274
+ self.releaseCropper()
275
+ // In dispatch mode the opener's host swaps its saving card in over
276
+ // this one, so the modal does not pop the stack itself.
277
+ if (!self.dispatch) self.$store[self._storeName].close()
278
+ }, "image/png")
279
+ }
280
+ }
281
+ }
@@ -0,0 +1,74 @@
1
+ // studio/identity_mini: when the compact identity bar
2
+ // (studio/profiles/_identity_mini) takes over from the full identity card.
3
+ //
4
+ // The bar is fixed under the navbar and ships hidden and inert. It shows once
5
+ // most of the full card has scrolled away, and on the edit page it carries the
6
+ // save controls from then on. The identity-mini controller binds one bar to
7
+ // its page's card.
8
+ //
9
+ // It imports nothing; test/javascript/identity_mini.test.mjs loads it as a
10
+ // data: module.
11
+
12
+ // The class that shows the bar (studio/profiles/_identity_styles).
13
+ export const VISIBLE_CLASS = "is-visible"
14
+
15
+ // The card the bar stands in for.
16
+ export const FULL_SELECTOR = "[data-studio-identity-full]"
17
+
18
+ // The bar shows once less than this much of the card is on screen. Part-way,
19
+ // so the two overlap briefly and there is no moment with neither on screen. A
20
+ // ratio and not a pixel offset: the card's height changes with the name's wrap
21
+ // and with whether an address sits under it.
22
+ export const SHOW_BELOW_RATIO = 0.6
23
+
24
+ // The navbar covers the top of the viewport, so the pixels behind it are not
25
+ // visible ones. The spread of thresholds is what lets the ratio test run:
26
+ // with one threshold the callback fires only at fully in and fully out.
27
+ export const OBSERVER_OPTIONS = {
28
+ rootMargin: "-72px 0px 0px 0px",
29
+ threshold: [0, 0.2, 0.4, 0.5, 0.6, 0.8, 1]
30
+ }
31
+
32
+ // Whether the card counts as gone. A card taller than the viewport never
33
+ // reaches ratio 1, so not intersecting at all counts in its own right.
34
+ export function cardGone(entry) {
35
+ return !entry.isIntersecting || entry.intersectionRatio < SHOW_BELOW_RATIO
36
+ }
37
+
38
+ // Shows or hides the bar. The tab order and the accessibility tree follow the
39
+ // same decision as the class: opacity and pointer-events cannot say "not
40
+ // here", and the bar holds buttons.
41
+ export function showBar(mini, visible) {
42
+ mini.classList.toggle(VISIBLE_CLASS, visible)
43
+ mini.inert = !visible
44
+ }
45
+
46
+ // One bar watching one card. `Observer` is IntersectionObserver; a browser
47
+ // without it leaves the bar hidden and inert, and the page fully usable.
48
+ export class IdentityMini {
49
+ constructor(mini, full, Observer) {
50
+ this.mini = mini
51
+ this.full = full
52
+ this.Observer = Observer
53
+ this.observer = null
54
+ }
55
+
56
+ start() {
57
+ this.stop()
58
+ if (!this.mini || !this.full || !this.Observer) return false
59
+ this.observer = new this.Observer((entries) => showBar(this.mini, cardGone(entries[0])), OBSERVER_OPTIONS)
60
+ this.observer.observe(this.full)
61
+ return true
62
+ }
63
+
64
+ stop() {
65
+ if (this.observer) this.observer.disconnect()
66
+ this.observer = null
67
+ }
68
+
69
+ // Hidden again, as the bar ships: for a page going into Turbo's cache.
70
+ reset() {
71
+ this.stop()
72
+ if (this.mini) showBar(this.mini, false)
73
+ }
74
+ }
@@ -0,0 +1,189 @@
1
+ // studio/image_upload: what an upload does around the crop-photo modal
2
+ // (studio/cropper). Three names a page binds:
3
+ //
4
+ // x-data="imageUploadHost({ aspectRatio: 5, maxWidth: 2000, filename: 'banner.png' })"
5
+ // crop, then save at once: the cropped file goes into the host's own
6
+ // hidden form and the form submits behind a saving card.
7
+ // x-data="avatarCropperHost()"
8
+ // crop, then wait: the cropped file is staged on a hidden input and the
9
+ // enclosing form submits it later (components/_avatar_cropper).
10
+ // submitFormWithProgress(form, opts)
11
+ // a Turbo form submit behind the `saving` card, with a toast at the end.
12
+ //
13
+ // A host hears the crop through the page's own listener:
14
+ //
15
+ // @crop-photo-confirmed.window="onCropConfirmed($event.detail)"
16
+ //
17
+ // studio/alpine_shims publishes all three on window, statically: Alpine
18
+ // evaluates the x-data when the page starts, and a host whose factory is
19
+ // missing cannot upload. It imports nothing; test/javascript/image_upload.test.mjs
20
+ // loads it as a data: module.
21
+
22
+ // How long the saving card stays up at the least, so it does not flash.
23
+ export const SAVING_HOLD_MS = 450
24
+
25
+ // The props a host opens the crop-photo modal with: its crop, in dispatch
26
+ // mode (the modal stays open and the host's saving card replaces it), plus
27
+ // `extra` (the picked image, the host's owner token).
28
+ export function uploadCropProps(opts, extra) {
29
+ opts = opts || {}
30
+ const props = {
31
+ aspectRatio: opts.aspectRatio || 1,
32
+ transparent: opts.transparent !== false,
33
+ dispatch: true
34
+ }
35
+ if (opts.maxWidth) props.maxWidth = opts.maxWidth
36
+ if (opts.maxHeight) props.maxHeight = opts.maxHeight
37
+ if (opts.autoCropArea) props.autoCropArea = opts.autoCropArea
38
+ if (typeof opts.allowGifs === "boolean") props.allowGifs = opts.allowGifs
39
+ return Object.assign(props, extra || {})
40
+ }
41
+
42
+ // Whether a confirmed crop is this host's to act on. Many hosts can be mounted
43
+ // on one page and all hear the same window event, so a confirm names the host
44
+ // that opened the cropper. A confirm with no owner applies to any host.
45
+ export function cropIsFor(detail, ownerId) {
46
+ if (!detail) return false
47
+ return !detail.owner || detail.owner === ownerId
48
+ }
49
+
50
+ // The name and type the file is uploaded under. A passed-through file (a GIF)
51
+ // keeps its own name and type; a cropped one is the PNG the canvas exported,
52
+ // under the host's filename.
53
+ export function uploadFileAttributes(blob, filename, opts) {
54
+ return {
55
+ name: filename || (opts && opts.filename) || "image.png",
56
+ type: (blob && blob.type) || "image/png"
57
+ }
58
+ }
59
+
60
+ // The toast a finished submit raises.
61
+ export function submitToast(ok, opts) {
62
+ opts = opts || {}
63
+ return {
64
+ type: ok ? "notice" : "alert",
65
+ title: ok ? (opts.success || "Saved") : (opts.failure || "Couldn't save"),
66
+ message: ok ? (opts.successMessage || "") : (opts.failureMessage || "Please try again.")
67
+ }
68
+ }
69
+
70
+ // Submits `form` (a Turbo form) behind the saving card: the card replaces
71
+ // whatever is open, stays at least SAVING_HOLD_MS, then closes, and a toast
72
+ // reports the result. opts: saving, success, successMessage, failure,
73
+ // failureMessage, dismissible (default false), toast (default true), store
74
+ // (the Alpine store the card is mounted in, default "modals").
75
+ export function submitFormWithProgress(form, opts, env) {
76
+ opts = opts || {}
77
+ const win = (env && env.win) || window
78
+ const store = win.Alpine && win.Alpine.store(opts.store || "modals")
79
+ const hold = (win.StudioModals && win.StudioModals.holdAtLeast)
80
+ ? win.StudioModals.holdAtLeast(SAVING_HOLD_MS)
81
+ : { then: function (callback) { callback() } }
82
+
83
+ if (store) {
84
+ store.open("saving", { dismissible: opts.dismissible === true, title: opts.saving || "Saving…" }, { replace: true })
85
+ }
86
+
87
+ const onEnd = function (event) {
88
+ form.removeEventListener("turbo:submit-end", onEnd)
89
+ const ok = !!(event.detail && event.detail.success)
90
+ hold.then(function () {
91
+ if (store) store.close()
92
+ if (opts.toast !== false) {
93
+ win.dispatchEvent(new win.CustomEvent("toast", { detail: submitToast(ok, opts) }))
94
+ }
95
+ })
96
+ }
97
+ form.addEventListener("turbo:submit-end", onEnd)
98
+ form.requestSubmit()
99
+ }
100
+
101
+ // x-data for a crop-then-save uploader. The host's markup carries
102
+ // x-ref="fileInput" (the hidden file field) inside x-ref="form". opts is the
103
+ // crop (aspectRatio, maxWidth, maxHeight, transparent, autoCropArea,
104
+ // allowGifs), the save copy submitFormWithProgress reads, `filename`, and
105
+ // `store`: the Alpine store the crop-photo and saving cards are mounted in,
106
+ // for a page that mounts them on its own scoped host.
107
+ //
108
+ // Two ways in:
109
+ // open() the crop modal is the picker (click or drop a file).
110
+ // onFileSelected() a native file input hands the image in.
111
+ export function imageUploadHost(opts, env) {
112
+ opts = opts || {}
113
+ const win = (env && env.win) || window
114
+ const storeName = opts.store || "modals"
115
+ const ownerId = "iuh-" + (win.__studioImageUploadHostSeq = (win.__studioImageUploadHostSeq || 0) + 1)
116
+
117
+ return {
118
+ ownerId,
119
+
120
+ open() {
121
+ if (!win.Alpine || !win.Alpine.store(storeName)) return
122
+ win.Alpine.store(storeName).open("crop-photo", uploadCropProps(opts, { owner: ownerId }))
123
+ },
124
+
125
+ onCropConfirmed(detail) {
126
+ if (!cropIsFor(detail, ownerId)) return
127
+ this.applyCrop(detail.blob, detail.filename)
128
+ },
129
+
130
+ onFileSelected(event) {
131
+ const file = event.target.files[0]
132
+ if (!file) return
133
+ const reader = new win.FileReader()
134
+ reader.onload = function (loaded) {
135
+ win.Alpine.store(storeName).open("crop-photo", uploadCropProps(opts, { imageUrl: loaded.target.result, owner: ownerId }))
136
+ }
137
+ reader.readAsDataURL(file)
138
+ event.target.value = ""
139
+ },
140
+
141
+ applyCrop(blob, filename) {
142
+ if (!blob) return
143
+ const file = uploadFileAttributes(blob, filename, opts)
144
+ const transfer = new win.DataTransfer()
145
+ transfer.items.add(new win.File([blob], file.name, { type: file.type }))
146
+ this.$refs.fileInput.files = transfer.files
147
+ const submit = win.submitFormWithProgress || ((form, options) => submitFormWithProgress(form, options, env))
148
+ submit(this.$refs.form, opts)
149
+ }
150
+ }
151
+ }
152
+
153
+ // x-data for components/_avatar_cropper: a cropped avatar as a form field.
154
+ // The crop runs in the shared crop-photo modal; the 256px PNG it confirms is
155
+ // previewed and staged on x-ref="hiddenFileInput", and the enclosing form
156
+ // (sign-up, profile completion) submits it later.
157
+ export function avatarCropperHost(env) {
158
+ const win = (env && env.win) || window
159
+
160
+ return {
161
+ croppedUrl: null,
162
+
163
+ onFileSelected(event) {
164
+ const file = event.target.files[0]
165
+ if (!file) return
166
+ const reader = new win.FileReader()
167
+ reader.onload = function (loaded) {
168
+ win.Alpine.store("modals").open("crop-photo", { imageUrl: loaded.target.result })
169
+ }
170
+ reader.readAsDataURL(file)
171
+ event.target.value = ""
172
+ },
173
+
174
+ applyCrop(blob) {
175
+ if (!blob) return
176
+ if (this.croppedUrl) win.URL.revokeObjectURL(this.croppedUrl)
177
+ this.croppedUrl = win.URL.createObjectURL(blob)
178
+ const transfer = new win.DataTransfer()
179
+ transfer.items.add(new win.File([blob], "avatar.png", { type: "image/png" }))
180
+ this.$refs.hiddenFileInput.files = transfer.files
181
+ },
182
+
183
+ removePhoto() {
184
+ if (this.croppedUrl) win.URL.revokeObjectURL(this.croppedUrl)
185
+ this.croppedUrl = null
186
+ this.$refs.hiddenFileInput.files = new win.DataTransfer().files
187
+ }
188
+ }
189
+ }
@@ -0,0 +1,88 @@
1
+ // studio/profile_form: the profile edit form's scope (studio/profiles/edit).
2
+ //
3
+ // x-data="studioProfileForm({ first_name: 'Pat', email: 'pat@example.com' })"
4
+ //
5
+ // It holds the fields the inputs bind with x-model and the values the page
6
+ // loaded with, says which have changed (the save controls show while any has),
7
+ // puts them back on Discard, and asks before the page is left with changes
8
+ // unsaved. Its markup carries x-ref="form" on the form it guards.
9
+ //
10
+ // studio/alpine_scopes publishes it on window, statically: a profile page whose
11
+ // factory is missing has no save controls. It imports nothing;
12
+ // test/javascript/profile_form.test.mjs loads it as a data: module.
13
+
14
+ export const LEAVE_PROMPT = "You have unsaved changes. Leave without saving?"
15
+
16
+ // Whether one field differs from what the page loaded with. Trimmed on both
17
+ // sides: whitespace is not a change, and the controller trims on the way in,
18
+ // so the bar agrees with what saving would do.
19
+ export function fieldChanged(fields, initial, key) {
20
+ return String(fields[key] || "").trim() !== String(initial[key] || "").trim()
21
+ }
22
+
23
+ // How many fields differ.
24
+ export function changeCount(fields, initial) {
25
+ return Object.keys(fields).filter((key) => fieldChanged(fields, initial, key)).length
26
+ }
27
+
28
+ // env: { win, doc } for the tests.
29
+ export function studioProfileForm(initial, env) {
30
+ const win = (env && env.win) || window
31
+ const doc = (env && env.doc) || document
32
+
33
+ return {
34
+ // True once this scope has booted: the page's JS-less controls hide on it.
35
+ alpine: true,
36
+ fields: Object.assign({}, initial),
37
+ initial: Object.assign({}, initial),
38
+
39
+ changed(key) {
40
+ return fieldChanged(this.fields, this.initial, key)
41
+ },
42
+
43
+ get changeCount() {
44
+ return changeCount(this.fields, this.initial)
45
+ },
46
+
47
+ get dirty() {
48
+ return this.changeCount > 0
49
+ },
50
+
51
+ discard() {
52
+ this.fields = Object.assign({}, this.initial)
53
+ },
54
+
55
+ init() {
56
+ const self = this
57
+
58
+ // The exit is guarded both ways. beforeunload covers a hard navigation
59
+ // or a closed tab; turbo:before-visit covers a Turbo Drive visit, which
60
+ // never fires beforeunload.
61
+ this._beforeUnload = function (event) {
62
+ if (!self.dirty) return
63
+ event.preventDefault()
64
+ event.returnValue = ""
65
+ }
66
+ win.addEventListener("beforeunload", this._beforeUnload)
67
+
68
+ this._beforeVisit = function (event) {
69
+ if (!self.dirty) return
70
+ if (!win.confirm(LEAVE_PROMPT)) event.preventDefault()
71
+ }
72
+ doc.addEventListener("turbo:before-visit", this._beforeVisit)
73
+
74
+ // Submitting is not leaving: both guards drop before the form goes, so
75
+ // saving does not prompt about the changes being saved.
76
+ this.$refs.form.addEventListener("submit", function () { self.teardown() })
77
+ },
78
+
79
+ teardown() {
80
+ win.removeEventListener("beforeunload", this._beforeUnload)
81
+ doc.removeEventListener("turbo:before-visit", this._beforeVisit)
82
+ },
83
+
84
+ destroy() {
85
+ this.teardown()
86
+ }
87
+ }
88
+ }
@@ -19,6 +19,10 @@
19
19
  // LAZY below and registered by studio/lazy_controllers the first time an
20
20
  // element names one.
21
21
  //
22
+ // A page that must find its controller connected the moment it has loaded
23
+ // registers it from its own module tag instead (studio/style_guide, for the
24
+ // style guide's Modals section): the tag runs with the page, in document order.
25
+ //
22
26
  // WHY THE HOLD BUTTON IS STATIC. A lazy controller is one more request, made
23
27
  // when its element first appears: for the token modal, minutes after the page
24
28
  // loaded. If that request fails once (a dropped connection, or a deploy that