Skip to content

Commit 0adc09d

Browse files
feat: add support for resize observer bindings (#8022)
Implements ResizeObserver bindings: #5524 (comment) Continuation of: #5963 Related to #7583 --------- Co-authored-by: Simon H <[email protected]>
1 parent 3a7685f commit 0adc09d

File tree

8 files changed

+127
-9
lines changed

8 files changed

+127
-9
lines changed

elements/index.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,11 @@ export interface HTMLAttributes<T extends EventTarget> extends AriaAttributes, D
546546
*/
547547
'bind:innerText'?: string | undefined | null;
548548

549+
readonly 'bind:contentRect'?: DOMRectReadOnly | undefined | null;
550+
readonly 'bind:contentBoxSize'?: Array<{ blockSize: number; inlineSize: number }> | undefined | null; // TODO make this ResizeObserverSize once we require TS>=4.4
551+
readonly 'bind:borderBoxSize'?: Array<{ blockSize: number; inlineSize: number }> | undefined | null; // TODO make this ResizeObserverSize once we require TS>=4.4
552+
readonly 'bind:devicePixelContentBoxSize'?: Array<{ blockSize: number; inlineSize: number }> | undefined | null; // TODO make this ResizeObserverSize once we require TS>=4.4
553+
549554
// SvelteKit
550555
'data-sveltekit-keepfocus'?: true | '' | 'off' | undefined | null;
551556
'data-sveltekit-noscroll'?: true | '' | 'off' | undefined | null;

src/compiler/compile/nodes/Binding.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import get_object from '../utils/get_object';
33
import Expression from './shared/Expression';
44
import Component from '../Component';
55
import TemplateScope from './shared/TemplateScope';
6-
import { regex_dimensions } from '../../utils/patterns';
6+
import { regex_dimensions, regex_box_size } from '../../utils/patterns';
77
import { Node as ESTreeNode } from 'estree';
88
import { TemplateNode } from '../../interfaces';
99
import Element from './Element';
@@ -92,6 +92,7 @@ export default class Binding extends Node {
9292

9393
this.is_readonly =
9494
regex_dimensions.test(this.name) ||
95+
regex_box_size.test(this.name) ||
9596
(isElement(parent) &&
9697
((parent.is_media_node() && read_only_media_attributes.has(this.name)) ||
9798
(parent.name === 'input' && type === 'file')) /* TODO others? */);

src/compiler/compile/nodes/Element.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Text from './Text';
1212
import { namespaces } from '../../utils/namespaces';
1313
import map_children from './shared/map_children';
1414
import { is_name_contenteditable, get_contenteditable_attr } from '../utils/contenteditable';
15-
import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character } from '../../utils/patterns';
15+
import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character, regex_box_size } from '../../utils/patterns';
1616
import fuzzymatch from '../../utils/fuzzymatch';
1717
import list from '../../utils/list';
1818
import Let from './Let';
@@ -1090,7 +1090,10 @@ export default class Element extends Node {
10901090
} else if (contenteditable && !contenteditable.is_static) {
10911091
return component.error(contenteditable, compiler_errors.dynamic_contenteditable_attribute);
10921092
}
1093-
} else if (name !== 'this') {
1093+
} else if (
1094+
name !== 'this' &&
1095+
!regex_box_size.test(name)
1096+
) {
10941097
return component.error(binding, compiler_errors.invalid_binding(binding.name));
10951098
}
10961099
});

src/compiler/compile/render_dom/wrappers/Element/Binding.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Node, Identifier } from 'estree';
1111
import add_to_set from '../../../utils/add_to_set';
1212
import mark_each_block_bindings from '../shared/mark_each_block_bindings';
1313
import handle_select_value_binding from './handle_select_value_binding';
14+
import { regex_box_size } from '../../../../utils/patterns';
1415

