phlex-reactive 0.15.1 → 0.15.3
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 +188 -0
- data/README.md +82 -9
- data/app/controllers/phlex/reactive/actions_controller.rb +4 -1
- data/app/javascript/phlex/reactive/compute.js +14 -1
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
- data/app/javascript/phlex/reactive/core.min.js +4 -3
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +66 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/form.js +54 -22
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/persist.js +31 -2
- data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
- data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +6 -6
- data/app/javascript/phlex/reactive/runtime.js +476 -13
- data/lib/phlex/reactive/component/helpers.rb +24 -5
- data/lib/phlex/reactive/component/identity.rb +34 -1
- data/lib/phlex/reactive/component/image_fallback_binding.rb +54 -0
- data/lib/phlex/reactive/component/media_binding.rb +103 -0
- data/lib/phlex/reactive/component/media_pattern.rb +79 -0
- data/lib/phlex/reactive/doctor.rb +20 -8
- data/lib/phlex/reactive/held_fields.rb +87 -0
- data/lib/phlex/reactive/js.rb +153 -29
- data/lib/phlex/reactive/streamable.rb +26 -2
- data/lib/phlex/reactive/test_helpers.rb +11 -2
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +5 -1
|
@@ -200,6 +200,7 @@ export function registerReactiveStreamRender() {
|
|
|
200
200
|
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
201
201
|
streamRenderRegistered = true
|
|
202
202
|
document.addEventListener("turbo:before-stream-render", (event) => {
|
|
203
|
+
streamOwnReply(event)
|
|
203
204
|
streamIntoRoots(event)
|
|
204
205
|
streamModals(event)
|
|
205
206
|
// The opt-in entry sees every stream (it may hold one for a module the
|
|
@@ -356,6 +357,197 @@ export function __resetReactiveModalsForTest() {
|
|
|
356
357
|
modalsRegistered = false
|
|
357
358
|
}
|
|
358
359
|
|
|
360
|
+
// --- keep: a morph re-applies what a kept client op did (issue #369) ---------
|
|
361
|
+
// A morph rewrites `class`, `hidden` and every attribute from the server
|
|
362
|
+
// render, so a selection made with on_client ops was lost to the next reply or
|
|
363
|
+
// broadcast morph. An op with keep: records its RESULT on each target — each
|
|
364
|
+
// class on or off, `hidden`, an attribute's value (null: absent) — and the one
|
|
365
|
+
// document listener below puts that back after Turbo morphs the element in
|
|
366
|
+
// place. The result, not the op: a toggle must not flip again on every morph.
|
|
367
|
+
// The latest kept op on an element and a class or attribute wins, so "clear
|
|
368
|
+
// all, then select one" leaves one. Classes and attributes no kept op touched
|
|
369
|
+
// follow the render. Weak: an element a stream replaces is a new element.
|
|
370
|
+
//
|
|
371
|
+
// One Map per element: ".name" → whether that class is on; an attribute name →
|
|
372
|
+
// its value, null when absent. show/hide/toggle keep the `hidden` attribute,
|
|
373
|
+
// so the morph puts back the visibility without running a transition again.
|
|
374
|
+
// (No attribute name starts with ".", so the two kinds of key never meet.)
|
|
375
|
+
// An entry is re-inserted on every kept op, so the Map replays in op order:
|
|
376
|
+
// a kept add_class after a kept set_attr("class", …) still wins.
|
|
377
|
+
const kept = new WeakMap()
|
|
378
|
+
|
|
379
|
+
function keepResult(el, name, args, resolveTargets) {
|
|
380
|
+
const state = kept.get(el) ?? new Map()
|
|
381
|
+
kept.set(el, state)
|
|
382
|
+
const put = (key, value) => {
|
|
383
|
+
state.delete(key)
|
|
384
|
+
state.set(key, value)
|
|
385
|
+
}
|
|
386
|
+
if (name.endsWith("_class")) for (const c of args.classes ?? []) put(`.${c}`, el.classList.contains(c))
|
|
387
|
+
else if (name.endsWith("_attr")) attrRefused(args.name) || put(args.name, el.getAttribute(args.name))
|
|
388
|
+
else if (name === "show" || name === "hide" || name === "toggle") {
|
|
389
|
+
put("hidden", el.getAttribute("hidden"))
|
|
390
|
+
// The expanded: trigger's aria-expanded is kept on the trigger: a document
|
|
391
|
+
// listener has no op scope to resolve it from later.
|
|
392
|
+
if (args.expanded != null) {
|
|
393
|
+
for (const trigger of resolveTargets({ ...args, to: args.expanded })) {
|
|
394
|
+
keepResult(trigger, "set_attr", { name: "aria-expanded" })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let keepRegistered = false
|
|
401
|
+
|
|
402
|
+
export function registerReactiveKeep() {
|
|
403
|
+
if (keepRegistered) return
|
|
404
|
+
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
405
|
+
keepRegistered = true
|
|
406
|
+
// turbo:morph-element fires for every element Idiomorph morphed in place,
|
|
407
|
+
// after its attributes were synced from the new render.
|
|
408
|
+
document.addEventListener("turbo:morph-element", (event) => {
|
|
409
|
+
const el = event.target
|
|
410
|
+
for (const [key, value] of kept.get(el) ?? []) {
|
|
411
|
+
if (key[0] === ".") el.classList.toggle(key.slice(1), value)
|
|
412
|
+
else if (value == null) el.removeAttribute(key)
|
|
413
|
+
else if (el.getAttribute(key) !== value) el.setAttribute(key, value)
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function __resetReactiveKeepForTest() {
|
|
419
|
+
keepRegistered = false
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --- An image fallback that recovers (issue #383) ---------------------------
|
|
423
|
+
// data-reactive-image-fallback='{"hide":true,"alt":"#name"}' on an <img>. On
|
|
424
|
+
// `error` it gets data-reactive-image-state="error" (plus `hidden` with hide,
|
|
425
|
+
// and its alt shown in the `alt` node of its own reactive root); on `load`,
|
|
426
|
+
// "loaded". A later src clears the error: copy_media and preview_file reset
|
|
427
|
+
// it as they write (resetImage), any src that loads clears it on `load`, and
|
|
428
|
+
// a morph that changes the src lets the browser try again. A morph that keeps
|
|
429
|
+
// the src puts the state back. An error with no src is not an error (a
|
|
430
|
+
// removed or emptied src). One document listener, capture phase (neither
|
|
431
|
+
// event bubbles); nothing per root at connect.
|
|
432
|
+
const IMAGE_FALLBACK = "data-reactive-image-fallback"
|
|
433
|
+
const IMAGE_STATE = "data-reactive-image-state"
|
|
434
|
+
// Marks the `hidden` the fallback set, so a reset removes only that one. On
|
|
435
|
+
// the element, not in a WeakSet: a Turbo snapshot restores a NEW node.
|
|
436
|
+
const IMAGE_HIDDEN = "data-reactive-image-hidden"
|
|
437
|
+
let imageFallbackListeners = null
|
|
438
|
+
|
|
439
|
+
function imageFallback(img) {
|
|
440
|
+
if (img?.localName !== "img") return null
|
|
441
|
+
const raw = img.getAttribute(IMAGE_FALLBACK)
|
|
442
|
+
if (raw == null) return null
|
|
443
|
+
try {
|
|
444
|
+
return JSON.parse(raw) || {}
|
|
445
|
+
} catch {
|
|
446
|
+
return {}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const hasImageSrc = (img) => img.getAttribute("src") || img.getAttribute("srcset")
|
|
451
|
+
|
|
452
|
+
// The node that shows the alt text: a selector resolved inside the img's own
|
|
453
|
+
// root only (a document listener has no op scope).
|
|
454
|
+
function imageAltNode(img, wire) {
|
|
455
|
+
if (typeof wire.alt !== "string" || !wire.alt) return null
|
|
456
|
+
try {
|
|
457
|
+
const node = img.closest('[data-controller~="reactive"]')?.querySelector(wire.alt)
|
|
458
|
+
// Never a node that holds the img: its textContent would wipe the img.
|
|
459
|
+
return node && !node.contains(img) ? node : null
|
|
460
|
+
} catch {
|
|
461
|
+
return null
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function setImageState(img, wire, value) {
|
|
466
|
+
if (value === "error") {
|
|
467
|
+
if (wire.hide && !img.hasAttribute("hidden")) {
|
|
468
|
+
img.setAttribute("hidden", "")
|
|
469
|
+
img.setAttribute(IMAGE_HIDDEN, "")
|
|
470
|
+
}
|
|
471
|
+
const node = imageAltNode(img, wire)
|
|
472
|
+
if (node) {
|
|
473
|
+
node.textContent = img.getAttribute("alt") ?? ""
|
|
474
|
+
node.hidden = false
|
|
475
|
+
}
|
|
476
|
+
} else resetImage(img, wire)
|
|
477
|
+
img.setAttribute(IMAGE_STATE, value)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Undo what an error did — only what it did: `hidden` stays when the author
|
|
481
|
+
// set it. Called by copy_media and preview_file when they change the src.
|
|
482
|
+
function resetImage(img, wire = imageFallback(img)) {
|
|
483
|
+
if (!wire || img.getAttribute(IMAGE_STATE) == null) return
|
|
484
|
+
if (img.getAttribute(IMAGE_STATE) === "error") {
|
|
485
|
+
if (img.hasAttribute(IMAGE_HIDDEN)) img.removeAttribute("hidden")
|
|
486
|
+
const node = imageAltNode(img, wire)
|
|
487
|
+
if (node) node.hidden = true
|
|
488
|
+
}
|
|
489
|
+
img.removeAttribute(IMAGE_STATE)
|
|
490
|
+
img.removeAttribute(IMAGE_HIDDEN)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function onImageEvent(event) {
|
|
494
|
+
const img = event.target
|
|
495
|
+
const wire = imageFallback(img)
|
|
496
|
+
if (!wire) return
|
|
497
|
+
if (event.type === "load") setImageState(img, wire, "loaded")
|
|
498
|
+
else if (hasImageSrc(img)) setImageState(img, wire, "error")
|
|
499
|
+
else resetImage(img, wire)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function registerReactiveImageFallback() {
|
|
503
|
+
if (imageFallbackListeners) return
|
|
504
|
+
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
505
|
+
// A morph that keeps the src keeps the state: the render carries none, and
|
|
506
|
+
// the browser loads nothing new to tell us again. Put it back once the
|
|
507
|
+
// whole morph is done, so a later-morphed alt node is shown again too.
|
|
508
|
+
const kept = new WeakMap()
|
|
509
|
+
imageFallbackListeners = {
|
|
510
|
+
error: onImageEvent,
|
|
511
|
+
load: onImageEvent,
|
|
512
|
+
"turbo:before-morph-element": (event) => {
|
|
513
|
+
const img = event.target
|
|
514
|
+
const next = event.detail?.newElement
|
|
515
|
+
const value = img?.getAttribute?.(IMAGE_STATE)
|
|
516
|
+
if (value == null || !next || !imageFallback(img)) return
|
|
517
|
+
const same = (name) => img.getAttribute(name) === next.getAttribute?.(name)
|
|
518
|
+
if (same("src") && same("srcset")) kept.set(img, value)
|
|
519
|
+
},
|
|
520
|
+
"turbo:morph-element": (event) => {
|
|
521
|
+
const img = event.target
|
|
522
|
+
const value = kept.get(img)
|
|
523
|
+
if (value === undefined) return
|
|
524
|
+
kept.delete(img)
|
|
525
|
+
queueMicrotask(() => {
|
|
526
|
+
const wire = imageFallback(img)
|
|
527
|
+
if (wire) setImageState(img, wire, value)
|
|
528
|
+
})
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
for (const [name, fn] of Object.entries(imageFallbackListeners)) document.addEventListener(name, fn, true)
|
|
532
|
+
// An image that finished before this ran (a cached 404) fired its event
|
|
533
|
+
// unheard: read it off the element once. naturalWidth 0 is also a loaded
|
|
534
|
+
// SVG with only a viewBox, so decode() decides (it rejects a broken image).
|
|
535
|
+
for (const img of document.querySelectorAll?.(`img[${IMAGE_FALLBACK}]`) ?? []) {
|
|
536
|
+
if (!img.complete || !hasImageSrc(img)) continue
|
|
537
|
+
// A decode() that settles after the src changed decoded the OLD one: ignore it.
|
|
538
|
+
const was = `${img.getAttribute("src")} ${img.getAttribute("srcset")}`
|
|
539
|
+
const mark = (value) => `${img.getAttribute("src")} ${img.getAttribute("srcset")}` === was && setImageState(img, imageFallback(img) ?? {}, value)
|
|
540
|
+
if (img.naturalWidth > 0) mark("loaded")
|
|
541
|
+
else if (typeof img.decode === "function") img.decode().then(() => mark("loaded"), () => mark("error"))
|
|
542
|
+
else mark("error")
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function __resetReactiveImageFallbackForTest() {
|
|
547
|
+
for (const [name, fn] of Object.entries(imageFallbackListeners ?? {})) document.removeEventListener(name, fn, true)
|
|
548
|
+
imageFallbackListeners = null
|
|
549
|
+
}
|
|
550
|
+
|
|
359
551
|
// For the opt-in entry: what to do with a stream before its render — hold it
|
|
360
552
|
// for a module the incoming content needs. `handle(event, effects)` gets the
|
|
361
553
|
// loaded effects module (or undefined) and wraps the stream with it itself.
|
|
@@ -470,18 +662,21 @@ export function registerReactiveMorphFocus() {
|
|
|
470
662
|
const el = event.target
|
|
471
663
|
const next = event.detail?.newElement // absent when Turbo asks before a removal
|
|
472
664
|
if (!next || event.defaultPrevented) return
|
|
665
|
+
const holds = holdsTypedValue(el)
|
|
666
|
+
// Before the default below is overwritten (issue #370).
|
|
667
|
+
if (holds) noteHeld(el, next)
|
|
473
668
|
if (el.hasAttribute?.("data-reactive-editable")) {
|
|
474
669
|
serverTexts.set(el, editableValue(next))
|
|
475
|
-
if (!
|
|
670
|
+
if (!holds) return
|
|
476
671
|
for (const { name } of [...el.attributes]) {
|
|
477
|
-
if (!next.hasAttribute(name) && name !== "contenteditable") el.removeAttribute(name)
|
|
672
|
+
if (!next.hasAttribute(name) && name !== "contenteditable" && name !== HELD) el.removeAttribute(name)
|
|
478
673
|
}
|
|
479
674
|
for (const { name, value } of next.attributes) {
|
|
480
675
|
if (name !== "contenteditable" && el.getAttribute(name) !== value) el.setAttribute(name, value)
|
|
481
676
|
}
|
|
482
677
|
return event.preventDefault()
|
|
483
678
|
}
|
|
484
|
-
if (el.localName !== "input" || !
|
|
679
|
+
if (el.localName !== "input" || !holds) return
|
|
485
680
|
const value = next.getAttribute("value")
|
|
486
681
|
if (value == null) el.removeAttribute("value")
|
|
487
682
|
else if (el.getAttribute("value") !== value) el.setAttribute("value", value)
|
|
@@ -490,8 +685,108 @@ export function registerReactiveMorphFocus() {
|
|
|
490
685
|
// text when first focused — before anything can be typed into it.
|
|
491
686
|
document.addEventListener("focusin", (event) => seedServerText(event.target))
|
|
492
687
|
document.addEventListener("turbo:before-morph-attribute", (event) => {
|
|
493
|
-
|
|
688
|
+
const name = event.detail?.attributeName
|
|
689
|
+
if ((name === "value" || name === HELD) && holdsTypedValue(event.target)) event.preventDefault()
|
|
494
690
|
})
|
|
691
|
+
// The user took theirs: the field reads its default again.
|
|
692
|
+
document.addEventListener("input", (event) => {
|
|
693
|
+
const el = event.target
|
|
694
|
+
if (el?.hasAttribute?.(HELD) && typedValue(el) === serverValue(el)) el.removeAttribute(HELD)
|
|
695
|
+
})
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// --- Knowing what was held (issue #370) ---
|
|
699
|
+
// A morph that holds a focused field (above) over a render whose value differs
|
|
700
|
+
// from the field's default — its last server value — means someone else changed
|
|
701
|
+
// the field while the user typed. The field is marked data-reactive-held (kept
|
|
702
|
+
// through later morphs while focused, like `value`), a bubbling reactive:held
|
|
703
|
+
// { name, theirs } is dispatched, and the root's next action sends the names as
|
|
704
|
+
// X-Reactive-Held. The root's own reply never marks (it answers exactly those
|
|
705
|
+
// values), and once applied the root holds nothing. A morph of the field while
|
|
706
|
+
// unfocused writes the server's value and drops the mark with it.
|
|
707
|
+
const HELD = "data-reactive-held"
|
|
708
|
+
const ROOT = '[data-controller~="reactive"]'
|
|
709
|
+
let replyRoot = null
|
|
710
|
+
const replying = new Map()
|
|
711
|
+
|
|
712
|
+
function typedValue(el) {
|
|
713
|
+
return el.hasAttribute("data-reactive-editable") ? editableValue(el) : el.value
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function serverValue(el) {
|
|
717
|
+
return el.hasAttribute("data-reactive-editable") ? serverText(el) : el.defaultValue
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function renderedValue(next) {
|
|
721
|
+
if (next.hasAttribute("data-reactive-editable")) return editableValue(next)
|
|
722
|
+
return next.localName === "textarea" ? next.textContent : (next.getAttribute("value") ?? "")
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function noteHeld(el, next) {
|
|
726
|
+
const name = el.getAttribute("name")
|
|
727
|
+
// A checkbox's or radio's value is the option, not what the user typed.
|
|
728
|
+
if (!name || /^(checkbox|radio)$/.test(el.type) || replying.has(el.closest(ROOT))) return
|
|
729
|
+
const theirs = renderedValue(next)
|
|
730
|
+
if (theirs === typedValue(el)) return el.removeAttribute(HELD)
|
|
731
|
+
if (theirs === serverValue(el)) return
|
|
732
|
+
el.setAttribute(HELD, "")
|
|
733
|
+
el.dispatchEvent(new CustomEvent("reactive:held", { bubbles: true, detail: { name, theirs } }))
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// The names a root's next request reports: its own marked fields (not a nested
|
|
737
|
+
// root's), still different from their default. As a header value: JSON with
|
|
738
|
+
// every non-ASCII character escaped (fetch refuses a code point above 0xFF).
|
|
739
|
+
function heldHeader(root) {
|
|
740
|
+
const names = []
|
|
741
|
+
for (const el of root.querySelectorAll(`[${HELD}]`)) {
|
|
742
|
+
if (el.closest(ROOT) !== root) continue
|
|
743
|
+
const name = el.getAttribute("name")
|
|
744
|
+
if (typedValue(el) === serverValue(el)) el.removeAttribute(HELD)
|
|
745
|
+
else if (name && !names.includes(name)) names.push(name)
|
|
746
|
+
}
|
|
747
|
+
if (names.length === 0) return null
|
|
748
|
+
return JSON.stringify(names).replace(/[\u007f-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function releaseHeld(root) {
|
|
752
|
+
for (const el of root.querySelectorAll(`[${HELD}]`)) if (el.closest(ROOT) === root) el.removeAttribute(HELD)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Hand a root's own reply to Turbo: while its streams render nothing in the
|
|
756
|
+
// root is marked, and once they have the root holds nothing. Turbo announces
|
|
757
|
+
// each stream synchronously inside renderStreamMessage (streamOwnReply wraps
|
|
758
|
+
// its render, innermost); a reply none of whose streams rendered releases now.
|
|
759
|
+
// An error reply (release false) never marks either, but releases nothing: the
|
|
760
|
+
// server refused those values rather than answering them.
|
|
761
|
+
function renderOwnReply(root, html, release = true) {
|
|
762
|
+
const own = { root, streams: 0, release }
|
|
763
|
+
const previous = replyRoot
|
|
764
|
+
replyRoot = own
|
|
765
|
+
try {
|
|
766
|
+
window.Turbo.renderStreamMessage(html)
|
|
767
|
+
} finally {
|
|
768
|
+
replyRoot = previous
|
|
769
|
+
}
|
|
770
|
+
if (release && own.streams === 0) releaseHeld(root)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function streamOwnReply(event) {
|
|
774
|
+
const own = replyRoot
|
|
775
|
+
const detail = event.detail
|
|
776
|
+
const render = detail?.render
|
|
777
|
+
if (!own || typeof render !== "function") return
|
|
778
|
+
own.streams++
|
|
779
|
+
detail.render = async (el) => {
|
|
780
|
+
replying.set(own.root, (replying.get(own.root) || 0) + 1)
|
|
781
|
+
try {
|
|
782
|
+
return await render(el)
|
|
783
|
+
} finally {
|
|
784
|
+
const left = replying.get(own.root) - 1
|
|
785
|
+
if (left) replying.set(own.root, left)
|
|
786
|
+
else replying.delete(own.root)
|
|
787
|
+
if (own.release) releaseHeld(own.root)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
495
790
|
}
|
|
496
791
|
|
|
497
792
|
export function __resetReactiveMorphFocusForTest() {
|
|
@@ -693,6 +988,8 @@ export function registerReactiveActions() {
|
|
|
693
988
|
registerReactiveStreamRender()
|
|
694
989
|
registerReactiveMorphFocus()
|
|
695
990
|
registerReactiveModals()
|
|
991
|
+
registerReactiveKeep()
|
|
992
|
+
registerReactiveImageFallback()
|
|
696
993
|
registerReactivePlaintext()
|
|
697
994
|
registerReactiveOffline()
|
|
698
995
|
registerReactiveDev()
|
|
@@ -767,7 +1064,8 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
|
767
1064
|
// time; this guards a hand-built / forged ops attr from bypassing it. Refused:
|
|
768
1065
|
// event handlers (on*, XSS), URL-bearing names (a javascript: navigation
|
|
769
1066
|
// surface), and style (CSS injection). Case-insensitive, mirroring js.rb.
|
|
770
|
-
|
|
1067
|
+
// srcset (issue #373) too: a URL list that picks what an <img> loads.
|
|
1068
|
+
const REFUSED_ATTR_URL = new Set(["href", "src", "srcset", "srcdoc", "action", "formaction", "xlink:href", "style"])
|
|
771
1069
|
function attrRefused(name) {
|
|
772
1070
|
const lower = String(name).toLowerCase()
|
|
773
1071
|
return lower.startsWith("on") || REFUSED_ATTR_URL.has(lower)
|
|
@@ -964,6 +1262,19 @@ const CLIENT_OPS = Object.freeze({
|
|
|
964
1262
|
if (el.textContent !== text) el.textContent = text
|
|
965
1263
|
},
|
|
966
1264
|
|
|
1265
|
+
// Copy a server-rendered image (issue #373): src, srcset and alt from the
|
|
1266
|
+
// `from` <img>, resolved with the op's own scoping (the `submitter:`
|
|
1267
|
+
// precedent). The URL is read off that element, never from an argument —
|
|
1268
|
+
// the src refusal above stays. A selector that does not parse (a URL
|
|
1269
|
+
// string) is a miss, like one that matches nothing.
|
|
1270
|
+
copy_media: (el, args, resolveTargets) => {
|
|
1271
|
+
let source = null
|
|
1272
|
+
try {
|
|
1273
|
+
source = resolveTargets({ ...args, to: args.from })[0] ?? null
|
|
1274
|
+
} catch {}
|
|
1275
|
+
copyMedia(el, source)
|
|
1276
|
+
},
|
|
1277
|
+
|
|
967
1278
|
// Tick or untick a whole checkbox group (issue #342) — "clear selection".
|
|
968
1279
|
// The bindings feature flips the root's OWNED boxes the way its select-all
|
|
969
1280
|
// header does (every box set, then input + change on each flipped one) and
|
|
@@ -1033,6 +1344,17 @@ const CLIENT_OPS = Object.freeze({
|
|
|
1033
1344
|
// carries this op.
|
|
1034
1345
|
paste_into: (el) => pasteClipboardInto(el),
|
|
1035
1346
|
|
|
1347
|
+
// Preview a chosen file (issue #375): paint the input's first image file
|
|
1348
|
+
// onto the `into` <img> through an object URL made HERE from the input's
|
|
1349
|
+
// own File. The op carries no URL and `args.url` is never read, so the src
|
|
1350
|
+
// refusal of set_attr (issue #96) stays whole. ACTOR-ONLY like paste_into
|
|
1351
|
+
// (BROADCAST_REFUSED_OPS server-side).
|
|
1352
|
+
preview_file: (el, args, resolveTargets) => previewFile(el, args, resolveTargets),
|
|
1353
|
+
|
|
1354
|
+
// A field's live value (issue #374): the paste_into write without the
|
|
1355
|
+
// clipboard or the focus. ACTOR-ONLY (BROADCAST_REFUSED_OPS server-side).
|
|
1356
|
+
set_value: (el, args) => setFieldValue(el, String(args.value ?? "")),
|
|
1357
|
+
|
|
1036
1358
|
// Client-only drafts (issue #239): merge a flat state bag into the root's
|
|
1037
1359
|
// reactive_persist draft / forget the draft. ACTOR-ONLY like focus/submit
|
|
1038
1360
|
// (BROADCAST_REFUSED_OPS server-side) — rewriting or wiping every
|
|
@@ -1074,7 +1396,7 @@ function pasteClipboardInto(field) {
|
|
|
1074
1396
|
.then((text) => {
|
|
1075
1397
|
if (!text) return
|
|
1076
1398
|
field.value = text
|
|
1077
|
-
|
|
1399
|
+
dispatchInput(field)
|
|
1078
1400
|
field.focus?.()
|
|
1079
1401
|
})
|
|
1080
1402
|
.catch(() => {
|
|
@@ -1083,6 +1405,109 @@ function pasteClipboardInto(field) {
|
|
|
1083
1405
|
})
|
|
1084
1406
|
}
|
|
1085
1407
|
|
|
1408
|
+
// --- Preview a chosen file (issue #375) ---
|
|
1409
|
+
// img -> the object URL preview_file last made for it. The runtime owns it:
|
|
1410
|
+
// never an op argument, never an attribute a caller sets. An entry lives
|
|
1411
|
+
// while the img is on the page AND still shows that URL; a re-pick, a clear,
|
|
1412
|
+
// a non-image file, the img leaving the page or a morph that strips its src
|
|
1413
|
+
// in place revokes it. One MutationObserver notices the last two, and only
|
|
1414
|
+
// while some preview is live: it then wakes on every DOM mutation of the page
|
|
1415
|
+
// (a live feed, a drag), and each wake is a walk over the few live previews.
|
|
1416
|
+
const previews = new Map()
|
|
1417
|
+
let previewObserver = null
|
|
1418
|
+
|
|
1419
|
+
function previewFile(input, args, resolveTargets) {
|
|
1420
|
+
if (input?.tagName !== "INPUT" || input.type !== "file") {
|
|
1421
|
+
return warnPreview(`reads an input[type=file], not <${input?.localName}>`)
|
|
1422
|
+
}
|
|
1423
|
+
const img = typeof resolveTargets === "function" ? resolveTargets({ ...args, to: args.into })[0] : null
|
|
1424
|
+
// Duck-typed on tagName (the isDialog precedent): only an <img> ever gets a
|
|
1425
|
+
// src here — never an iframe, a script, a frame or anything else.
|
|
1426
|
+
if (img?.tagName !== "IMG") {
|
|
1427
|
+
return warnPreview(`paints an <img>, and ${JSON.stringify(args.into)} is ${img ? `a <${img.localName}>` : "not on the page"}`)
|
|
1428
|
+
}
|
|
1429
|
+
// valid: true (issue #382): an input the app marked invalid (setCustomValidity,
|
|
1430
|
+
// required) previews as empty. validity.valid, not checkValidity(): no
|
|
1431
|
+
// `invalid` event fired into the app's own listeners.
|
|
1432
|
+
const files = args.valid === true && input.validity?.valid === false ? [] : (input.files ?? [])
|
|
1433
|
+
const file = [...files].find((f) => typeof f?.type === "string" && f.type.startsWith("image/"))
|
|
1434
|
+
forgetPreview(img)
|
|
1435
|
+
if (file) {
|
|
1436
|
+
const url = URL.createObjectURL(file)
|
|
1437
|
+
previews.set(img, url)
|
|
1438
|
+
img.setAttribute("src", url)
|
|
1439
|
+
watchPreviews()
|
|
1440
|
+
} else img.removeAttribute("src")
|
|
1441
|
+
resetImage(img)
|
|
1442
|
+
if (args.name == null) return
|
|
1443
|
+
for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function warnPreview(why) {
|
|
1447
|
+
console.warn(`[phlex-reactive] preview_file ${why} — skipped`)
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// Revoke the URL preview_file made for `img`, if it made one.
|
|
1451
|
+
function forgetPreview(img) {
|
|
1452
|
+
const url = previews.get(img)
|
|
1453
|
+
if (url === undefined) return
|
|
1454
|
+
previews.delete(img)
|
|
1455
|
+
URL.revokeObjectURL(url)
|
|
1456
|
+
if (previews.size === 0) {
|
|
1457
|
+
previewObserver?.disconnect()
|
|
1458
|
+
previewObserver = null
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function watchPreviews() {
|
|
1463
|
+
if (previewObserver || typeof MutationObserver !== "function" || !document?.documentElement) return
|
|
1464
|
+
previewObserver = new MutationObserver(() => {
|
|
1465
|
+
for (const [img, url] of previews) if (!img.isConnected || img.getAttribute("src") !== url) forgetPreview(img)
|
|
1466
|
+
})
|
|
1467
|
+
previewObserver.observe(document.documentElement, {
|
|
1468
|
+
childList: true,
|
|
1469
|
+
subtree: true,
|
|
1470
|
+
attributes: true,
|
|
1471
|
+
attributeFilter: ["src"],
|
|
1472
|
+
})
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
export function __resetReactivePreviewsForTest() {
|
|
1476
|
+
previewObserver?.disconnect()
|
|
1477
|
+
previewObserver = null
|
|
1478
|
+
previews.clear()
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// The set-value contract's dispatch (issue #183): a bubbling `input`, so
|
|
1482
|
+
// compute reducers, show bindings and on_complete run as if the user typed.
|
|
1483
|
+
function dispatchInput(field) {
|
|
1484
|
+
if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// The body of the set_value op (issue #374). Writes the IDL .value — never the
|
|
1488
|
+
// attribute, never HTML — and dispatches `input` only when the field's value
|
|
1489
|
+
// actually moved (compared after the write: a textarea normalizes CRLF, a
|
|
1490
|
+
// select ends at "" for a value no option has). No `change`, no focus. Only an
|
|
1491
|
+
// input, textarea or select is a target, so a contenteditable (reactive_editable's
|
|
1492
|
+
// to write) warns and skips like any other node. A file input takes "" alone —
|
|
1493
|
+
// the browser throws on anything else.
|
|
1494
|
+
function setFieldValue(field, value) {
|
|
1495
|
+
if (!/^(input|textarea|select)$/.test(field.localName) || (field.type === "file" && value !== "")) {
|
|
1496
|
+
console.warn("[phlex-reactive] set_value skipped: not a field it can write", field)
|
|
1497
|
+
return
|
|
1498
|
+
}
|
|
1499
|
+
const before = fieldState(field)
|
|
1500
|
+
field.value = value
|
|
1501
|
+
if (fieldState(field) !== before) dispatchInput(field)
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// What set_value compares: .value, or for a <select multiple> its whole
|
|
1505
|
+
// selection — assigning .value deselects every other option, which .value
|
|
1506
|
+
// (the first selected) alone would not show.
|
|
1507
|
+
function fieldState(field) {
|
|
1508
|
+
return field.type === "select-multiple" ? [...field.selectedOptions].map((o) => o.value).join("\n") : field.value
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1086
1511
|
// Apply a hidden-flag change, optionally animated by a [during, from, to]
|
|
1087
1512
|
// transition (issue #96). Split out so show/hide/toggle share it.
|
|
1088
1513
|
function setHidden(el, hidden, args) {
|
|
@@ -1110,6 +1535,35 @@ function guardAttr(name) {
|
|
|
1110
1535
|
return false
|
|
1111
1536
|
}
|
|
1112
1537
|
|
|
1538
|
+
// Copy src, srcset and alt from one <img> onto another (issue #373) — the
|
|
1539
|
+
// copy_media op and the reactive_media binding (the bindings feature reaches it
|
|
1540
|
+
// through the core handle). Only an <img> target is written, so an iframe,
|
|
1541
|
+
// script, object, embed or frame never receives a src from here. A source that
|
|
1542
|
+
// is missing or not an <img> clears the target. An <img> source without src is
|
|
1543
|
+
// a hit with no image (issue #381): the target loses src and srcset but takes
|
|
1544
|
+
// the alt, so a known entry without a logo keeps its name. Each attribute is
|
|
1545
|
+
// written only when it changes, so the same URL twice does not reload. Returns
|
|
1546
|
+
// the alt copied, false when it cleared, or null when the target was skipped.
|
|
1547
|
+
function copyMedia(target, source) {
|
|
1548
|
+
if (target?.localName !== "img") {
|
|
1549
|
+
console.warn(`[phlex-reactive] copy_media targets an <img>, not <${target?.localName}> — skipped`)
|
|
1550
|
+
return null
|
|
1551
|
+
}
|
|
1552
|
+
const img = source?.localName === "img" ? source : null
|
|
1553
|
+
const src = img?.getAttribute("src")
|
|
1554
|
+
let changed = false
|
|
1555
|
+
for (const name of ["srcset", "src"]) {
|
|
1556
|
+
const value = src ? img.getAttribute(name) : null
|
|
1557
|
+
if (value == null) {
|
|
1558
|
+
if (target.hasAttribute(name)) changed = target.removeAttribute(name) ?? true
|
|
1559
|
+
} else if (target.getAttribute(name) !== value) changed = target.setAttribute(name, value) ?? true
|
|
1560
|
+
}
|
|
1561
|
+
if (changed) resetImage(target)
|
|
1562
|
+
const alt = img?.getAttribute("alt") ?? ""
|
|
1563
|
+
if (target.getAttribute("alt") !== alt) target.setAttribute("alt", alt)
|
|
1564
|
+
return img ? alt : false
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1113
1567
|
// The first focusable descendant of `el`, in document order — the natural
|
|
1114
1568
|
// keyboard target inside an opened menu/dialog. Covers the standard focusable
|
|
1115
1569
|
// set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
|
|
@@ -1352,7 +1806,10 @@ function applyOps(list, resolveTargets, onZeroTargets) {
|
|
|
1352
1806
|
}
|
|
1353
1807
|
const targets = resolveTargets(args)
|
|
1354
1808
|
if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
|
|
1355
|
-
for (const el of targets)
|
|
1809
|
+
for (const el of targets) {
|
|
1810
|
+
CLIENT_OPS[name](el, args, resolveTargets)
|
|
1811
|
+
if (args?.keep === true) keepResult(el, name, args, resolveTargets)
|
|
1812
|
+
}
|
|
1356
1813
|
}
|
|
1357
1814
|
}
|
|
1358
1815
|
|
|
@@ -1497,8 +1954,8 @@ function formNeeded(root) {
|
|
|
1497
1954
|
// Show bindings and cross-root show targets, completion bindings, option
|
|
1498
1955
|
// filtering, the bulk-selection group bindings (enable, select-all, count —
|
|
1499
1956
|
// issue #319) and their cross-root targets (#343), the tag-chip input, draft
|
|
1500
|
-
// nested rows
|
|
1501
|
-
// bindings. The root declares one, or
|
|
1957
|
+
// nested rows, editable text's keys and paste (#351) and media previews
|
|
1958
|
+
// (#373): a form's client-only bindings. The root declares one, or
|
|
1502
1959
|
// owns an element that does. (The probes the connect-time gates ran before
|
|
1503
1960
|
// the split, unchanged.)
|
|
1504
1961
|
function bindingsNeeded(root) {
|
|
@@ -1508,7 +1965,7 @@ function bindingsNeeded(root) {
|
|
|
1508
1965
|
) ||
|
|
1509
1966
|
(root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
|
|
1510
1967
|
root.querySelector?.(
|
|
1511
|
-
"[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count], [data-reactive-editable]",
|
|
1968
|
+
"[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count], [data-reactive-editable], [data-reactive-media]",
|
|
1512
1969
|
) != null
|
|
1513
1970
|
)
|
|
1514
1971
|
}
|
|
@@ -2342,6 +2799,8 @@ export default class extends Controller {
|
|
|
2342
2799
|
plain: plaintextSupported,
|
|
2343
2800
|
text: editableValue,
|
|
2344
2801
|
served: serverText,
|
|
2802
|
+
// Copy one <img> onto another (issue #373): the reactive_media binding.
|
|
2803
|
+
copyMedia,
|
|
2345
2804
|
// The confirm gate, as dispatch() runs it: the resolver inside the chain
|
|
2346
2805
|
// (a synchronous throw is a cancel), never a rejection out.
|
|
2347
2806
|
confirm: (message, context) =>
|
|
@@ -3340,6 +3799,9 @@ export default class extends Controller {
|
|
|
3340
3799
|
presenceNeeded(this.element) &&
|
|
3341
3800
|
document.getElementById(`reactive-presence-src-${this.element.id}`)?.getAttribute("connection-id")
|
|
3342
3801
|
if (presence) headers["X-Reactive-Presence"] = presence
|
|
3802
|
+
// The fields a morph held over a changed server value (issue #370).
|
|
3803
|
+
const held = heldHeader(this.element)
|
|
3804
|
+
if (held) headers["X-Reactive-Held"] = held
|
|
3343
3805
|
|
|
3344
3806
|
// ONLY `fetch` itself is the network boundary — offline, DNS, a reset
|
|
3345
3807
|
// connection. Everything below this inner try (reading the body,
|
|
@@ -3421,7 +3883,7 @@ export default class extends Controller {
|
|
|
3421
3883
|
if (__SPLIT__) this.#devtools((devtools) => devtools.recordBody(debug, errorBody, fresh))
|
|
3422
3884
|
else devtoolsFeature.recordBody(debug, errorBody, fresh)
|
|
3423
3885
|
}
|
|
3424
|
-
|
|
3886
|
+
renderOwnReply(this.element, errorBody, false)
|
|
3425
3887
|
}
|
|
3426
3888
|
this.#markError("http")
|
|
3427
3889
|
this.#emitError(action, params, allParams, { kind: "http", status: response.status, body: errorBody })
|
|
@@ -3452,8 +3914,9 @@ export default class extends Controller {
|
|
|
3452
3914
|
// Turbo applies the <turbo-stream> ops by id. A plain replace is an
|
|
3453
3915
|
// outerHTML swap (focus on the replaced subtree is lost); a method="morph"
|
|
3454
3916
|
// replace (Response.morph) or an update morphs in place, preserving the
|
|
3455
|
-
// focused input + caret on unchanged nodes — see issue #28.
|
|
3456
|
-
|
|
3917
|
+
// focused input + caret on unchanged nodes — see issue #28. As the root's
|
|
3918
|
+
// own reply, it clears what the root held (issue #370).
|
|
3919
|
+
renderOwnReply(this.element, html)
|
|
3457
3920
|
// Debug-only (issue #181): the morph may apply a microtask later, so check
|
|
3458
3921
|
// the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
|
|
3459
3922
|
// null — zero cost.
|
|
@@ -15,6 +15,8 @@ module Phlex
|
|
|
15
15
|
include Selection
|
|
16
16
|
include Sortable
|
|
17
17
|
include Editable
|
|
18
|
+
include MediaBinding
|
|
19
|
+
include ImageFallbackBinding
|
|
18
20
|
|
|
19
21
|
# The acting client's SSE connection id during the current action (nil
|
|
20
22
|
# outside an action, or when the client isn't subscribed to a stream).
|
|
@@ -31,6 +33,20 @@ module Phlex
|
|
|
31
33
|
Phlex::Reactive.current_connection_id
|
|
32
34
|
end
|
|
33
35
|
|
|
36
|
+
# The names of this action's posted fields that a morph held over a
|
|
37
|
+
# changed server value (issue #370): someone else changed the field
|
|
38
|
+
# while this user typed, and this tab kept the user's text. [] when
|
|
39
|
+
# nothing was held, and outside an action. Filtered to the action's
|
|
40
|
+
# declared params; a hint about what the user saw, never authorization.
|
|
41
|
+
#
|
|
42
|
+
# def save(title: nil)
|
|
43
|
+
# return conflict!(title) if reactive_held_fields.include?("title") && title != @note.title
|
|
44
|
+
# @note.update!(title:)
|
|
45
|
+
# end
|
|
46
|
+
def reactive_held_fields
|
|
47
|
+
Phlex::Reactive::HeldFields.current
|
|
48
|
+
end
|
|
49
|
+
|
|
34
50
|
# Manually satisfy the verify_authorized guard (issue #168) for a bespoke
|
|
35
51
|
# authorization check the interceptor can't see — a hand-rolled policy, a
|
|
36
52
|
# feature flag, an ownership comparison that doesn't go through one of
|
|
@@ -1751,13 +1767,16 @@ module Phlex
|
|
|
1751
1767
|
|
|
1752
1768
|
private_constant :REMOVED_ON_KWARGS, :PENDING_CLASS_OPS
|
|
1753
1769
|
|
|
1770
|
+
# Runs on every on() (a hot path): a key lookup per removed name, no arrays.
|
|
1754
1771
|
def reject_removed_on_kwargs!(action_name, params)
|
|
1755
|
-
|
|
1756
|
-
return if removed.empty?
|
|
1772
|
+
return if params.empty?
|
|
1757
1773
|
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1774
|
+
REMOVED_ON_KWARGS.each_key do
|
|
1775
|
+
next unless params.key?(it)
|
|
1776
|
+
|
|
1777
|
+
raise ArgumentError,
|
|
1778
|
+
"on(#{action_name.inspect}) #{it}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[it]}"
|
|
1779
|
+
end
|
|
1761
1780
|
end
|
|
1762
1781
|
|
|
1763
1782
|
# Issue #357: parse on()'s shortcut: (nil when absent). A shortcut
|