@lingxia/elements 0.6.0 → 0.6.5

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.
@@ -0,0 +1,828 @@
1
+ import {
2
+ sendNativeComponentMessage,
3
+ registerNativeComponentHandler,
4
+ addNativeComponentLayoutInvalidationListener,
5
+ } from "./nativecomponent.js";
6
+ import { ensureComponentId, NativeComponentUpdateState, iOSNativeComponentHelper } from "./component.js";
7
+ import { measureElement } from "./dom.js";
8
+ import { isAndroid, isHarmony, isIOS } from "./platform.js";
9
+
10
+ type LxMediaSwiperEventHandler = (e: Event) => void;
11
+
12
+ export type LxMediaSwiperItem =
13
+ | {
14
+ id?: string;
15
+ type: "image";
16
+ src: string;
17
+ }
18
+ | {
19
+ id?: string;
20
+ type: "video";
21
+ src: string;
22
+ poster?: string;
23
+ controls?: boolean;
24
+ muted?: boolean;
25
+ };
26
+
27
+ export type LxMediaSwiperChangeSource = "touch" | "autoplay" | "api" | "video";
28
+ export type LxMediaSwiperErrorCode =
29
+ | "not_found"
30
+ | "network"
31
+ | "decode"
32
+ | "unsupported_format"
33
+ | "permission_denied"
34
+ | "unknown";
35
+
36
+ export type LxMediaSwiperEventDetail = {
37
+ index: number;
38
+ item: LxMediaSwiperItem;
39
+ };
40
+
41
+ export type LxMediaSwiperChangeEventDetail = {
42
+ index: number;
43
+ previousIndex: number;
44
+ item: LxMediaSwiperItem;
45
+ source: LxMediaSwiperChangeSource;
46
+ };
47
+
48
+ export type LxMediaSwiperTransitionEndEventDetail = LxMediaSwiperChangeEventDetail;
49
+
50
+ export type LxMediaSwiperEndReachedEventDetail = {
51
+ index: number;
52
+ item: LxMediaSwiperItem;
53
+ source: LxMediaSwiperChangeSource;
54
+ };
55
+
56
+ export type LxMediaSwiperErrorEventDetail = {
57
+ index: number;
58
+ item?: LxMediaSwiperItem;
59
+ code: LxMediaSwiperErrorCode;
60
+ message: string;
61
+ };
62
+
63
+ export type LxMediaSwiperChangeEvent = CustomEvent<LxMediaSwiperChangeEventDetail>;
64
+ export type LxMediaSwiperTransitionEndEvent = CustomEvent<LxMediaSwiperTransitionEndEventDetail>;
65
+ export type LxMediaSwiperItemEvent = CustomEvent<LxMediaSwiperEventDetail>;
66
+ export type LxMediaSwiperEndReachedEvent = CustomEvent<LxMediaSwiperEndReachedEventDetail>;
67
+ export type LxMediaSwiperErrorEvent = CustomEvent<LxMediaSwiperErrorEventDetail>;
68
+
69
+ /**
70
+ * Peek leaves part of the previous and next pages visible alongside the current
71
+ * page so users get a hint that more content exists. A single `number` applies
72
+ * symmetric peek on both edges; an object lets the two sides differ.
73
+ *
74
+ * For `direction="vertical"`, `previous` peeks the top edge and `next` peeks
75
+ * the bottom edge.
76
+ *
77
+ * Values are CSS pixels (DIPs). `0` (default) disables peek and behaves
78
+ * identically to v1: each page fills the swiper bounds.
79
+ */
80
+ export type LxMediaSwiperPeek = number | { previous?: number; next?: number };
81
+
82
+ export type LxMediaSwiperAttributes = {
83
+ id?: string;
84
+ items?: LxMediaSwiperItem[];
85
+ index?: number;
86
+ initialIndex?: number;
87
+ loop?: boolean;
88
+ autoplay?: boolean;
89
+ interval?: number;
90
+ animation?: "slide" | "none";
91
+ animationDuration?: number;
92
+ direction?: "horizontal" | "vertical";
93
+ contentRotate?: 0 | 90 | 180 | 270;
94
+ objectFit?: "cover" | "contain" | "fill" | "fit";
95
+ controls?: boolean;
96
+ muted?: boolean;
97
+ dots?: boolean | { color?: string; activeColor?: string };
98
+ swipeEnabled?: boolean;
99
+ peek?: LxMediaSwiperPeek;
100
+ className?: string;
101
+ style?: any;
102
+ ref?: any;
103
+ onChange?: LxMediaSwiperEventHandler;
104
+ onTransitionEnd?: LxMediaSwiperEventHandler;
105
+ onEndReached?: LxMediaSwiperEventHandler;
106
+ onTap?: LxMediaSwiperEventHandler;
107
+ onVideoEnded?: LxMediaSwiperEventHandler;
108
+ onError?: LxMediaSwiperEventHandler;
109
+ pageBindings?: Record<string, string>;
110
+ };
111
+
112
+ type ObjectFit = "cover" | "contain" | "fill" | "fit";
113
+ type Animation = "slide" | "none";
114
+ type Direction = "horizontal" | "vertical";
115
+
116
+ const EVENT_MAP = {
117
+ onchange: "change",
118
+ ontransitionend: "transitionend",
119
+ onendreached: "endreached",
120
+ ontap: "tap",
121
+ onvideoended: "videoended",
122
+ } as const;
123
+
124
+ declare global {
125
+ namespace JSX {
126
+ interface IntrinsicElements {
127
+ "lx-media-swiper": LxMediaSwiperAttributes;
128
+ }
129
+ }
130
+ }
131
+
132
+ export class LxMediaSwiperElement extends HTMLElement {
133
+ static get observedAttributes() {
134
+ return [
135
+ "id",
136
+ "items",
137
+ "index",
138
+ "initial-index",
139
+ "loop",
140
+ "autoplay",
141
+ "interval",
142
+ "animation",
143
+ "animation-duration",
144
+ "direction",
145
+ "content-rotate",
146
+ "object-fit",
147
+ "controls",
148
+ "muted",
149
+ "dots",
150
+ "swipe-enabled",
151
+ "peek",
152
+ ];
153
+ }
154
+
155
+ private componentId: string | null = null;
156
+ private mounted = false;
157
+ private updateState = new NativeComponentUpdateState();
158
+ private unregister?: () => void;
159
+ private resizeObserver?: ResizeObserver;
160
+ private pendingLayoutFrame: number | null = null;
161
+ private boundUpdatePosition = this.updatePosition.bind(this);
162
+ private removeLayoutInvalidationListener?: () => void;
163
+ private iOSHelper?: iOSNativeComponentHelper;
164
+ private rawHandlers: Record<string, EventListenerOrEventListenerObject> = {};
165
+ private handlers: Record<string, EventListenerOrEventListenerObject> = {};
166
+ private _pageBindings: Record<string, string> = {};
167
+ // Monotonic command counter used as the Harmony bridge fallback. Harmony's
168
+ // Web.onNativeEmbedDataInfo channel only delivers attribute-derived props,
169
+ // so imperative API calls have to piggyback into a prop the Builder polls.
170
+ private harmonyCommandSeq = 0;
171
+ private harmonyPendingCommand: { name: string; params?: Record<string, unknown> } | null = null;
172
+ private pendingHarmonyProps?: Record<string, unknown>;
173
+ private pendingHarmonyRetryTimer: number | null = null;
174
+ private pendingHarmonyRetryCount = 0;
175
+ // Harmony's ArkWeb only fires `onNativeEmbedDataInfo` for actual <embed> elements
176
+ // with `type="native/..."`. Other platforms rely on the bridge message channel
177
+ // (component.mount/update/unmount), but Harmony has no such channel, so props
178
+ // are encoded into the child <embed> src.
179
+ private harmonyEmbed?: HTMLEmbedElement;
180
+ private lastHarmonyProps?: string;
181
+
182
+ set items(value: LxMediaSwiperItem[] | string | null | undefined) {
183
+ if (typeof value === "string") {
184
+ const trimmed = value.trim();
185
+ if (trimmed.length === 0) {
186
+ this.removeAttribute("items");
187
+ return;
188
+ }
189
+ this.setAttribute("items", trimmed);
190
+ return;
191
+ }
192
+ if (!Array.isArray(value) || value.length === 0) {
193
+ this.removeAttribute("items");
194
+ return;
195
+ }
196
+ this.setAttribute("items", JSON.stringify(value));
197
+ }
198
+
199
+ get items(): LxMediaSwiperItem[] {
200
+ return this.parseItems();
201
+ }
202
+
203
+ set index(value: number | null | undefined) {
204
+ if (value == null || !Number.isFinite(Number(value))) {
205
+ this.removeAttribute("index");
206
+ return;
207
+ }
208
+ this.setAttribute("index", String(Math.trunc(Number(value))));
209
+ }
210
+
211
+ get index(): number | undefined {
212
+ return this.parseIntegerAttr("index");
213
+ }
214
+
215
+ set initialIndex(value: number | null | undefined) {
216
+ if (value == null || !Number.isFinite(Number(value))) {
217
+ this.removeAttribute("initial-index");
218
+ return;
219
+ }
220
+ this.setAttribute("initial-index", String(Math.trunc(Number(value))));
221
+ }
222
+
223
+ get initialIndex(): number | undefined {
224
+ return this.parseIntegerAttr("initial-index");
225
+ }
226
+
227
+ set contentRotate(value: unknown) {
228
+ const parsed = this.parseRotate(value);
229
+ if (parsed === undefined) {
230
+ this.removeAttribute("content-rotate");
231
+ return;
232
+ }
233
+ this.setAttribute("content-rotate", String(parsed));
234
+ }
235
+
236
+ get contentRotate(): 0 | 90 | 180 | 270 | undefined {
237
+ return this.parseRotate(this.getAttribute("content-rotate"));
238
+ }
239
+
240
+ set pageBindings(bindings: Record<string, string>) {
241
+ this.setPageBindings(bindings);
242
+ }
243
+
244
+ get pageBindings(): Record<string, string> {
245
+ return this._pageBindings;
246
+ }
247
+
248
+ set pageFuncBindings(bindings: Record<string, string>) {
249
+ this.setPageBindings(bindings);
250
+ }
251
+
252
+ setPageBindings(bindings: Record<string, string>) {
253
+ this._pageBindings = bindings ?? {};
254
+ if (this.isConnected) this.mountOrUpdate(true);
255
+ }
256
+
257
+ next(): void {
258
+ this.sendCommand("next");
259
+ }
260
+
261
+ previous(): void {
262
+ this.sendCommand("previous");
263
+ }
264
+
265
+ goToIndex(index: number): void {
266
+ if (!Number.isInteger(index)) return;
267
+ this.sendCommand("goToIndex", { index });
268
+ }
269
+
270
+ connectedCallback() {
271
+ this.upgradeProperty("items");
272
+ this.upgradeProperty("index");
273
+ this.upgradeProperty("initialIndex");
274
+ this.upgradeProperty("contentRotate");
275
+ this.upgradeProperty("pageBindings");
276
+ this.upgradeProperty("pageFuncBindings");
277
+ for (const prop of Object.keys(EVENT_MAP)) this.upgradeProperty(prop);
278
+
279
+ this.componentId = ensureComponentId(this, "lx-media-swiper", this.componentId);
280
+ if (!this.componentId) return;
281
+
282
+ this.unregister = registerNativeComponentHandler(this.componentId, (message) => {
283
+ if (!message.event) return;
284
+ const detail = message.detail || message.payload || {};
285
+ const ev = new CustomEvent(message.event, {
286
+ detail,
287
+ bubbles: true,
288
+ cancelable: false,
289
+ });
290
+ this.dispatchEvent(ev);
291
+ });
292
+
293
+ this.ensurePlaceholderStyle();
294
+ this.iOSHelper = new iOSNativeComponentHelper(this, this.componentId);
295
+ this.iOSHelper.setup();
296
+
297
+ this.mountOrUpdate();
298
+ this.startTracking();
299
+ }
300
+
301
+ disconnectedCallback() {
302
+ this.updateState.reset();
303
+ if (this.componentId && !isHarmony()) {
304
+ sendNativeComponentMessage({
305
+ action: "component.unmount",
306
+ id: this.componentId,
307
+ });
308
+ }
309
+ if (this.harmonyEmbed && this.contains(this.harmonyEmbed)) {
310
+ this.removeChild(this.harmonyEmbed);
311
+ }
312
+ this.harmonyEmbed = undefined;
313
+ this.lastHarmonyProps = undefined;
314
+ this.harmonyPendingCommand = null;
315
+ this.pendingHarmonyProps = undefined;
316
+ this.pendingHarmonyRetryCount = 0;
317
+ if (this.pendingHarmonyRetryTimer !== null) {
318
+ window.clearTimeout(this.pendingHarmonyRetryTimer);
319
+ this.pendingHarmonyRetryTimer = null;
320
+ }
321
+ this.stopTracking();
322
+ this.unregister?.();
323
+ this.unregister = undefined;
324
+ this.iOSHelper?.cleanup();
325
+ this.iOSHelper = undefined;
326
+ Object.keys(this.handlers).forEach((name) => this.removeEventListener(name, this.handlers[name]));
327
+ this.handlers = {};
328
+ this.rawHandlers = {};
329
+ }
330
+
331
+ attributeChangedCallback(name: string) {
332
+ if (name === "id") {
333
+ const prev = this.componentId;
334
+ this.componentId = ensureComponentId(this, "lx-media-swiper", this.componentId);
335
+ if (prev && this.componentId !== prev) {
336
+ this.mounted = false;
337
+ this.updateState.reset();
338
+ }
339
+ }
340
+ if (!this.isConnected) return;
341
+ this.mountOrUpdate(name === "content-rotate" || name === "object-fit");
342
+ }
343
+
344
+ set onchange(cb: EventListener) { this.setEventHandler("change", cb); }
345
+ get onchange() { return this.getEventHandler("change"); }
346
+
347
+ set ontransitionend(cb: EventListener) { this.setEventHandler("transitionend", cb); }
348
+ get ontransitionend() { return this.getEventHandler("transitionend"); }
349
+
350
+ set onendreached(cb: EventListener) { this.setEventHandler("endreached", cb); }
351
+ get onendreached() { return this.getEventHandler("endreached"); }
352
+
353
+ set ontap(cb: EventListener) { this.setEventHandler("tap", cb); }
354
+ get ontap() { return this.getEventHandler("tap"); }
355
+
356
+ set onvideoended(cb: EventListener) { this.setEventHandler("videoended", cb); }
357
+ get onvideoended() { return this.getEventHandler("videoended"); }
358
+
359
+ private sendCommand(name: string, params?: Record<string, unknown>) {
360
+ if (!this.componentId) return;
361
+ if (isHarmony()) {
362
+ // Harmony has no bridge channel for `component.command`; route the call through
363
+ // the props pipeline so the Builder's onParamsChange can dispatch it.
364
+ this.harmonyCommandSeq += 1;
365
+ this.harmonyPendingCommand = { name, params };
366
+ this.mountOrUpdate(true);
367
+ return;
368
+ }
369
+ sendNativeComponentMessage({
370
+ action: "component.command",
371
+ id: this.componentId,
372
+ name,
373
+ ...(params ? { params } : {}),
374
+ });
375
+ }
376
+
377
+ private collectProps(): Record<string, unknown> {
378
+ const objectFit = this.parseObjectFit(this.getAttribute("object-fit"));
379
+ const dots = this.parseDots();
380
+ const dataset = this.collectDataset();
381
+ // Always emit a normalised peek object so removing the attribute (or setting it
382
+ // to 0) propagates as an explicit reset. JSON.stringify would drop `undefined`,
383
+ // which makes native think peek hasn't changed and keep the old padding.
384
+ const peek = this.parsePeek() ?? { previous: 0, next: 0 };
385
+ const props: Record<string, unknown> = {
386
+ items: this.parseItems(),
387
+ index: this.parseIntegerAttr("index") ?? null,
388
+ initialIndex: this.parseIntegerAttr("initial-index") ?? 0,
389
+ loop: this.hasAttribute("loop"),
390
+ autoplay: this.hasAttribute("autoplay"),
391
+ interval: this.parseIntegerAttr("interval") ?? 5000,
392
+ animation: this.parseAnimation(this.getAttribute("animation")),
393
+ animationDuration: this.parseIntegerAttr("animation-duration") ?? 300,
394
+ direction: this.parseDirection(this.getAttribute("direction")),
395
+ contentRotate: this.parseRotate(this.getAttribute("content-rotate")) ?? 0,
396
+ objectFit: objectFit ?? "cover",
397
+ controls: this.hasAttribute("controls"),
398
+ muted: this.hasAttribute("muted") ? this.getAttribute("muted") !== "false" : true,
399
+ dots,
400
+ swipeEnabled: this.getAttribute("swipe-enabled") !== "false",
401
+ peek,
402
+ pageFuncBindings: this._pageBindings,
403
+ pageFuncBindingsJson: JSON.stringify(this._pageBindings),
404
+ dataset,
405
+ datasetJson: JSON.stringify(dataset),
406
+ };
407
+ return props;
408
+ }
409
+
410
+ private mountOrUpdate(forceUpdate = false) {
411
+ if (isHarmony()) {
412
+ this.mountOrUpdateHarmony(forceUpdate);
413
+ return;
414
+ }
415
+ if (!this.componentId) return;
416
+ const measured = measureElement(this);
417
+ const rect = measured.rect;
418
+ if (!rect.width || !rect.height) {
419
+ if (!this.mounted) this.scheduleMountRetry();
420
+ return;
421
+ }
422
+ const props = this.collectProps();
423
+ const zIndex = parseFloat(this.style.zIndex || "0") || 0;
424
+ const decision = this.updateState.decide(rect, props, zIndex, !this.mounted || forceUpdate);
425
+ if (!decision.shouldSend) return;
426
+
427
+ const payload: any = {
428
+ action: this.mounted ? "component.update" : "component.mount",
429
+ id: this.componentId,
430
+ type: "media-swiper.native",
431
+ rect,
432
+ zIndex,
433
+ ...(measured.cornerRadius !== undefined ? { cornerRadius: measured.cornerRadius } : {}),
434
+ };
435
+ this.iOSHelper?.enhancePayload(payload);
436
+ if (decision.propsChanged || !this.mounted) payload.props = props;
437
+ sendNativeComponentMessage(payload);
438
+ this.mounted = true;
439
+ }
440
+
441
+ private mountOrUpdateHarmony(forceUpdate = false) {
442
+ if (!this.componentId) return;
443
+ const measured = measureElement(this);
444
+ const rect = measured.rect;
445
+ if (!rect.width || !rect.height) {
446
+ if (!this.mounted) this.scheduleMountRetry();
447
+ return;
448
+ }
449
+ const props = this.collectProps();
450
+ if (measured.cornerRadius !== undefined) {
451
+ (props as any).cornerRadius = measured.cornerRadius;
452
+ }
453
+ const zIndex = parseFloat(this.style.zIndex || "0") || 0;
454
+ const decision = this.updateState.decide(rect, props, zIndex, !this.mounted || forceUpdate);
455
+ if (!decision.shouldSend) return;
456
+ this.ensureHarmonyEmbed(rect, props, measured.cornerRadius);
457
+ this.flushHarmonyCommandIfPending(props);
458
+ this.mounted = true;
459
+ }
460
+
461
+ private ensureHarmonyEmbed(
462
+ rect: { width: number; height: number },
463
+ props: Record<string, unknown>,
464
+ cornerRadius?: number
465
+ ) {
466
+ const createEmbed = (): HTMLEmbedElement => {
467
+ const embed = document.createElement("embed");
468
+ embed.setAttribute("type", "native/media-swiper");
469
+ embed.setAttribute("width", `${rect.width}`);
470
+ embed.setAttribute("height", `${rect.height}`);
471
+ embed.setAttribute("id", this.componentId!);
472
+ embed.setAttribute("data-lx-component-id", this.componentId!);
473
+ embed.style.display = "block";
474
+ embed.style.width = "100%";
475
+ embed.style.height = "100%";
476
+ embed.style.border = "none";
477
+ if (cornerRadius !== undefined) {
478
+ embed.style.borderRadius = `${cornerRadius}px`;
479
+ embed.style.overflow = "hidden";
480
+ }
481
+ const encodedProps = this.encodeHarmonyProps(props);
482
+ embed.setAttribute("src", encodedProps);
483
+ this.lastHarmonyProps = encodedProps;
484
+ return embed;
485
+ };
486
+
487
+ if (!this.harmonyEmbed) {
488
+ const embed = createEmbed();
489
+ this.appendChild(embed);
490
+ this.harmonyEmbed = embed;
491
+ this.pushHarmonyPropsUpdate(props);
492
+ return;
493
+ }
494
+
495
+ const encodedProps = this.encodeHarmonyProps(props);
496
+ if (encodedProps !== this.lastHarmonyProps) {
497
+ this.harmonyEmbed.setAttribute("src", encodedProps);
498
+ const pushed = this.pushHarmonyPropsUpdate(props);
499
+ if (pushed) {
500
+ this.lastHarmonyProps = encodedProps;
501
+ this.pendingHarmonyProps = undefined;
502
+ this.pendingHarmonyRetryCount = 0;
503
+ } else {
504
+ this.scheduleHarmonyPropsRetry(props);
505
+ }
506
+ }
507
+
508
+ if (cornerRadius !== undefined && this.harmonyEmbed.style.borderRadius !== `${cornerRadius}px`) {
509
+ this.harmonyEmbed.style.borderRadius = `${cornerRadius}px`;
510
+ this.harmonyEmbed.style.overflow = "hidden";
511
+ }
512
+ }
513
+
514
+ private encodeHarmonyProps(props: Record<string, unknown>): string {
515
+ try {
516
+ const payload = { componentId: this.componentId, ...props };
517
+ return `data:application/json,${encodeURIComponent(JSON.stringify(payload))}`;
518
+ } catch {
519
+ return `data:application/json,%7B%7D`;
520
+ }
521
+ }
522
+
523
+ private pushHarmonyPropsUpdate(props: Record<string, unknown>): boolean {
524
+ if (!this.componentId || !isHarmony()) return false;
525
+ try {
526
+ const proxy = (window as unknown as Record<string, unknown>).LingXiaProxy as
527
+ | { nativeComponentUpdate?: ((componentId: string, payload: string) => void) | ((payload: string) => void) }
528
+ | undefined;
529
+ const updateFn = proxy?.nativeComponentUpdate;
530
+ if (typeof updateFn !== "function") {
531
+ return false;
532
+ }
533
+ const payload = JSON.stringify({ componentId: this.componentId, ...props });
534
+ try {
535
+ (updateFn as (payload: string) => void).call(proxy, payload);
536
+ return true;
537
+ } catch {
538
+ try {
539
+ (updateFn as (componentId: string, payload: string) => void).call(proxy, this.componentId, payload);
540
+ return true;
541
+ } catch {
542
+ return false;
543
+ }
544
+ }
545
+ } catch {
546
+ return false;
547
+ }
548
+ }
549
+
550
+ private scheduleHarmonyPropsRetry(props: Record<string, unknown>): void {
551
+ this.pendingHarmonyProps = { ...props };
552
+ if (this.pendingHarmonyRetryTimer !== null) {
553
+ return;
554
+ }
555
+ this.pendingHarmonyRetryTimer = window.setTimeout(() => {
556
+ this.pendingHarmonyRetryTimer = null;
557
+ if (!this.isConnected || !this.pendingHarmonyProps) {
558
+ return;
559
+ }
560
+ const pendingProps = this.pendingHarmonyProps;
561
+ const pushed = this.pushHarmonyPropsUpdate(pendingProps);
562
+ if (pushed) {
563
+ this.lastHarmonyProps = this.encodeHarmonyProps(pendingProps);
564
+ this.pendingHarmonyProps = undefined;
565
+ this.pendingHarmonyRetryCount = 0;
566
+ } else {
567
+ this.pendingHarmonyRetryCount += 1;
568
+ if (this.pendingHarmonyRetryCount < 20) {
569
+ this.scheduleHarmonyPropsRetry(pendingProps);
570
+ }
571
+ }
572
+ }, 100);
573
+ }
574
+
575
+ private flushHarmonyCommandIfPending(baseProps: Record<string, unknown>): void {
576
+ if (!this.harmonyPendingCommand) return;
577
+ const command = this.harmonyPendingCommand;
578
+ const commandProps: Record<string, unknown> = {
579
+ ...baseProps,
580
+ __cmdSeq: this.harmonyCommandSeq,
581
+ __cmdName: command.name,
582
+ };
583
+ if (command.params) {
584
+ commandProps.__cmdParams = command.params;
585
+ }
586
+ if (this.harmonyEmbed) {
587
+ const encodedProps = this.encodeHarmonyProps(commandProps);
588
+ this.harmonyEmbed.setAttribute("src", encodedProps);
589
+ this.lastHarmonyProps = encodedProps;
590
+ if (!this.pushHarmonyPropsUpdate(commandProps)) {
591
+ this.scheduleHarmonyPropsRetry(commandProps);
592
+ }
593
+ }
594
+ this.harmonyPendingCommand = null;
595
+ }
596
+
597
+ private updatePosition() {
598
+ if (isHarmony()) {
599
+ // Harmony tracks size via the embed element + ArkWeb's onNativeEmbedDataInfo;
600
+ // any rect/prop refresh has to go through mountOrUpdateHarmony so the embed
601
+ // stays consistent.
602
+ this.mountOrUpdateHarmony();
603
+ return;
604
+ }
605
+ if (!this.mounted || !this.componentId) return;
606
+ const measured = measureElement(this);
607
+ const rect = measured.rect;
608
+ if (!rect.width || !rect.height) return;
609
+ const zIndex = parseFloat(this.style.zIndex || "0") || 0;
610
+ const decision = this.updateState.decide(rect, null, zIndex);
611
+ if (!decision.shouldSend) return;
612
+ const payload: any = {
613
+ action: "component.update",
614
+ id: this.componentId,
615
+ rect,
616
+ zIndex,
617
+ ...(measured.cornerRadius !== undefined ? { cornerRadius: measured.cornerRadius } : {}),
618
+ };
619
+ this.iOSHelper?.enhancePayload(payload);
620
+ sendNativeComponentMessage(payload);
621
+ }
622
+
623
+ private startTracking() {
624
+ window.addEventListener("resize", this.boundUpdatePosition);
625
+ if (isAndroid()) {
626
+ this.removeLayoutInvalidationListener = addNativeComponentLayoutInvalidationListener(this.boundUpdatePosition);
627
+ }
628
+ this.startSizeObserver();
629
+ this.updatePosition();
630
+ }
631
+
632
+ private stopTracking() {
633
+ window.removeEventListener("resize", this.boundUpdatePosition);
634
+ this.removeLayoutInvalidationListener?.();
635
+ this.removeLayoutInvalidationListener = undefined;
636
+ this.stopSizeObserver();
637
+ if (this.pendingLayoutFrame !== null) {
638
+ cancelAnimationFrame(this.pendingLayoutFrame);
639
+ this.pendingLayoutFrame = null;
640
+ }
641
+ }
642
+
643
+ private startSizeObserver() {
644
+ if (typeof ResizeObserver === "undefined" || this.resizeObserver) return;
645
+ this.resizeObserver = new ResizeObserver(() => {
646
+ if (this.pendingLayoutFrame !== null) cancelAnimationFrame(this.pendingLayoutFrame);
647
+ this.pendingLayoutFrame = requestAnimationFrame(() => {
648
+ this.pendingLayoutFrame = null;
649
+ this.mountOrUpdate();
650
+ });
651
+ });
652
+ this.resizeObserver.observe(this);
653
+ }
654
+
655
+ private stopSizeObserver() {
656
+ this.resizeObserver?.disconnect();
657
+ this.resizeObserver = undefined;
658
+ }
659
+
660
+ private scheduleMountRetry() {
661
+ if (this.pendingLayoutFrame !== null) return;
662
+ this.pendingLayoutFrame = requestAnimationFrame(() => {
663
+ this.pendingLayoutFrame = null;
664
+ if (this.isConnected) this.mountOrUpdate();
665
+ });
666
+ }
667
+
668
+ private ensurePlaceholderStyle() {
669
+ if (!this.style.display) this.style.display = "block";
670
+ if (!this.style.position) this.style.position = "relative";
671
+ if (!this.style.backgroundColor) this.style.backgroundColor = "black";
672
+ if (!this.style.aspectRatio) this.style.aspectRatio = "16 / 9";
673
+ }
674
+
675
+ private upgradeProperty(propName: string): void {
676
+ const self = this as unknown as Record<string, unknown>;
677
+ if (!Object.prototype.hasOwnProperty.call(self, propName)) return;
678
+ const value = self[propName];
679
+ delete self[propName];
680
+ self[propName] = value;
681
+ }
682
+
683
+ private isEventListener(value: unknown): value is EventListenerOrEventListenerObject {
684
+ if (typeof value === "function") return true;
685
+ if (!value || typeof value !== "object") return false;
686
+ return typeof (value as EventListenerObject).handleEvent === "function";
687
+ }
688
+
689
+ private setEventHandler(name: string, value: unknown) {
690
+ const current = this.handlers[name];
691
+ if (current) this.removeEventListener(name, current);
692
+ if (this.isEventListener(value)) {
693
+ const listener = typeof value === "function" ? ({ handleEvent: value } as EventListenerObject) : value;
694
+ this.handlers[name] = listener;
695
+ this.rawHandlers[name] = value;
696
+ this.addEventListener(name, listener);
697
+ } else {
698
+ delete this.handlers[name];
699
+ delete this.rawHandlers[name];
700
+ }
701
+ }
702
+
703
+ private getEventHandler(name: string) {
704
+ return (this.rawHandlers[name] || null) as any;
705
+ }
706
+
707
+ private parseItems(): LxMediaSwiperItem[] {
708
+ const raw = this.getAttribute("items");
709
+ if (!raw) return [];
710
+ try {
711
+ const parsed = JSON.parse(raw);
712
+ if (!Array.isArray(parsed)) return [];
713
+ return parsed
714
+ .map((item): LxMediaSwiperItem | null => {
715
+ if (!item || typeof item !== "object") return null;
716
+ const rec = item as Record<string, unknown>;
717
+ const type = rec.type === "video" ? "video" : rec.type === "image" ? "image" : null;
718
+ const src = typeof rec.src === "string" ? rec.src.trim() : "";
719
+ if (!type || !src) return null;
720
+ if (type === "image") {
721
+ return { id: typeof rec.id === "string" ? rec.id : undefined, type, src };
722
+ }
723
+ return {
724
+ id: typeof rec.id === "string" ? rec.id : undefined,
725
+ type,
726
+ src,
727
+ poster: typeof rec.poster === "string" ? rec.poster : undefined,
728
+ controls: typeof rec.controls === "boolean" ? rec.controls : undefined,
729
+ muted: typeof rec.muted === "boolean" ? rec.muted : undefined,
730
+ };
731
+ })
732
+ .filter((item): item is LxMediaSwiperItem => item !== null);
733
+ } catch {
734
+ return [];
735
+ }
736
+ }
737
+
738
+ private parseDots(): false | { color?: string; activeColor?: string } {
739
+ if (!this.hasAttribute("dots")) return false;
740
+ const raw = this.getAttribute("dots");
741
+ if (!raw || raw === "true") return {};
742
+ if (raw === "false") return false;
743
+ try {
744
+ const parsed = JSON.parse(raw);
745
+ if (!parsed || typeof parsed !== "object") return {};
746
+ const rec = parsed as Record<string, unknown>;
747
+ return {
748
+ color: typeof rec.color === "string" ? rec.color : undefined,
749
+ activeColor: typeof rec.activeColor === "string" ? rec.activeColor : undefined,
750
+ };
751
+ } catch {
752
+ return {};
753
+ }
754
+ }
755
+
756
+ private parseIntegerAttr(name: string): number | undefined {
757
+ const raw = this.getAttribute(name);
758
+ if (raw == null || raw.trim() === "") return undefined;
759
+ const parsed = Number(raw);
760
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
761
+ }
762
+
763
+ private parseRotate(value: unknown): 0 | 90 | 180 | 270 | undefined {
764
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value.trim()) : NaN;
765
+ return parsed === 0 || parsed === 90 || parsed === 180 || parsed === 270 ? parsed : undefined;
766
+ }
767
+
768
+ private parseObjectFit(value: unknown): ObjectFit | undefined {
769
+ if (typeof value !== "string") return undefined;
770
+ const normalized = value.trim().toLowerCase();
771
+ return normalized === "cover" || normalized === "contain" || normalized === "fill" || normalized === "fit"
772
+ ? normalized
773
+ : undefined;
774
+ }
775
+
776
+ private parseAnimation(value: unknown): Animation {
777
+ return value === "none" ? "none" : "slide";
778
+ }
779
+
780
+ private parseDirection(value: unknown): Direction {
781
+ return value === "vertical" ? "vertical" : "horizontal";
782
+ }
783
+
784
+ /**
785
+ * Parse the `peek` attribute into a normalised `{ previous, next }` shape.
786
+ * Returns `undefined` when no peek is configured so native sides can fall back
787
+ * to the zero-peek (full-width page) layout.
788
+ */
789
+ private parsePeek(): { previous: number; next: number } | undefined {
790
+ if (!this.hasAttribute("peek")) return undefined;
791
+ const raw = this.getAttribute("peek");
792
+ if (raw == null) return undefined;
793
+ const trimmed = raw.trim();
794
+ if (trimmed.length === 0) return undefined;
795
+ const numeric = Number(trimmed);
796
+ if (Number.isFinite(numeric)) {
797
+ const value = Math.max(0, numeric);
798
+ return value > 0 ? { previous: value, next: value } : undefined;
799
+ }
800
+ try {
801
+ const parsed = JSON.parse(trimmed);
802
+ if (!parsed || typeof parsed !== "object") return undefined;
803
+ const rec = parsed as Record<string, unknown>;
804
+ const previous = Math.max(0, Number(rec.previous) || 0);
805
+ const next = Math.max(0, Number(rec.next) || 0);
806
+ return previous > 0 || next > 0 ? { previous, next } : undefined;
807
+ } catch {
808
+ return undefined;
809
+ }
810
+ }
811
+
812
+ private collectDataset(): Record<string, string> {
813
+ const dataset: Record<string, string> = {};
814
+ for (const attr of this.getAttributeNames()) {
815
+ if (!attr.startsWith("data-")) continue;
816
+ const key = attr.slice(5).replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
817
+ const value = this.getAttribute(attr);
818
+ if (key && value != null) dataset[key] = value;
819
+ }
820
+ return dataset;
821
+ }
822
+ }
823
+
824
+ export function registerMediaSwiperComponent() {
825
+ if (!customElements.get("lx-media-swiper")) {
826
+ customElements.define("lx-media-swiper", LxMediaSwiperElement);
827
+ }
828
+ }