@rdlabo/ionic-theme-ios27 1.1.0 → 1.2.0-0
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.
- package/README.md +8 -1
- package/dist/css/components/ion-list.css +1 -1
- package/dist/css/components/ion-menu.css +1 -1
- package/dist/css/ionic-theme-ios27.css +1 -1
- package/dist/css/vertical-bars.css +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -8
- package/dist/native/components/index.d.ts +1 -0
- package/dist/native/components/index.d.ts.map +1 -1
- package/dist/native/components/index.js +8 -3
- package/dist/native/components/ion-back-button.d.ts.map +1 -1
- package/dist/native/components/ion-back-button.js +5 -2
- package/dist/native/components/ion-button.d.ts.map +1 -1
- package/dist/native/components/ion-button.js +10 -2
- package/dist/native/components/ion-buttons.d.ts.map +1 -1
- package/dist/native/components/ion-buttons.js +12 -4
- package/dist/native/components/ion-fab.js +4 -4
- package/dist/native/components/ion-menu-button.d.ts.map +1 -1
- package/dist/native/components/ion-menu-button.js +5 -4
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +4 -1
- package/dist/native/definitions.d.ts +29 -3
- package/dist/native/definitions.d.ts.map +1 -1
- package/dist/native/definitions.js +6 -1
- package/dist/native/index.d.ts +6 -2
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +154 -17
- package/dist/native/prehide.d.ts +5 -0
- package/dist/native/prehide.d.ts.map +1 -0
- package/dist/native/prehide.js +246 -0
- package/dist/native/runtime.d.ts +1 -1
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +162 -28
- package/dist/native/shared/candidate.d.ts.map +1 -1
- package/dist/native/shared/candidate.js +7 -3
- package/dist/native/shared/dom.d.ts +33 -2
- package/dist/native/shared/dom.d.ts.map +1 -1
- package/dist/native/shared/dom.js +171 -10
- package/dist/native/vertical-bars-web.d.ts +3 -0
- package/dist/native/vertical-bars-web.d.ts.map +1 -0
- package/dist/native/vertical-bars-web.js +341 -0
- package/dist/native-integration/index.d.ts +3 -2
- package/dist/native-integration/index.d.ts.map +1 -1
- package/dist/native-integration/index.js +11 -5
- package/dist/transition/index.d.ts +2 -2
- package/dist/transition/index.d.ts.map +1 -1
- package/dist/transition/index.js +1 -1
- package/dist/transition/ios.transition.d.ts.map +1 -1
- package/dist/transition/ios.transition.js +1 -0
- package/dist/utils.d.ts +3 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/vertical-bars.d.ts +4 -0
- package/dist/vertical-bars.d.ts.map +1 -0
- package/dist/vertical-bars.js +2 -0
- package/docs/iphone-duo.md +255 -0
- package/docs/migration.md +2 -2
- package/docs/native-ui-shell.md +15 -3
- package/docs/special-markup.md +6 -0
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +15 -1
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +358 -0
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +215 -35
- package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +10 -1
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +29 -0
- package/package.json +6 -2
- package/src/index.ts +15 -8
- package/src/native/components/index.ts +12 -3
- package/src/native/components/ion-back-button.ts +8 -2
- package/src/native/components/ion-button.ts +12 -2
- package/src/native/components/ion-buttons.ts +9 -4
- package/src/native/components/ion-fab.ts +4 -4
- package/src/native/components/ion-menu-button.ts +5 -4
- package/src/native/components/searchable-tabs.ts +9 -1
- package/src/native/definitions.ts +40 -3
- package/src/native/index.ts +188 -20
- package/src/native/prehide.ts +255 -0
- package/src/native/runtime.ts +173 -27
- package/src/native/shared/candidate.ts +11 -3
- package/src/native/shared/dom.ts +212 -11
- package/src/native/vertical-bars-web.ts +374 -0
- package/src/native-integration/index.ts +13 -8
- package/src/styles/components/ion-list.scss +2 -2
- package/src/styles/components/ion-menu.scss +2 -2
- package/src/styles/ionic-theme-ios27.scss +1 -0
- package/src/styles/vertical-bars.scss +342 -0
- package/src/transition/index.ts +11 -6
- package/src/transition/ios.transition.ts +1 -0
- package/src/utils.ts +8 -8
- package/src/vertical-bars.ts +3 -0
- package/dist/native/lifecycle.d.ts +0 -4
- package/dist/native/lifecycle.d.ts.map +0 -1
- package/dist/native/lifecycle.js +0 -34
- package/src/native/lifecycle.ts +0 -38
package/src/native/shared/dom.ts
CHANGED
|
@@ -3,15 +3,202 @@ import { fadeMarker } from './crossfade';
|
|
|
3
3
|
import type { Frame } from '../definitions';
|
|
4
4
|
|
|
5
5
|
export const marker = 'data-native-ui-shell';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
// Scoped runtimes (Stencil slot emulation) patch child accessors on upgraded
|
|
7
|
+
// elements and may report none before hydration; the prototype getter still
|
|
8
|
+
// reports the real light DOM.
|
|
9
|
+
const childNodesGet = typeof Node === 'undefined' ? undefined : Object.getOwnPropertyDescriptor(Node.prototype, 'childNodes')?.get;
|
|
10
|
+
export const childNodesOf = (element: Element): Node[] =>
|
|
11
|
+
childNodesGet ? Array.from(childNodesGet.call(element)) : Array.from(element.childNodes);
|
|
12
|
+
export const childElements = (element: Element): HTMLElement[] =>
|
|
13
|
+
childNodesOf(element).filter((node) => node.nodeType === 1) as HTMLElement[];
|
|
14
|
+
export const prehiddenClass = 'ios-theme-native-ui-shell-prehidden';
|
|
15
|
+
export const prehideRootClass = 'ios-theme-native-ui-shell-prehide';
|
|
16
|
+
export const rejectedClass = 'ios-theme-native-ui-shell-rejected';
|
|
17
|
+
export const verticalBarsBackWebClass = 'ios-theme-vertical-bars-back-web-owned';
|
|
18
|
+
const prehideClasses = new Set([prehiddenClass, prehideRootClass]);
|
|
19
|
+
export const prehideOnlyMutation = (record: MutationRecord): boolean => {
|
|
20
|
+
if (record.attributeName !== 'class' || record.oldValue === null) return false;
|
|
21
|
+
const withoutPrehide = (value: string) =>
|
|
22
|
+
value
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.filter((name) => name && !prehideClasses.has(name))
|
|
25
|
+
.join(' ');
|
|
26
|
+
return withoutPrehide(record.oldValue) === withoutPrehide((record.target as Element).getAttribute('class') ?? '');
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const withoutPrehide = <T>(element: HTMLElement, read: () => T): T => {
|
|
30
|
+
const changed: HTMLElement[] = [];
|
|
31
|
+
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
|
|
32
|
+
if (current.classList.contains(prehiddenClass)) {
|
|
33
|
+
current.classList.remove(prehiddenClass);
|
|
34
|
+
changed.push(current);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const root = element.ownerDocument.documentElement;
|
|
38
|
+
if (element.matches('ion-back-button') && root.classList.contains(prehideRootClass)) {
|
|
39
|
+
root.classList.remove(prehideRootClass);
|
|
40
|
+
changed.push(root);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return read();
|
|
44
|
+
} finally {
|
|
45
|
+
changed.forEach((current) => current.classList.add(current === root ? prehideRootClass : prehiddenClass));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
export const isDark = (style: CSSStyleDeclaration): boolean => {
|
|
49
|
+
const themeScheme = style.getPropertyValue('--ios27-color-scheme').trim();
|
|
50
|
+
if (themeScheme) return themeScheme === 'dark';
|
|
51
|
+
const background = style.getPropertyValue('--ion-background-color-rgb').match(/\d+/g)?.slice(0, 3).map(Number);
|
|
52
|
+
return !!background && background.length === 3 && background[0] * 0.2126 + background[1] * 0.7152 + background[2] * 0.0722 < 128;
|
|
53
|
+
};
|
|
54
|
+
const permanentlyExcluded = '.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled, .ion-cloned-element, [hidden], [inert]';
|
|
55
|
+
export const excluded = `${permanentlyExcluded}, .ion-page-hidden, .ion-page-invisible`;
|
|
56
|
+
const enteringPages = new WeakSet<HTMLElement>();
|
|
57
|
+
export const setVerticalBarsEnteringPage = (page: HTMLElement, entering: boolean): void => {
|
|
58
|
+
if (entering) enteringPages.add(page);
|
|
59
|
+
else enteringPages.delete(page);
|
|
60
|
+
};
|
|
61
|
+
export const verticalBarsEnteringPage = (element: HTMLElement): HTMLElement | undefined => {
|
|
62
|
+
const page = element.closest<HTMLElement>('.ion-page-invisible');
|
|
63
|
+
return page && enteringPages.has(page) && page.closest('ion-app.ios-theme-vertical-bars') ? page : undefined;
|
|
64
|
+
};
|
|
65
|
+
export const createVerticalBarsPageState = () => {
|
|
66
|
+
const departed = new WeakSet<HTMLElement>();
|
|
67
|
+
return {
|
|
68
|
+
isDeparted(element: HTMLElement): boolean {
|
|
69
|
+
const page = element.closest<HTMLElement>('.ion-page');
|
|
70
|
+
return !!page && departed.has(page) && !!element.closest('ion-app.ios-theme-vertical-bars');
|
|
71
|
+
},
|
|
72
|
+
lifecycle(event: Event): void {
|
|
73
|
+
// Avoid instanceof so stale listeners stay safe after global teardown.
|
|
74
|
+
const page = event.target && (event.target as Node).nodeType === 1 ? (event.target as HTMLElement) : undefined;
|
|
75
|
+
if (!page?.matches('.ion-page')) return;
|
|
76
|
+
const entering = event.type === 'ionViewWillEnter';
|
|
77
|
+
if (entering || event.type === 'ionViewDidEnter') departed.delete(page);
|
|
78
|
+
else if (event.type === 'ionViewWillLeave' || event.type === 'ionViewDidLeave') departed.add(page);
|
|
79
|
+
setVerticalBarsEnteringPage(page, entering);
|
|
80
|
+
},
|
|
81
|
+
cancel(entering?: HTMLElement, leaving?: HTMLElement): void {
|
|
82
|
+
if (entering) {
|
|
83
|
+
departed.add(entering);
|
|
84
|
+
setVerticalBarsEnteringPage(entering, false);
|
|
85
|
+
}
|
|
86
|
+
if (leaving) departed.delete(leaving);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
const disabledButtonGroup = 'ion-buttons:is(.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled)';
|
|
9
91
|
const shellDisabledSelector = '.ios-theme-shell-disabled';
|
|
10
92
|
|
|
93
|
+
export const isDisabledButtonGroupChild = (element: HTMLElement): boolean =>
|
|
94
|
+
element.matches('ion-button') && element.parentElement?.matches(disabledButtonGroup) === true;
|
|
95
|
+
|
|
96
|
+
const verticalBarsTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons', 'ion-menu-button', 'ion-tab-bar']);
|
|
97
|
+
|
|
98
|
+
export const isVerticalBarsSource = (element: HTMLElement): boolean =>
|
|
99
|
+
verticalBarsTags.has(element.localName) &&
|
|
100
|
+
(element.matches('ion-tab-bar') || verticalBarsOwned(element)) &&
|
|
101
|
+
!element.closest('ion-menu, ion-modal, ion-popover') &&
|
|
102
|
+
!!element.closest('ion-app.ios-theme-vertical-bars');
|
|
103
|
+
|
|
104
|
+
export const isVerticalBarsBackPosition = (element: HTMLElement): boolean => {
|
|
105
|
+
if (element.closest('ion-header[collapse], ion-footer[collapse]')) return false;
|
|
106
|
+
const page = element.closest('.ion-page');
|
|
107
|
+
if (!page) return true;
|
|
108
|
+
const candidates = Array.from(page.querySelectorAll<HTMLElement>('ion-back-button')).filter(
|
|
109
|
+
(back) =>
|
|
110
|
+
back.closest('.ion-page') === page &&
|
|
111
|
+
!back.matches('.ion-cloned-element') &&
|
|
112
|
+
!back.closest('ion-header[collapse], ion-footer[collapse], ion-menu, ion-modal, ion-popover'),
|
|
113
|
+
);
|
|
114
|
+
const rank = (back: HTMLElement) =>
|
|
115
|
+
inFixedToolbar(back) ? 0 : back.closest('ion-header ion-toolbar, ion-footer ion-toolbar') ? 1 : back.closest('ion-toolbar') ? 3 : 2;
|
|
116
|
+
const best = candidates.reduce<HTMLElement | undefined>(
|
|
117
|
+
(winner, back) => (!winner || rank(back) < rank(winner) ? back : winner),
|
|
118
|
+
undefined,
|
|
119
|
+
);
|
|
120
|
+
return best === element;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const preferredVerticalBarsBack = <T extends HTMLElement>(elements: T[], doc: Document): T | undefined => {
|
|
124
|
+
const pages = Array.from(doc.querySelectorAll('.ion-page'));
|
|
125
|
+
const pageOrder = (element: Element) => pages.indexOf(element.closest('.ion-page')!);
|
|
126
|
+
return elements.sort((a, b) => pageOrder(b) - pageOrder(a) || Number(!!b.closest('ion-header')) - Number(!!a.closest('ion-header')))[0];
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const excludedBy = (element: HTMLElement, selector: string): boolean => {
|
|
130
|
+
const owner = element.closest<HTMLElement>(selector);
|
|
131
|
+
return !!owner && !(element.parentElement === owner && isDisabledButtonGroupChild(element));
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const isPermanentlyExcluded = (element: HTMLElement): boolean => excludedBy(element, permanentlyExcluded);
|
|
135
|
+
export const isExcluded = (element: HTMLElement, enteringPage?: HTMLElement): boolean =>
|
|
136
|
+
excludedBy(element, `${permanentlyExcluded}, .ion-page-hidden`) || (!!element.closest('.ion-page-invisible') && !enteringPage);
|
|
137
|
+
|
|
11
138
|
// A shared native surface must not cover an opted-out descendant either.
|
|
12
139
|
export const isShellDisabled = (element: Element): boolean =>
|
|
13
140
|
!!element.closest(shellDisabledSelector) || !!element.querySelector(shellDisabledSelector);
|
|
14
141
|
|
|
142
|
+
export const isVerticalBarsToolbarActionShape = (element: HTMLElement): boolean =>
|
|
143
|
+
element.matches('ion-menu-button') ||
|
|
144
|
+
(element.matches('ion-button') &&
|
|
145
|
+
!!element.querySelector('ion-icon, svg') &&
|
|
146
|
+
!childNodesOf(element).some((node) => node.nodeType === 3 && !!node.textContent?.trim()) &&
|
|
147
|
+
!element.matches('.ion-color, [color]') &&
|
|
148
|
+
['default', 'clear'].includes((element as HTMLIonButtonElement).fill ?? element.getAttribute('fill') ?? 'default'));
|
|
149
|
+
|
|
150
|
+
// Placement belongs to the DOM identity for one routed-page epoch. Changes to
|
|
151
|
+
// content/disabled state affect rendering, never its chosen surface.
|
|
152
|
+
const verticalBarsPlacement = new WeakMap<HTMLElement, boolean>();
|
|
153
|
+
export const setVerticalBarsPlacement = (element: HTMLElement, rail: boolean): void => {
|
|
154
|
+
verticalBarsPlacement.set(element, rail);
|
|
155
|
+
};
|
|
156
|
+
export const clearVerticalBarsPlacement = (element: HTMLElement): void => {
|
|
157
|
+
verticalBarsPlacement.delete(element);
|
|
158
|
+
};
|
|
159
|
+
export const verticalBarsOwned = (element: HTMLElement): boolean => verticalBarsPlacement.get(element) === true;
|
|
160
|
+
|
|
161
|
+
export const isVerticalBarsToolbarAction = (element: HTMLElement): boolean =>
|
|
162
|
+
verticalBarsOwned(element) && !isExcluded(element, verticalBarsEnteringPage(element)) && !isShellDisabled(element);
|
|
163
|
+
|
|
164
|
+
const overlays = 'ion-menu, ion-modal, ion-popover';
|
|
165
|
+
|
|
166
|
+
/** Shared eligibility for a toolbar action candidate, before placement is decided (prehide) or consumed (projection). */
|
|
167
|
+
export const verticalBarsActionCandidate = (element: HTMLElement): boolean =>
|
|
168
|
+
inFixedToolbar(element) &&
|
|
169
|
+
!element.closest('ion-buttons.ios-theme-horizontal-only, ion-button.ios-theme-horizontal-only') &&
|
|
170
|
+
!isPermanentlyExcluded(element) &&
|
|
171
|
+
!isShellDisabled(element) &&
|
|
172
|
+
!element.closest(overlays);
|
|
173
|
+
|
|
174
|
+
/** Shared eligibility for a back-button candidate; the back button may sit outside a fixed toolbar. */
|
|
175
|
+
export const verticalBarsBackCandidate = (element: HTMLElement): boolean =>
|
|
176
|
+
isVerticalBarsBackPosition(element) &&
|
|
177
|
+
!element.closest('ion-buttons.ios-theme-horizontal-only') &&
|
|
178
|
+
!isPermanentlyExcluded(element) &&
|
|
179
|
+
!isShellDisabled(element) &&
|
|
180
|
+
!element.closest(overlays);
|
|
181
|
+
|
|
182
|
+
export const verticalBarsToolbarActions = (element: HTMLElement): HTMLElement[] =>
|
|
183
|
+
// Back buttons use the dedicated rail slot; they are never toolbar actions.
|
|
184
|
+
childElements(element).filter((child) => !child.matches('ion-back-button') && isVerticalBarsToolbarAction(child));
|
|
185
|
+
|
|
186
|
+
export const isVerticalBarsToolbarGroup = (element: HTMLElement): boolean => {
|
|
187
|
+
return (
|
|
188
|
+
element.matches('ion-buttons') &&
|
|
189
|
+
verticalBarsOwned(element) &&
|
|
190
|
+
!element.matches('.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled') &&
|
|
191
|
+
!isShellDisabled(element)
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const activateProjectedElement = (element: HTMLElement): void => {
|
|
196
|
+
const target = element.matches('ion-button, ion-back-button, ion-menu-button')
|
|
197
|
+
? element.shadowRoot?.querySelector<HTMLElement>('[part~="native"]')
|
|
198
|
+
: undefined;
|
|
199
|
+
(target ?? element).click();
|
|
200
|
+
};
|
|
201
|
+
|
|
15
202
|
export const unprojected = <T>(elements: Iterable<HTMLElement>, read: () => T): T => {
|
|
16
203
|
const hidden = Array.from(elements).filter((element) => element.hasAttribute(marker));
|
|
17
204
|
hidden.forEach((element) => element.removeAttribute(marker));
|
|
@@ -22,14 +209,22 @@ export const unprojected = <T>(elements: Iterable<HTMLElement>, read: () => T):
|
|
|
22
209
|
}
|
|
23
210
|
};
|
|
24
211
|
|
|
25
|
-
|
|
26
|
-
|
|
212
|
+
const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boolean => {
|
|
213
|
+
const enteringPage = isVerticalBarsSource(element) ? verticalBarsEnteringPage(element) : undefined;
|
|
214
|
+
if (!element.isConnected || isExcluded(element, enteringPage) || isShellDisabled(element)) return false;
|
|
27
215
|
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
|
|
28
216
|
const style = getComputedStyle(current);
|
|
29
|
-
if (
|
|
217
|
+
if (
|
|
218
|
+
style.display === 'none' ||
|
|
219
|
+
style.visibility !== 'visible' ||
|
|
220
|
+
(Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker) && current !== enteringPage)
|
|
221
|
+
)
|
|
30
222
|
return false;
|
|
31
|
-
//
|
|
223
|
+
// Ordinary controls on moving/collapsing/custom transformed surfaces stay in Web coordinates.
|
|
224
|
+
// VerticalBars rail controls are placed independently of their Web coordinates and must remain
|
|
225
|
+
// owned while Ionic transforms the content behind an open menu.
|
|
32
226
|
if (
|
|
227
|
+
!allowOutsideViewport &&
|
|
33
228
|
style.transform !== 'none' &&
|
|
34
229
|
!new DOMMatrixReadOnly(style.transform).isIdentity &&
|
|
35
230
|
current !== element &&
|
|
@@ -39,10 +234,15 @@ export const visible = (element: HTMLElement): boolean => {
|
|
|
39
234
|
}
|
|
40
235
|
const rect = element.getBoundingClientRect();
|
|
41
236
|
return (
|
|
42
|
-
rect.width > 0 &&
|
|
237
|
+
rect.width > 0 &&
|
|
238
|
+
rect.height > 0 &&
|
|
239
|
+
(allowOutsideViewport || (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1))
|
|
43
240
|
);
|
|
44
241
|
};
|
|
45
242
|
|
|
243
|
+
export const visible = (element: HTMLElement, allowOutsideViewport = false): boolean =>
|
|
244
|
+
withoutPrehide(element, () => readVisible(element, allowOutsideViewport));
|
|
245
|
+
|
|
46
246
|
export const frame = (rect: DOMRect, origin?: DOMRect): Frame => ({
|
|
47
247
|
x: rect.x - (origin?.x ?? 0),
|
|
48
248
|
y: rect.y - (origin?.y ?? 0),
|
|
@@ -52,8 +252,8 @@ export const frame = (rect: DOMRect, origin?: DOMRect): Frame => ({
|
|
|
52
252
|
|
|
53
253
|
export const text = (element: Element): string => {
|
|
54
254
|
if (element.matches('ion-icon, svg, ion-badge, .ios27-segment-lens, .ion-cloned-element')) return '';
|
|
55
|
-
return
|
|
56
|
-
.map((node) => (node.nodeType ===
|
|
255
|
+
return childNodesOf(element)
|
|
256
|
+
.map((node) => (node.nodeType === 3 ? node.textContent : node.nodeType === 1 ? text(node as Element) : ''))
|
|
57
257
|
.join('')
|
|
58
258
|
.replace(/\s+/g, ' ')
|
|
59
259
|
.trim();
|
|
@@ -61,10 +261,11 @@ export const text = (element: Element): string => {
|
|
|
61
261
|
|
|
62
262
|
export const inFixedToolbar = (element: Element): boolean => {
|
|
63
263
|
const edge = element.closest('ion-toolbar')?.parentElement;
|
|
264
|
+
const verticalBars = !!element.closest('ion-app.ios-theme-vertical-bars');
|
|
64
265
|
return (
|
|
65
266
|
!!edge?.matches('ion-header, ion-footer') &&
|
|
66
267
|
!element.closest('ion-content') &&
|
|
67
268
|
!edge.hasAttribute('collapse') &&
|
|
68
|
-
!edge.matches('.header-collapse-main, .header-collapse-condense')
|
|
269
|
+
(verticalBars || !edge.matches('.header-collapse-main, .header-collapse-condense'))
|
|
69
270
|
);
|
|
70
271
|
};
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions';
|
|
2
|
+
import { VERTICAL_BARS_TRANSITION_CANCELED } from '../native-integration';
|
|
3
|
+
import {
|
|
4
|
+
activateProjectedElement,
|
|
5
|
+
createVerticalBarsPageState,
|
|
6
|
+
verticalBarsActionCandidate,
|
|
7
|
+
verticalBarsBackCandidate,
|
|
8
|
+
verticalBarsEnteringPage,
|
|
9
|
+
verticalBarsToolbarActions,
|
|
10
|
+
isExcluded,
|
|
11
|
+
isVerticalBarsToolbarGroup,
|
|
12
|
+
preferredVerticalBarsBack,
|
|
13
|
+
verticalBarsOwned,
|
|
14
|
+
marker,
|
|
15
|
+
prehideOnlyMutation,
|
|
16
|
+
prehiddenClass,
|
|
17
|
+
unprojected,
|
|
18
|
+
withoutPrehide,
|
|
19
|
+
} from './shared/dom';
|
|
20
|
+
|
|
21
|
+
const backProjectionClass = 'ios-theme-vertical-bars-back-button-projection';
|
|
22
|
+
const toolbarProjectionClass = 'ios-theme-vertical-bars-toolbar-projection';
|
|
23
|
+
const readyClass = 'ios-theme-vertical-bars-toolbar-ready';
|
|
24
|
+
const toolbarControlSize = 46;
|
|
25
|
+
const toolbarControlGap = 10;
|
|
26
|
+
|
|
27
|
+
interface ToolbarProjection {
|
|
28
|
+
source: HTMLIonButtonsElement;
|
|
29
|
+
projection: HTMLElement;
|
|
30
|
+
actions: { source: HTMLElement; projection: HTMLElement; previousAriaHidden: string | null }[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ToolbarSource {
|
|
34
|
+
group: HTMLIonButtonsElement;
|
|
35
|
+
actions: HTMLElement[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const createVerticalBarsWebProjection = (
|
|
39
|
+
doc: Document,
|
|
40
|
+
options: NativeUIShellOptions,
|
|
41
|
+
enabled: () => boolean = () => true,
|
|
42
|
+
): NativeUIShellHandle => {
|
|
43
|
+
const win = doc.defaultView!;
|
|
44
|
+
if (options.controls !== undefined && options.controls.toolbar !== true)
|
|
45
|
+
return {
|
|
46
|
+
getStatus: () => ({ state: 'web', projected: 0, updates: 0 }),
|
|
47
|
+
suspend: async () => ({ resume: async () => {} }),
|
|
48
|
+
destroy: async () => {},
|
|
49
|
+
};
|
|
50
|
+
let root: HTMLElement | undefined;
|
|
51
|
+
let backSource: HTMLIonBackButtonElement | undefined;
|
|
52
|
+
let backProjection: HTMLIonBackButtonElement | undefined;
|
|
53
|
+
let previousBackAriaHidden: string | null = null;
|
|
54
|
+
let toolbarProjections: ToolbarProjection[] = [];
|
|
55
|
+
let suspended = 0;
|
|
56
|
+
let stopped = false;
|
|
57
|
+
let frame = 0;
|
|
58
|
+
let updates = 0;
|
|
59
|
+
let observingVerticalBars = false;
|
|
60
|
+
const verticalBarsPages = createVerticalBarsPageState();
|
|
61
|
+
let sourceObserver: MutationObserver | undefined;
|
|
62
|
+
let waiters: (() => void)[] = [];
|
|
63
|
+
const listeners = new AbortController();
|
|
64
|
+
const verticalBarsRoot = () => doc.querySelector<HTMLElement>('ion-app.ios-theme-vertical-bars');
|
|
65
|
+
const projectedSources = () =>
|
|
66
|
+
[backSource, ...toolbarProjections.flatMap(({ actions }) => actions.map(({ source }) => source))].filter(
|
|
67
|
+
(source): source is HTMLElement => !!source,
|
|
68
|
+
);
|
|
69
|
+
const isRendered = (element: HTMLElement) =>
|
|
70
|
+
withoutPrehide(element, () => {
|
|
71
|
+
const style = getComputedStyle(element);
|
|
72
|
+
const rect = element.getBoundingClientRect();
|
|
73
|
+
return element.isConnected && style.display !== 'none' && style.visibility === 'visible' && rect.width > 0 && rect.height > 0;
|
|
74
|
+
});
|
|
75
|
+
const inEligibleToolbar = (element: HTMLElement) =>
|
|
76
|
+
!!verticalBarsRoot()?.contains(element) &&
|
|
77
|
+
verticalBarsActionCandidate(element) &&
|
|
78
|
+
!isExcluded(element, verticalBarsEnteringPage(element)) &&
|
|
79
|
+
!verticalBarsPages.isDeparted(element);
|
|
80
|
+
const isEligibleBack = (element: HTMLIonBackButtonElement) =>
|
|
81
|
+
!!verticalBarsRoot()?.contains(element) &&
|
|
82
|
+
verticalBarsOwned(element) &&
|
|
83
|
+
verticalBarsBackCandidate(element) &&
|
|
84
|
+
!isExcluded(element, verticalBarsEnteringPage(element)) &&
|
|
85
|
+
!verticalBarsPages.isDeparted(element) &&
|
|
86
|
+
unprojected(projectedSources(), () => isRendered(element));
|
|
87
|
+
const pageOrder = (element: Element) => Array.from(doc.querySelectorAll('.ion-page')).indexOf(element.closest('.ion-page')!);
|
|
88
|
+
const findBack = () => {
|
|
89
|
+
const candidates = Array.from(doc.querySelectorAll<HTMLIonBackButtonElement>(`ion-back-button:not(.${backProjectionClass})`)).filter(
|
|
90
|
+
isEligibleBack,
|
|
91
|
+
);
|
|
92
|
+
return preferredVerticalBarsBack(candidates, doc);
|
|
93
|
+
};
|
|
94
|
+
const findToolbarGroups = () => {
|
|
95
|
+
const candidates = Array.from(doc.querySelectorAll<HTMLIonButtonsElement>(`ion-buttons:not(.${toolbarProjectionClass})`)).flatMap(
|
|
96
|
+
(group): ToolbarSource[] => {
|
|
97
|
+
const actions = verticalBarsToolbarActions(group).filter((action) => unprojected(projectedSources(), () => isRendered(action)));
|
|
98
|
+
if (!actions.length) return [];
|
|
99
|
+
if (!isVerticalBarsToolbarGroup(group)) return actions.filter(inEligibleToolbar).map((action) => ({ group, actions: [action] }));
|
|
100
|
+
return inEligibleToolbar(group) && unprojected(projectedSources(), () => isRendered(group)) ? [{ group, actions }] : [];
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
const highestPage = Math.max(...candidates.map(({ group }) => pageOrder(group)));
|
|
104
|
+
return candidates.filter(({ group }) => pageOrder(group) === highestPage);
|
|
105
|
+
};
|
|
106
|
+
const isCurrentToolbarAction = (source: HTMLElement) => findToolbarGroups().some(({ actions }) => actions.includes(source));
|
|
107
|
+
const restore = () => {
|
|
108
|
+
backProjection?.remove();
|
|
109
|
+
backProjection = undefined;
|
|
110
|
+
if (backSource) {
|
|
111
|
+
backSource.removeAttribute(marker);
|
|
112
|
+
if (previousBackAriaHidden === null) backSource.removeAttribute('aria-hidden');
|
|
113
|
+
else backSource.setAttribute('aria-hidden', previousBackAriaHidden);
|
|
114
|
+
backSource.dispatchEvent(new CustomEvent('nativeUIShellChange'));
|
|
115
|
+
}
|
|
116
|
+
backSource = undefined;
|
|
117
|
+
previousBackAriaHidden = null;
|
|
118
|
+
for (const { projection, actions } of toolbarProjections) {
|
|
119
|
+
projection.remove();
|
|
120
|
+
for (const { source, previousAriaHidden } of actions) {
|
|
121
|
+
source.removeAttribute(marker);
|
|
122
|
+
if (previousAriaHidden === null) source.removeAttribute('aria-hidden');
|
|
123
|
+
else source.setAttribute('aria-hidden', previousAriaHidden);
|
|
124
|
+
source.dispatchEvent(new CustomEvent('nativeUIShellChange'));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
toolbarProjections = [];
|
|
128
|
+
sourceObserver?.disconnect();
|
|
129
|
+
sourceObserver = undefined;
|
|
130
|
+
root?.classList.remove(readyClass);
|
|
131
|
+
root = undefined;
|
|
132
|
+
};
|
|
133
|
+
const copyAttributes = (target: HTMLElement, original: HTMLElement) => {
|
|
134
|
+
for (const name of ['id', 'slot', marker, 'aria-hidden']) target.removeAttribute(name);
|
|
135
|
+
for (const attribute of Array.from(target.attributes))
|
|
136
|
+
if (!['class', marker].includes(attribute.name) && !original.hasAttribute(attribute.name)) target.removeAttribute(attribute.name);
|
|
137
|
+
for (const attribute of Array.from(original.attributes))
|
|
138
|
+
if (!['id', 'slot', marker, 'aria-hidden'].includes(attribute.name)) target.setAttribute(attribute.name, attribute.value);
|
|
139
|
+
target.classList.remove(prehiddenClass);
|
|
140
|
+
};
|
|
141
|
+
const syncBack = (target: HTMLIonBackButtonElement, original: HTMLIonBackButtonElement) => {
|
|
142
|
+
copyAttributes(target, original);
|
|
143
|
+
target.classList.add(backProjectionClass, 'ion-cloned-element');
|
|
144
|
+
target.disabled = original.disabled;
|
|
145
|
+
target.defaultHref = original.defaultHref;
|
|
146
|
+
target.icon = original.icon;
|
|
147
|
+
target.color = original.color;
|
|
148
|
+
target.mode = original.mode;
|
|
149
|
+
target.text = '';
|
|
150
|
+
};
|
|
151
|
+
const syncAction = (target: HTMLElement, original: HTMLElement) => {
|
|
152
|
+
copyAttributes(target, original);
|
|
153
|
+
target.classList.add('ios-theme-vertical-bars-toolbar-action', 'ion-cloned-element');
|
|
154
|
+
const label = original.getAttribute('aria-label') ?? original.textContent?.trim();
|
|
155
|
+
if (label) target.setAttribute('aria-label', label);
|
|
156
|
+
if ('disabled' in original) (target as HTMLIonButtonElement).disabled = (original as HTMLIonButtonElement).disabled;
|
|
157
|
+
if (original.matches('ion-button')) {
|
|
158
|
+
const icon = original.querySelector<HTMLElement>('ion-icon, svg');
|
|
159
|
+
const clonedIcon = icon?.cloneNode(true) as HTMLElement | undefined;
|
|
160
|
+
clonedIcon?.setAttribute('slot', 'icon-only');
|
|
161
|
+
target.replaceChildren(...(clonedIcon ? [clonedIcon] : []));
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const sameSources = (back: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) =>
|
|
165
|
+
back === backSource &&
|
|
166
|
+
groups.length === toolbarProjections.length &&
|
|
167
|
+
groups.every(({ group, actions }, index) => {
|
|
168
|
+
const current = toolbarProjections[index];
|
|
169
|
+
return (
|
|
170
|
+
group === current.source &&
|
|
171
|
+
actions.length === current.actions.length &&
|
|
172
|
+
actions.every((action, actionIndex) => action === current.actions[actionIndex].source)
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
const syncExisting = () => {
|
|
176
|
+
if (backSource && backProjection) syncBack(backProjection, backSource);
|
|
177
|
+
let topOffset = backSource ? toolbarControlSize + toolbarControlGap : 0;
|
|
178
|
+
for (const { projection, source, actions } of toolbarProjections) {
|
|
179
|
+
if (actions.length === 1 && !projection.matches('ion-buttons')) syncAction(projection, actions[0].source);
|
|
180
|
+
else copyAttributes(projection, source);
|
|
181
|
+
projection.classList.add(toolbarProjectionClass, 'ion-cloned-element');
|
|
182
|
+
projection.style.setProperty('--ios-theme-vertical-bars-toolbar-offset', `${topOffset}px`);
|
|
183
|
+
if (projection.matches('ion-buttons')) actions.forEach(({ source: action, projection: clone }) => syncAction(clone, action));
|
|
184
|
+
topOffset += actions.length * toolbarControlSize + toolbarControlGap;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const project = (nextBack: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) => {
|
|
188
|
+
root = verticalBarsRoot()!;
|
|
189
|
+
let topOffset = 0;
|
|
190
|
+
if (nextBack) {
|
|
191
|
+
backSource = nextBack;
|
|
192
|
+
previousBackAriaHidden = nextBack.getAttribute('aria-hidden');
|
|
193
|
+
backProjection = nextBack.cloneNode(false) as HTMLIonBackButtonElement;
|
|
194
|
+
syncBack(backProjection, nextBack);
|
|
195
|
+
backProjection.addEventListener(
|
|
196
|
+
'click',
|
|
197
|
+
(event) => {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
event.stopImmediatePropagation();
|
|
200
|
+
if (backSource && backSource === findBack()) activateProjectedElement(backSource);
|
|
201
|
+
},
|
|
202
|
+
{ capture: true },
|
|
203
|
+
);
|
|
204
|
+
root.append(backProjection);
|
|
205
|
+
nextBack.setAttribute(marker, '');
|
|
206
|
+
nextBack.setAttribute('aria-hidden', 'true');
|
|
207
|
+
nextBack.dispatchEvent(new CustomEvent('nativeUIShellChange'));
|
|
208
|
+
topOffset = toolbarControlSize + toolbarControlGap;
|
|
209
|
+
}
|
|
210
|
+
for (const { group, actions: sources } of groups) {
|
|
211
|
+
const projection = (sources.length === 1 ? sources[0] : group).cloneNode(false) as HTMLElement;
|
|
212
|
+
if (sources.length === 1) syncAction(projection, sources[0]);
|
|
213
|
+
else copyAttributes(projection, group);
|
|
214
|
+
projection.classList.add(toolbarProjectionClass, 'ion-cloned-element');
|
|
215
|
+
projection.style.setProperty('--ios-theme-vertical-bars-toolbar-offset', `${topOffset}px`);
|
|
216
|
+
const actions = sources.map((source) => {
|
|
217
|
+
const clone = sources.length === 1 ? projection : (source.cloneNode(false) as HTMLElement);
|
|
218
|
+
if (sources.length > 1) syncAction(clone, source);
|
|
219
|
+
clone.addEventListener(
|
|
220
|
+
'click',
|
|
221
|
+
(event) => {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
event.stopImmediatePropagation();
|
|
224
|
+
if (isCurrentToolbarAction(source)) activateProjectedElement(source);
|
|
225
|
+
},
|
|
226
|
+
{ capture: true },
|
|
227
|
+
);
|
|
228
|
+
if (sources.length > 1) projection.append(clone);
|
|
229
|
+
const previousAriaHidden = source.getAttribute('aria-hidden');
|
|
230
|
+
source.setAttribute(marker, '');
|
|
231
|
+
source.setAttribute('aria-hidden', 'true');
|
|
232
|
+
source.dispatchEvent(new CustomEvent('nativeUIShellChange'));
|
|
233
|
+
return { source, projection: clone, previousAriaHidden };
|
|
234
|
+
});
|
|
235
|
+
root.append(projection);
|
|
236
|
+
toolbarProjections.push({ source: group, projection, actions });
|
|
237
|
+
topOffset += actions.length * toolbarControlSize + toolbarControlGap;
|
|
238
|
+
}
|
|
239
|
+
if (nextBack || toolbarProjections.length) root.classList.add(readyClass);
|
|
240
|
+
sourceObserver = new MutationObserver((records) => {
|
|
241
|
+
if (
|
|
242
|
+
records.some(
|
|
243
|
+
(record) =>
|
|
244
|
+
record.type !== 'attributes' || (![marker, 'aria-hidden'].includes(record.attributeName ?? '') && !prehideOnlyMutation(record)),
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
schedule();
|
|
248
|
+
});
|
|
249
|
+
if (nextBack?.shadowRoot)
|
|
250
|
+
sourceObserver.observe(nextBack.shadowRoot, { subtree: true, childList: true, attributes: true, attributeOldValue: true });
|
|
251
|
+
for (const { group } of groups)
|
|
252
|
+
sourceObserver.observe(group, { subtree: true, childList: true, attributes: true, attributeOldValue: true });
|
|
253
|
+
};
|
|
254
|
+
const performUpdate = () => {
|
|
255
|
+
frame = 0;
|
|
256
|
+
const currentRoot = verticalBarsRoot();
|
|
257
|
+
if (observingVerticalBars !== !!currentRoot) {
|
|
258
|
+
observingVerticalBars = !!currentRoot;
|
|
259
|
+
observer.disconnect();
|
|
260
|
+
observer.observe(doc.documentElement, {
|
|
261
|
+
subtree: true,
|
|
262
|
+
childList: true,
|
|
263
|
+
attributes: true,
|
|
264
|
+
attributeOldValue: true,
|
|
265
|
+
attributeFilter: observingVerticalBars ? undefined : ['class'],
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (stopped || suspended || !currentRoot || !enabled()) return restore();
|
|
269
|
+
const nextBack = findBack();
|
|
270
|
+
const groups = findToolbarGroups();
|
|
271
|
+
if (!nextBack && !groups.length) return restore();
|
|
272
|
+
if (sameSources(nextBack, groups)) return syncExisting();
|
|
273
|
+
restore();
|
|
274
|
+
project(nextBack, groups);
|
|
275
|
+
updates++;
|
|
276
|
+
};
|
|
277
|
+
const update = () => {
|
|
278
|
+
try {
|
|
279
|
+
performUpdate();
|
|
280
|
+
} catch {
|
|
281
|
+
restore();
|
|
282
|
+
} finally {
|
|
283
|
+
const pending = waiters;
|
|
284
|
+
waiters = [];
|
|
285
|
+
pending.forEach((resolve) => resolve());
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
const schedule = (): Promise<void> => {
|
|
289
|
+
if (stopped) return Promise.resolve();
|
|
290
|
+
const done = new Promise<void>((resolve) => waiters.push(resolve));
|
|
291
|
+
if (!frame) frame = win.requestAnimationFrame(update);
|
|
292
|
+
return done;
|
|
293
|
+
};
|
|
294
|
+
const observer = new MutationObserver((records) => {
|
|
295
|
+
const insideProjection = (target: Node) => {
|
|
296
|
+
const element = target instanceof Element ? target : target.parentNode instanceof Element ? target.parentNode : undefined;
|
|
297
|
+
const rootNode = target.getRootNode();
|
|
298
|
+
const shadowHost = rootNode instanceof ShadowRoot ? rootNode.host : undefined;
|
|
299
|
+
return !![element, shadowHost].some((candidate) => candidate?.closest(`.${backProjectionClass}, .${toolbarProjectionClass}`));
|
|
300
|
+
};
|
|
301
|
+
const verticalBarsChanged = records.some(
|
|
302
|
+
(record) =>
|
|
303
|
+
(record.type === 'attributes' && (record.target as Element).matches('ion-app')) ||
|
|
304
|
+
(record.type === 'childList' &&
|
|
305
|
+
Array.from(record.addedNodes).some(
|
|
306
|
+
(node) =>
|
|
307
|
+
node instanceof Element &&
|
|
308
|
+
(node.matches('ion-app.ios-theme-vertical-bars') || !!node.querySelector('ion-app.ios-theme-vertical-bars')),
|
|
309
|
+
)),
|
|
310
|
+
);
|
|
311
|
+
if (
|
|
312
|
+
(observingVerticalBars &&
|
|
313
|
+
records.some((record) => record.attributeName !== marker && !prehideOnlyMutation(record) && !insideProjection(record.target))) ||
|
|
314
|
+
(!observingVerticalBars && verticalBarsChanged && records.some((record) => !prehideOnlyMutation(record)))
|
|
315
|
+
)
|
|
316
|
+
schedule();
|
|
317
|
+
});
|
|
318
|
+
observer.observe(doc.documentElement, {
|
|
319
|
+
subtree: true,
|
|
320
|
+
childList: true,
|
|
321
|
+
attributes: true,
|
|
322
|
+
attributeOldValue: true,
|
|
323
|
+
attributeFilter: ['class'],
|
|
324
|
+
});
|
|
325
|
+
const pageLifecycle = (event: Event) => {
|
|
326
|
+
const page = event.target;
|
|
327
|
+
if (!(page instanceof HTMLElement) || !page.matches('.ion-page')) return;
|
|
328
|
+
if (event.type === VERTICAL_BARS_TRANSITION_CANCELED) {
|
|
329
|
+
const entering = (event as CustomEvent<{ entering?: HTMLElement }>).detail?.entering;
|
|
330
|
+
verticalBarsPages.cancel(entering, page);
|
|
331
|
+
} else verticalBarsPages.lifecycle(event);
|
|
332
|
+
if (verticalBarsRoot()) schedule();
|
|
333
|
+
};
|
|
334
|
+
for (const name of ['ionViewWillEnter', 'ionViewWillLeave', 'ionViewDidEnter', 'ionViewDidLeave'])
|
|
335
|
+
doc.addEventListener(name, pageLifecycle, { capture: true, signal: listeners.signal });
|
|
336
|
+
doc.addEventListener(VERTICAL_BARS_TRANSITION_CANCELED, pageLifecycle, { capture: true, signal: listeners.signal });
|
|
337
|
+
for (const name of ['ionModalWillPresent', 'ionModalDidDismiss'])
|
|
338
|
+
doc.addEventListener(name, schedule, { capture: true, signal: listeners.signal });
|
|
339
|
+
win.addEventListener('nativeUIShellRefresh', schedule, { signal: listeners.signal });
|
|
340
|
+
schedule();
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
getStatus: (): NativeUIShellStatus => ({
|
|
344
|
+
state: stopped ? 'stopped' : 'web',
|
|
345
|
+
projected: Number(!!backSource) + toolbarProjections.reduce((count, group) => count + group.actions.length, 0),
|
|
346
|
+
updates,
|
|
347
|
+
}),
|
|
348
|
+
async suspend() {
|
|
349
|
+
suspended++;
|
|
350
|
+
restore();
|
|
351
|
+
let resumed = false;
|
|
352
|
+
return {
|
|
353
|
+
async resume() {
|
|
354
|
+
if (resumed) return;
|
|
355
|
+
resumed = true;
|
|
356
|
+
suspended = Math.max(0, suspended - 1);
|
|
357
|
+
if (!suspended) await schedule();
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
async destroy() {
|
|
362
|
+
if (stopped) return;
|
|
363
|
+
stopped = true;
|
|
364
|
+
win.cancelAnimationFrame(frame);
|
|
365
|
+
frame = 0;
|
|
366
|
+
waiters.forEach((resolve) => resolve());
|
|
367
|
+
waiters = [];
|
|
368
|
+
observer.disconnect();
|
|
369
|
+
sourceObserver?.disconnect();
|
|
370
|
+
listeners.abort();
|
|
371
|
+
restore();
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
};
|