From 7212a56296a7328bba8b5e73557494ce86aa32b9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 12 Mar 2024 12:19:34 -0400 Subject: [PATCH] chore: move bindings code (#10772) * move bindings to separate file * tidy up * errrr this code is confusing as hell but it works and the tests pass. fix it another day --------- Co-authored-by: Rich Harris --- .../src/internal/client/dom/bindings/input.js | 170 ++++ .../src/internal/client/dom/bindings/media.js | 278 ++++++ .../internal/client/dom/bindings/navigator.js | 11 + .../src/internal/client/dom/bindings/props.js | 22 + .../internal/client/dom/bindings/select.js | 116 +++ .../internal/client/dom/bindings/shared.js | 27 + .../src/internal/client/dom/bindings/size.js | 107 +++ .../src/internal/client/dom/bindings/this.js | 60 ++ .../internal/client/dom/bindings/universal.js | 69 ++ .../internal/client/dom/bindings/window.js | 61 ++ packages/svelte/src/internal/client/render.js | 850 ------------------ packages/svelte/src/internal/index.js | 9 + 12 files changed, 930 insertions(+), 850 deletions(-) create mode 100644 packages/svelte/src/internal/client/dom/bindings/input.js create mode 100644 packages/svelte/src/internal/client/dom/bindings/media.js create mode 100644 packages/svelte/src/internal/client/dom/bindings/navigator.js create mode 100644 packages/svelte/src/internal/client/dom/bindings/props.js create mode 100644 packages/svelte/src/internal/client/dom/bindings/select.js create mode 100644 packages/svelte/src/internal/client/dom/bindings/shared.js create mode 100644 packages/svelte/src/internal/client/dom/bindings/size.js create mode 100644 packages/svelte/src/internal/client/dom/bindings/this.js create mode 100644 packages/svelte/src/internal/client/dom/bindings/universal.js create mode 100644 packages/svelte/src/internal/client/dom/bindings/window.js diff --git a/packages/svelte/src/internal/client/dom/bindings/input.js b/packages/svelte/src/internal/client/dom/bindings/input.js new file mode 100644 index 0000000000..67ffd7562e --- /dev/null +++ b/packages/svelte/src/internal/client/dom/bindings/input.js @@ -0,0 +1,170 @@ +import { DEV } from 'esm-env'; +import { render_effect } from '../../reactivity/effects.js'; +import { stringify } from '../../render.js'; + +/** + * @param {HTMLInputElement} input + * @param {() => unknown} get_value + * @param {(value: unknown) => void} update + * @returns {void} + */ +export function bind_value(input, get_value, update) { + input.addEventListener('input', () => { + if (DEV && input.type === 'checkbox') { + throw new Error( + 'Using bind:value together with a checkbox input is not allowed. Use bind:checked instead' + ); + } + + update(is_numberlike_input(input) ? to_number(input.value) : input.value); + }); + + render_effect(() => { + if (DEV && input.type === 'checkbox') { + throw new Error( + 'Using bind:value together with a checkbox input is not allowed. Use bind:checked instead' + ); + } + + var value = get_value(); + + // @ts-ignore + input.__value = value; + + if (is_numberlike_input(input) && value === to_number(input.value)) { + // handles 0 vs 00 case (see https://github.com/sveltejs/svelte/issues/9959) + return; + } + + if (input.type === 'date' && !value && !input.value) { + // Handles the case where a temporarily invalid date is set (while typing, for example with a leading 0 for the day) + // and prevents this state from clearing the other parts of the date input (see https://github.com/sveltejs/svelte/issues/7897) + return; + } + + input.value = stringify(value); + }); +} + +/** + * @param {Array} inputs + * @param {null | [number]} group_index + * @param {HTMLInputElement} input + * @param {() => unknown} get_value + * @param {(value: unknown) => void} update + * @returns {void} + */ +export function bind_group(inputs, group_index, input, get_value, update) { + var is_checkbox = input.getAttribute('type') === 'checkbox'; + var binding_group = inputs; + + if (group_index !== null) { + for (var index of group_index) { + var group = binding_group; + // @ts-ignore + binding_group = group[index]; + if (binding_group === undefined) { + // @ts-ignore + binding_group = group[index] = []; + } + } + } + + binding_group.push(input); + + input.addEventListener('change', () => { + // @ts-ignore + var value = input.__value; + + if (is_checkbox) { + value = get_binding_group_value(binding_group, value, input.checked); + } + + update(value); + }); + + render_effect(() => { + var value = get_value(); + + if (is_checkbox) { + value = value || []; + // @ts-ignore + input.checked = value.includes(input.__value); + } else { + // @ts-ignore + input.checked = input.__value === value; + } + }); + + render_effect(() => { + return () => { + var index = binding_group.indexOf(input); + + if (index !== -1) { + binding_group.splice(index, 1); + } + }; + }); +} + +/** + * @param {HTMLInputElement} input + * @param {() => unknown} get_value + * @param {(value: unknown) => void} update + * @returns {void} + */ +export function bind_checked(input, get_value, update) { + input.addEventListener('change', () => { + var value = input.checked; + update(value); + }); + + // eslint-disable-next-line eqeqeq + if (get_value() == undefined) { + update(false); + } + + render_effect(() => { + var value = get_value(); + input.checked = Boolean(value); + }); +} + +/** + * @template V + * @param {Array} group + * @param {V} __value + * @param {boolean} checked + * @returns {V[]} + */ +function get_binding_group_value(group, __value, checked) { + var value = new Set(); + + for (var i = 0; i < group.length; i += 1) { + if (group[i].checked) { + // @ts-ignore + value.add(group[i].__value); + } + } + + if (!checked) { + value.delete(__value); + } + + return Array.from(value); +} + +/** + * @param {HTMLInputElement} input + */ +function is_numberlike_input(input) { + var type = input.type; + return type === 'number' || type === 'range'; +} + +/** + * @param {string} value + */ +function to_number(value) { + return value === '' ? null : +value; +} diff --git a/packages/svelte/src/internal/client/dom/bindings/media.js b/packages/svelte/src/internal/client/dom/bindings/media.js new file mode 100644 index 0000000000..35ec4fa196 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/bindings/media.js @@ -0,0 +1,278 @@ +import { hydrating } from '../../hydration.js'; +import { destroy_effect, managed_effect, render_effect } from '../../reactivity/effects.js'; +import { listen } from './shared.js'; + +/** @param {TimeRanges} ranges */ +function time_ranges_to_array(ranges) { + var array = []; + + for (var i = 0; i < ranges.length; i += 1) { + array.push({ start: ranges.start(i), end: ranges.end(i) }); + } + + return array; +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {() => number | undefined} get_value + * @param {(value: number) => void} update + * @returns {void} + */ +export function bind_current_time(media, get_value, update) { + /** @type {number} */ + var raf_id; + var updating = false; + + // Ideally, listening to timeupdate would be enough, but it fires too infrequently for the currentTime + // binding, which is why we use a raf loop, too. We additionally still listen to timeupdate because + // the user could be scrubbing through the video using the native controls when the media is paused. + var callback = () => { + cancelAnimationFrame(raf_id); + + if (!media.paused) { + raf_id = requestAnimationFrame(callback); + } + + updating = true; + update(media.currentTime); + }; + + raf_id = requestAnimationFrame(callback); + media.addEventListener('timeupdate', callback); + + render_effect(() => { + var value = get_value(); + + // through isNaN we also allow number strings, which is more robust + if (!updating && !isNaN(/** @type {any} */ (value))) { + media.currentTime = /** @type {number} */ (value); + } + + updating = false; + }); + + render_effect(() => () => cancelAnimationFrame(raf_id)); +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {(array: Array<{ start: number; end: number }>) => void} update + */ +export function bind_buffered(media, update) { + listen(media, ['loadedmetadata', 'progress'], () => update(time_ranges_to_array(media.buffered))); +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {(array: Array<{ start: number; end: number }>) => void} update + */ +export function bind_seekable(media, update) { + listen(media, ['loadedmetadata'], () => update(time_ranges_to_array(media.seekable))); +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {(array: Array<{ start: number; end: number }>) => void} update + */ +export function bind_played(media, update) { + listen(media, ['timeupdate'], () => update(time_ranges_to_array(media.played))); +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {(seeking: boolean) => void} update + */ +export function bind_seeking(media, update) { + listen(media, ['seeking', 'seeked'], () => update(media.seeking)); +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {(seeking: boolean) => void} update + */ +export function bind_ended(media, update) { + listen(media, ['timeupdate', 'ended'], () => update(media.ended)); +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {(ready_state: number) => void} update + */ +export function bind_ready_state(media, update) { + listen( + media, + ['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'playing', 'waiting', 'emptied'], + () => update(media.readyState) + ); +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {() => number | undefined} get_value + * @param {(playback_rate: number) => void} update + */ +export function bind_playback_rate(media, get_value, update) { + var updating = false; + + var callback = () => { + if (!updating) { + update(media.playbackRate); + } + updating = false; + }; + + // Needs to happen after the element is inserted into the dom, else playback will be set back to 1 by the browser. + // For hydration we could do it immediately but the additional code is not worth the lost microtask. + + /** @type {import('#client').Effect | undefined} */ + var render; + var destroyed = false; + + var effect = managed_effect(() => { + destroy_effect(effect); + + if (destroyed) return; + + if (get_value() == null) { + callback(); + } + + listen(media, ['ratechange'], callback, false); + + render = render_effect(() => { + var value = get_value(); + + // through isNaN we also allow number strings, which is more robust + if (!isNaN(/** @type {any} */ (value)) && value !== media.playbackRate) { + updating = true; + media.playbackRate = /** @type {number} */ (value); + } + }); + }); + + render_effect(() => () => { + destroyed = true; + if (render) { + destroy_effect(render); + } + }); +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {() => boolean | undefined} get_value + * @param {(paused: boolean) => void} update + */ +export function bind_paused(media, get_value, update) { + var mounted = hydrating; + var paused = get_value(); + + var callback = () => { + if (paused !== media.paused) { + paused = media.paused; + update((paused = media.paused)); + } + }; + + if (paused == null) { + callback(); + } + + // Defer listening if not mounted yet so that the first canplay event doesn't cause a potentially wrong update + if (mounted) { + // If someone switches the src while media is playing, the player will pause. + // Listen to the canplay event to get notified of this situation. + listen(media, ['play', 'pause', 'canplay'], callback, false); + } + + render_effect(() => { + paused = !!get_value(); + + if (paused !== media.paused) { + var toggle = () => { + mounted = true; + if (paused) { + media.pause(); + } else { + media.play().catch(() => { + update((paused = true)); + }); + } + }; + + if (mounted) { + toggle(); + } else { + // If this is the first invocation in dom mode, the media element isn't mounted yet, + // and therefore its resource isn't loaded yet. We need to wait for the canplay event + // in this case or else we'll get a "The play() request was interrupted by a new load request" error. + media.addEventListener( + 'canplay', + () => { + listen(media, ['play', 'pause', 'canplay'], callback, false); + toggle(); + }, + { once: true } + ); + } + } + }); +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {() => number | undefined} get_value + * @param {(volume: number) => void} update + */ +export function bind_volume(media, get_value, update) { + var updating = false; + var callback = () => { + updating = true; + update(media.volume); + }; + + if (get_value() == null) { + callback(); + } + + listen(media, ['volumechange'], callback, false); + + render_effect(() => { + var value = get_value(); + + // through isNaN we also allow number strings, which is more robust + if (!updating && !isNaN(/** @type {any} */ (value))) { + media.volume = /** @type {number} */ (value); + } + + updating = false; + }); +} + +/** + * @param {HTMLVideoElement | HTMLAudioElement} media + * @param {() => boolean | undefined} get_value + * @param {(muted: boolean) => void} update + */ +export function bind_muted(media, get_value, update) { + var updating = false; + + var callback = () => { + updating = true; + update(media.muted); + }; + + if (get_value() == null) { + callback(); + } + + listen(media, ['volumechange'], callback, false); + + render_effect(() => { + var value = get_value(); + + if (!updating) media.muted = !!value; + updating = false; + }); +} diff --git a/packages/svelte/src/internal/client/dom/bindings/navigator.js b/packages/svelte/src/internal/client/dom/bindings/navigator.js new file mode 100644 index 0000000000..a5d505400c --- /dev/null +++ b/packages/svelte/src/internal/client/dom/bindings/navigator.js @@ -0,0 +1,11 @@ +import { listen } from './shared.js'; + +/** + * @param {(online: boolean) => void} update + * @returns {void} + */ +export function bind_online(update) { + listen(window, ['online', 'offline'], () => { + update(navigator.onLine); + }); +} diff --git a/packages/svelte/src/internal/client/dom/bindings/props.js b/packages/svelte/src/internal/client/dom/bindings/props.js new file mode 100644 index 0000000000..0142f3a6f8 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/bindings/props.js @@ -0,0 +1,22 @@ +import { render_effect } from '../../reactivity/effects.js'; +import { get_descriptor } from '../../utils.js'; + +/** + * Makes an `export`ed (non-prop) variable available on the `$$props` object + * so that consumers can do `bind:x` on the component. + * @template V + * @param {Record} props + * @param {string} prop + * @param {V} value + * @returns {void} + */ +export function bind_prop(props, prop, value) { + var desc = get_descriptor(props, prop); + + if (desc && desc.set) { + props[prop] = value; + render_effect(() => () => { + props[prop] = null; + }); + } +} diff --git a/packages/svelte/src/internal/client/dom/bindings/select.js b/packages/svelte/src/internal/client/dom/bindings/select.js new file mode 100644 index 0000000000..32b01c0472 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/bindings/select.js @@ -0,0 +1,116 @@ +import { effect } from '../../reactivity/effects.js'; + +/** + * Selects the correct option(s) (depending on whether this is a multiple select) + * @template V + * @param {HTMLSelectElement} select + * @param {V} value + * @param {boolean} [mounting] + */ +export function select_option(select, value, mounting) { + if (select.multiple) { + return select_options(select, value); + } + + for (var option of select.options) { + var option_value = get_option_value(option); + if (option_value === value) { + option.selected = true; + return; + } + } + + if (!mounting || value !== undefined) { + select.selectedIndex = -1; // no option should be selected + } +} + +/** + * Finds the containing ` yet when this is called + effect(() => { + var select = option.parentNode; + + while (select != null) { + if (select.nodeName === 'SELECT') break; + select = select.parentNode; + } + + // @ts-ignore + if (select != null && option.__value === select.__value) { + // never set to false, since this causes browser to select default option + option.selected = true; + } + }); +} + +/** + * @param {HTMLSelectElement} select + * @param {() => unknown} get_value + * @param {(value: unknown) => void} update + * @returns {void} + */ +export function bind_select_value(select, get_value, update) { + var mounting = true; + + select.addEventListener('change', () => { + /** @type {unknown} */ + var value; + + if (select.multiple) { + value = [].map.call(select.querySelectorAll(':checked'), get_option_value); + } else { + /** @type {HTMLOptionElement | null} */ + var selected_option = select.querySelector(':checked'); + value = selected_option && get_option_value(selected_option); + } + + update(value); + }); + + // Needs to be an effect, not a render_effect, so that in case of each loops the logic runs after the each block has updated + effect(() => { + var value = get_value(); + select_option(select, value, mounting); + + if (mounting && value === undefined) { + /** @type {HTMLOptionElement | null} */ + var selected_option = select.querySelector(':checked'); + if (selected_option !== null) { + value = get_option_value(selected_option); + update(value); + } + } + + // @ts-ignore + select.__value = value; + mounting = false; + }); +} + +/** + * @template V + * @param {HTMLSelectElement} select + * @param {V} value + */ +function select_options(select, value) { + for (var option of select.options) { + // @ts-ignore + option.selected = ~value.indexOf(get_option_value(option)); + } +} + +/** @param {HTMLOptionElement} option */ +function get_option_value(option) { + // __value only exists if the