1516
export default class BindingWrapper {
1617
node: Binding;
@@ -455,7 +456,12 @@ function get_value_from_dom(
455456
return x`$$value`;
456457
}
457458

458-
// <select bind:value='selected'>
459+
// <div bind:contentRect|contentBoxSize|borderBoxSize|devicePixelContentBoxSize>
460+
if (regex_box_size.test(name)) {
461+
return x`@ResizeObserverSingleton.entries.get(this)?.${name}`;
462+
}
463+
464+
// <select bind:value='selected>
459465
if (node.name === 'select') {
460466
return node.get_static_attribute_value('multiple') === true ?
461467
x`@select_multiple_value(this)` :

src/compiler/compile/render_dom/wrappers/Element/index.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { namespaces } from '../../../../utils/namespaces';
1212
import AttributeWrapper from './Attribute';
1313
import StyleAttributeWrapper from './StyleAttribute';
1414
import SpreadAttributeWrapper from './SpreadAttribute';
15-
import { regex_dimensions, regex_starts_with_newline, regex_backslashes } from '../../../../utils/patterns';
15+
import { regex_dimensions, regex_starts_with_newline, regex_backslashes, regex_border_box_size, regex_content_box_size, regex_device_pixel_content_box_size, regex_content_rect } from '../../../../utils/patterns';
1616
import Binding from './Binding';
1717
import add_to_set from '../../../utils/add_to_set';
1818
import { add_event_handler } from '../shared/add_event_handlers';
@@ -64,11 +64,29 @@ const events = [
6464
filter: (node: Element, _name: string) =>
6565
node.name === 'input' && node.get_static_attribute_value('type') === 'range'
6666
},
67+
// resize events
6768
{
6869
event_names: ['elementresize'],
6970
filter: (_node: Element, name: string) =>
7071
regex_dimensions.test(name)
7172
},
73+
{
74+
event_names: ['elementresizecontentbox'],
75+
filter: (_node: Element, name: string) =>
76+
regex_content_rect.test(name) ?? regex_content_box_size.test(name)
77+
},
78+
79+
{
80+
event_names: ['elementresizeborderbox'],
81+
filter: (_node: Element, name: string) =>
82+
regex_border_box_size.test(name)
83+
},
84+
85+
{
86+
event_names: ['elementresizedevicepixelcontentbox'],
87+
filter: (_node: Element, name: string) =>
88+
regex_device_pixel_content_box_size.test(name)
89+
},
7290
// media events
7391
{
7492
event_names: ['timeupdate'],
@@ -747,13 +765,19 @@ export default class ElementWrapper extends Wrapper {
747765
`);
748766

749767
binding_group.events.forEach(name => {
750-
if (name === 'elementresize') {
751-
// special case
768+
const resizeListenerFunctions = {
769+
elementresize: 'add_iframe_resize_listener',
770+
elementresizecontentbox: 'resize_observer_content_box.observe',
771+
elementresizeborderbox: 'resize_observer_border_box.observe',
772+
elementresizedevicepixelcontentbox: 'resize_observer_device_pixel_content_box.observe'
773+
};
774+
775+
if (name in resizeListenerFunctions) {
752776
const resize_listener = block.get_unique_name(`${this.var.name}_resize_listener`);
753777
block.add_variable(resize_listener);
754778

755779
block.chunks.mount.push(
756-
b`${resize_listener} = @add_resize_listener(${this.var}, ${callee}.bind(${this.var}));`
780+
b`${resize_listener} = @${resizeListenerFunctions[name]}(${this.var}, ${callee}.bind(${this.var}));`
757781
);
758782

759783
block.chunks.destroy.push(

src/compiler/utils/patterns.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,9 @@ export const regex_ends_with_underscore = /_$/;
2222
export const regex_invalid_variable_identifier_characters = /[^a-zA-Z0-9_$]/g;
2323

2424
export const regex_dimensions = /^(?:offset|client)(?:Width|Height)$/;
25+
26+
export const regex_content_rect = /^(?:contentRect)$/;
27+
export const regex_content_box_size = /^(?:contentBoxSize)$/;
28+
export const regex_border_box_size = /^(?:borderBoxSize)$/;
29+
export const regex_device_pixel_content_box_size = /^(?:devicePixelContentBoxSize)$/;
30+
export const regex_box_size = /^(?:contentRect|contentBoxSize|borderBoxSize|devicePixelContentBoxSize)$/;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Resize observer singleton.
3+
* One listener per element only!
4+
* https://groups.google.com/a/chromium.org/g/blink-dev/c/z6ienONUb5A/m/F5-VcUZtBAAJ
5+
*/
6+
export class ResizeObserverSingleton {
7+
constructor(readonly options?: ResizeObserverOptions) {}
8+
9+
observe(element: Element, listener: Listener) {
10+
this._listeners.set(element, listener);
11+
this._getObserver().observe(element, this.options);
12+
return () => {
13+
this._listeners.delete(element);
14+
this._observer.unobserve(element); // this line can probably be removed
15+
};
16+
}
17+
18+
static readonly entries: WeakMap<Element, ResizeObserverEntry> = 'WeakMap' in globalThis ? new WeakMap() : undefined;
19+
20+
private readonly _listeners: WeakMap<Element, Listener> = 'WeakMap' in globalThis ? new WeakMap() : undefined;
21+
private _observer?: ResizeObserver;
22+
private _getObserver() {
23+
return this._observer ?? (this._observer = new ResizeObserver((entries) => {
24+
for (const entry of entries) {
25+
ResizeObserverSingleton.entries.set(entry.target, entry);
26+
this._listeners.get(entry.target)?.(entry);
27+
}
28+
}));
29+
}
30+
}
31+
32+
type Listener = (entry: ResizeObserverEntry)=>any;
33+
34+
// TODO: Remove this
35+
interface ResizeObserverSize {
36+
readonly blockSize: number;
37+
readonly inlineSize: number;
38+
}
39+
40+
interface ResizeObserverEntry {
41+
readonly borderBoxSize: readonly ResizeObserverSize[];
42+
readonly contentBoxSize: readonly ResizeObserverSize[];
43+
readonly contentRect: DOMRectReadOnly;
44+
readonly devicePixelContentBoxSize: readonly ResizeObserverSize[];
45+
readonly target: Element;
46+
}
47+
48+
type ResizeObserverBoxOptions = 'border-box' | 'content-box' | 'device-pixel-content-box';
49+
50+
interface ResizeObserverOptions {
51+
box?: ResizeObserverBoxOptions;
52+
}
53+
54+
interface ResizeObserver {
55+
disconnect(): void;
56+
observe(target: Element, options?: ResizeObserverOptions): void;
57+
unobserve(target: Element): void;
58+
}
59+
60+
interface ResizeObserverCallback {
61+
(entries: ResizeObserverEntry[], observer: ResizeObserver): void;
62+
}
63+
64+
declare let ResizeObserver: {
65+
prototype: ResizeObserver;
66+
new(callback: ResizeObserverCallback): ResizeObserver;
67+
};

src/runtime/internal/dom.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ResizeObserverSingleton } from './ResizeObserverSingleton';
12
import { contenteditable_truthy_values, has_prop } from './utils';
23

34
// Track which nodes are claimed during hydration. Unclaimed nodes can then be removed from the DOM
@@ -698,7 +699,7 @@ export function is_crossorigin() {
698699
return crossorigin;
699700
}
700701

701-
export function add_resize_listener(node: HTMLElement, fn: () => void) {
702+
export function add_iframe_resize_listener(node: HTMLElement, fn: () => void) {
702703
const computed_style = getComputedStyle(node);
703704

704705
if (computed_style.position === 'static') {
@@ -746,6 +747,11 @@ export function add_resize_listener(node: HTMLElement, fn: () => void) {
746747
};
747748
}
748749

750+
export const resize_observer_content_box = new ResizeObserverSingleton({ box: 'content-box' });
751+
export const resize_observer_border_box = new ResizeObserverSingleton({ box: 'border-box' });
752+
export const resize_observer_device_pixel_content_box = new ResizeObserverSingleton({ box: 'device-pixel-content-box' });
753+
export { ResizeObserverSingleton };
754+
749755
export function toggle_class(element, name, toggle) {
750756
element.classList[toggle ? 'add' : 'remove'](name);
751757
}

0 commit comments

Comments
 (0)