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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/app/javascript/studio/alpine_scopes.js +66 -0
- data/app/javascript/studio/alpine_shims.js +4 -0
- data/app/javascript/studio/application.js +4 -1
- data/app/javascript/studio/birthday.js +351 -0
- data/app/javascript/studio/confirm_interstitial.js +44 -0
- data/app/javascript/studio/controllers/identity_mini_controller.js +32 -0
- data/app/javascript/studio/controllers/style_modals_controller.js +41 -0
- data/app/javascript/studio/cropper.js +281 -0
- data/app/javascript/studio/identity_mini.js +74 -0
- data/app/javascript/studio/image_upload.js +189 -0
- data/app/javascript/studio/profile_form.js +88 -0
- data/app/javascript/studio/stimulus.js +4 -0
- data/app/javascript/studio/style_guide.js +13 -0
- data/app/javascript/studio/style_modals.js +214 -0
- data/app/views/components/_avatar_cropper.html.erb +3 -38
- data/app/views/layouts/studio/_head.html.erb +12 -2
- data/app/views/studio/_alpine_scopes_guard.html.erb +178 -0
- data/app/views/studio/_birthday_assets.html.erb +6 -240
- data/app/views/studio/_confirm_interstitial.html.erb +31 -18
- data/app/views/studio/_cropper_assets.html.erb +9 -8
- data/app/views/studio/modals/_crop_photo.html.erb +1 -1
- data/app/views/studio/modals/_image_upload.html.erb +8 -321
- data/app/views/studio/modals/blocks/_birthday.html.erb +2 -3
- data/app/views/studio/profiles/_birthday_fields.html.erb +0 -1
- data/app/views/studio/profiles/_identity_mini.html.erb +5 -62
- data/app/views/studio/profiles/_plain_save.html.erb +11 -0
- data/app/views/studio/profiles/edit.html.erb +1 -6
- data/app/views/style/_modals.html.erb +36 -420
- data/lib/studio/version.rb +1 -1
- metadata +14 -3
- data/app/views/studio/profiles/_birthday_picker_script.html.erb +0 -146
- 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
|