@digia-engage/core 2.0.3 → 2.2.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.
Files changed (65) hide show
  1. package/DigiaEngageReactNative.podspec +1 -1
  2. package/android/.project +28 -0
  3. package/android/bin/.gradle/8.13/fileHashes/fileHashes.lock +0 -0
  4. package/android/bin/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  5. package/android/bin/.gradle/buildOutputCleanup/cache.properties +2 -0
  6. package/android/bin/.project +34 -0
  7. package/android/bin/build/generated/source/buildConfig/debug/com/digia/engage/rn/BuildConfig.class +0 -0
  8. package/android/bin/build/generated/source/codegen/java/com/digia/engage/rn/NativeDigiaEngageSpec.class +0 -0
  9. package/android/bin/build.gradle +97 -0
  10. package/android/bin/gradle/wrapper/gradle-wrapper.jar +0 -0
  11. package/android/bin/gradle/wrapper/gradle-wrapper.properties +5 -0
  12. package/android/bin/gradle.properties +2 -0
  13. package/android/bin/gradlew +185 -0
  14. package/android/bin/gradlew.bat +89 -0
  15. package/android/bin/local.properties +1 -0
  16. package/android/bin/settings.gradle +25 -0
  17. package/android/bin/src/main/AndroidManifest.xml +2 -0
  18. package/android/bin/src/main/java/com/digia/engage/rn/DigiaAnchorViewManager.kt +90 -0
  19. package/android/bin/src/main/java/com/digia/engage/rn/DigiaModule.kt +309 -0
  20. package/android/bin/src/main/java/com/digia/engage/rn/DigiaPackage.kt +70 -0
  21. package/android/bin/src/main/java/com/digia/engage/rn/DigiaSlotViewManager.kt +183 -0
  22. package/android/bin/src/main/java/com/digia/engage/rn/DigiaViewManager.kt +64 -0
  23. package/android/build.gradle +1 -1
  24. package/android/src/main/java/com/digia/engage/rn/DigiaModule.kt +37 -13
  25. package/ios/DigiaEngageModule.m +25 -26
  26. package/ios/DigiaModule.swift +71 -10
  27. package/lib/commonjs/Digia.js +13 -2
  28. package/lib/commonjs/Digia.js.map +1 -1
  29. package/lib/commonjs/DigiaAnchorView.js +7 -3
  30. package/lib/commonjs/DigiaAnchorView.js.map +1 -1
  31. package/lib/commonjs/DigiaGuideController.js.map +1 -1
  32. package/lib/commonjs/DigiaProvider.js +70 -26
  33. package/lib/commonjs/DigiaProvider.js.map +1 -1
  34. package/lib/commonjs/digiaAnchorRegistry.js +15 -1
  35. package/lib/commonjs/digiaAnchorRegistry.js.map +1 -1
  36. package/lib/commonjs/interpolate.js +41 -0
  37. package/lib/commonjs/interpolate.js.map +1 -0
  38. package/lib/module/Digia.js +13 -2
  39. package/lib/module/Digia.js.map +1 -1
  40. package/lib/module/DigiaAnchorView.js +7 -3
  41. package/lib/module/DigiaAnchorView.js.map +1 -1
  42. package/lib/module/DigiaGuideController.js.map +1 -1
  43. package/lib/module/DigiaProvider.js +72 -27
  44. package/lib/module/DigiaProvider.js.map +1 -1
  45. package/lib/module/digiaAnchorRegistry.js +15 -1
  46. package/lib/module/digiaAnchorRegistry.js.map +1 -1
  47. package/lib/module/interpolate.js +34 -0
  48. package/lib/module/interpolate.js.map +1 -0
  49. package/lib/typescript/Digia.d.ts +1 -0
  50. package/lib/typescript/Digia.d.ts.map +1 -1
  51. package/lib/typescript/DigiaGuideController.d.ts +2 -0
  52. package/lib/typescript/DigiaGuideController.d.ts.map +1 -1
  53. package/lib/typescript/DigiaProvider.d.ts.map +1 -1
  54. package/lib/typescript/digiaAnchorRegistry.d.ts +4 -0
  55. package/lib/typescript/digiaAnchorRegistry.d.ts.map +1 -1
  56. package/lib/typescript/interpolate.d.ts +4 -0
  57. package/lib/typescript/interpolate.d.ts.map +1 -0
  58. package/package.json +4 -5
  59. package/react-native.config.js +1 -1
  60. package/src/Digia.ts +15 -2
  61. package/src/DigiaAnchorView.tsx +8 -1
  62. package/src/DigiaGuideController.ts +2 -0
  63. package/src/DigiaProvider.tsx +185 -145
  64. package/src/digiaAnchorRegistry.ts +16 -1
  65. package/src/interpolate.ts +44 -0
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
1
+ import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
2
2
  import {
3
3
  Animated,
4
4
  Dimensions,
@@ -17,6 +17,18 @@ import { digiaAnchorRegistry, type AnchorLayout } from './digiaAnchorRegistry';
17
17
  import { digiaActionHandler, type ActionCallbacks } from './actionHandler';
18
18
  import type { DismissReason } from './types';
19
19
  import type { Action, SpotlightConfig, SpotlightStep, TooltipConfig, TooltipStep } from './templateTypes';
20
+ import { interpolateVariables, type VariableMap } from './interpolate';
21
+
22
+ // ─── Variable context ─────────────────────────────────────────────────────────
23
+ // Provides the active campaign's variable map to all descendant components.
24
+ // Avoids threading `variables` through every prop chain.
25
+
26
+ const VariableContext = createContext<VariableMap | undefined>(undefined);
27
+
28
+ function TextWithVariables({ children, ...props }: Omit<React.ComponentProps<typeof Text>, 'children'> & { children: string }) {
29
+ const variables = useContext(VariableContext);
30
+ return <Text {...props}>{interpolateVariables(children, variables)}</Text>;
31
+ }
20
32
 
21
33
  // ─── @floating-ui/core platform adapter ──────────────────────────────────────
22
34
 
@@ -184,9 +196,9 @@ function ActionButton({
184
196
  onPress={onPress}
185
197
  style={[s.button, isPrimary && { backgroundColor: btnPrimaryBg }]}
186
198
  >
187
- <Text style={{ color: isPrimary ? btnPrimaryText : btnGhostText, fontSize: 13, fontWeight: '600', fontFamily }}>
199
+ <TextWithVariables style={{ color: isPrimary ? btnPrimaryText : btnGhostText, fontSize: 13, fontWeight: '600', fontFamily }}>
188
200
  {action.label}
189
- </Text>
201
+ </TextWithVariables>
190
202
  </Pressable>
191
203
  );
192
204
  }
@@ -221,10 +233,20 @@ function TooltipOverlay({
221
233
  useEffect(() => {
222
234
  setLayout(null);
223
235
  setFloatPos(null);
224
- return digiaAnchorRegistry.subscribe(step.anchorKey, (l) => {
236
+ let skipCached = false;
237
+ const unsub = digiaAnchorRegistry.subscribe(step.anchorKey, (l) => {
238
+ if (!skipCached) return;
239
+ const { width: screenW, height: screenH } = Dimensions.get('window');
240
+ if (l.pageY + l.height <= 0 || l.pageY >= screenH || l.pageX + l.width <= 0 || l.pageX >= screenW) {
241
+ digiaGuideController.cancel(request.payloadId);
242
+ return;
243
+ }
225
244
  setLayout(l);
226
245
  });
227
- }, [step.anchorKey]);
246
+ skipCached = true;
247
+ digiaAnchorRegistry.remeasure(step.anchorKey);
248
+ return unsub;
249
+ }, [step.anchorKey]); // eslint-disable-line react-hooks/exhaustive-deps
228
250
 
229
251
  useEffect(() => {
230
252
  if (!layout || !floatingSize) return;
@@ -317,104 +339,110 @@ function TooltipOverlay({
317
339
  }, [config.outsideTapBehavior, next, dismiss]);
318
340
 
319
341
  return (
320
- <Modal transparent statusBarTranslucent animationType="none" visible>
321
- <Animated.View style={[StyleSheet.absoluteFill, { opacity: opacityAnim }]}>
322
- {/* Full-screen backdrop: blocks all touches (scroll, tap) */}
323
- <Pressable style={StyleSheet.absoluteFill} onPress={handleBackdropPress} />
324
- {floatPos ? (
325
- // Bubble as Pressable so tapping the bubble body also advances
326
- <Pressable
327
- onLayout={(e) => {
328
- const { width, height } = e.nativeEvent.layout;
329
- if (floatingSize?.w !== width || floatingSize?.h !== height) {
330
- setFloatingSize({ w: width, h: height });
331
- }
332
- }}
333
- onPress={handleBackdropPress}
334
- style={[
335
- s.tooltipBubble,
336
- {
337
- left: floatPos.x,
338
- top: floatPos.y,
339
- width: tooltipW,
340
- backgroundColor: step.backgroundColor,
341
- borderRadius: step.cornerRadius,
342
- borderWidth: step.borderWidth,
343
- borderColor: step.borderColor,
344
- padding: step.padding,
345
- },
346
- step.shadow && s.shadow,
347
- ]}
348
- >
349
- {showArrow && (
350
- <GuideArrow
351
- placement={resolvedPlacement}
352
- color={step.arrowColor ?? step.backgroundColor}
353
- borderColor={step.arrowBorderColor ?? step.borderColor}
354
- size={arrowSize}
355
- arrowOffset={arrowOffset}
356
- />
357
- )}
358
- <Text style={{ color: step.titleColor, fontSize: step.titleSize, fontWeight: step.titleWeight, fontFamily }}>
359
- {step.title}
360
- </Text>
361
- {!!step.body && (
362
- <Text style={{ marginTop: 4, color: step.bodyColor, fontSize: step.bodySize, fontFamily }}>
363
- {step.body}
364
- </Text>
365
- )}
366
- <View style={s.actionRow}>
367
- {step.actions.map((action, i) => (
368
- <ActionButton
369
- key={i}
370
- action={action}
371
- btnPrimaryBg={step.buttonPrimaryBackgroundColor}
372
- btnPrimaryText={step.buttonPrimaryTextColor}
373
- btnGhostText={step.buttonGhostTextColor}
374
- onPress={() => {
375
- const isMultiStep = config.steps.length > 1;
376
- const isLastStep = stepIndex === config.steps.length - 1;
377
- const actionUrl = 'url' in action ? (action as { url: string }).url : undefined;
378
- request.onExperienceEvent({ type: 'clicked', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip', ctaLabel: action.label, actionType: action.type, actionUrl });
379
- if (isMultiStep) {
380
- request.onExperienceEvent({ type: 'step_clicked', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip', ctaLabel: action.label, actionType: action.type, actionUrl });
381
- }
382
- if (isMultiStep && isLastStep && action.type !== 'back') {
383
- request.onExperienceEvent({ type: 'completed', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip' });
384
- }
385
- void digiaActionHandler.execute(action, {
386
- campaign_id: request.payloadId,
387
- campaign_key: request.campaignKey,
388
- campaign_type: 'guide',
389
- source: { kind: 'button', button_label: action.label },
390
- step_index: stepIndex,
391
- step_total: config.steps.length,
392
- }, actionCallbacks());
393
- }}
394
- />
395
- ))}
396
- </View>
397
- </Pressable>
398
- ) : (
399
- // Off-screen measurement pass to determine bubble size before positioning.
400
- <View
401
- pointerEvents="none"
402
- onLayout={(e) => setFloatingSize({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })}
403
- style={[s.tooltipBubble, { left: -9999, top: -9999, width: tooltipW, padding: step.padding }]}
404
- >
405
- <Text style={{ fontSize: step.titleSize, fontFamily }}>{step.title}</Text>
406
- {!!step.body && <Text style={{ fontSize: step.bodySize, fontFamily }}>{step.body}</Text>}
407
- <View style={s.actionRow}>
408
- {step.actions.map((a, i) => (
409
- <View key={i} style={s.button}>
410
- <Text style={{ fontSize: 13, fontFamily }}>{a.label}</Text>
342
+ <VariableContext.Provider value={request.variables}>
343
+ <Modal transparent statusBarTranslucent animationType="none" visible>
344
+ {/* pointerEvents="box-none": container passes touches through; only children intercept.
345
+ This prevents the invisible measurement pass from blocking the screen. */}
346
+ <Animated.View style={[StyleSheet.absoluteFill, { opacity: opacityAnim }]} pointerEvents="box-none">
347
+ {floatPos ? (
348
+ <>
349
+ {/* Full-screen backdrop: blocks all touches once bubble is positioned */}
350
+ <Pressable style={StyleSheet.absoluteFill} onPress={handleBackdropPress} />
351
+ {/* Bubble as Pressable so tapping the bubble body also advances */}
352
+ <Pressable
353
+ onLayout={(e) => {
354
+ const { width, height } = e.nativeEvent.layout;
355
+ if (floatingSize?.w !== width || floatingSize?.h !== height) {
356
+ setFloatingSize({ w: width, h: height });
357
+ }
358
+ }}
359
+ onPress={handleBackdropPress}
360
+ style={[
361
+ s.tooltipBubble,
362
+ {
363
+ left: floatPos.x,
364
+ top: floatPos.y,
365
+ width: tooltipW,
366
+ backgroundColor: step.backgroundColor,
367
+ borderRadius: step.cornerRadius,
368
+ borderWidth: step.borderWidth,
369
+ borderColor: step.borderColor,
370
+ padding: step.padding,
371
+ },
372
+ step.shadow && s.shadow,
373
+ ]}
374
+ >
375
+ {showArrow && (
376
+ <GuideArrow
377
+ placement={resolvedPlacement}
378
+ color={step.arrowColor ?? step.backgroundColor}
379
+ borderColor={step.arrowBorderColor ?? step.borderColor}
380
+ size={arrowSize}
381
+ arrowOffset={arrowOffset}
382
+ />
383
+ )}
384
+ <TextWithVariables style={{ color: step.titleColor, fontSize: step.titleSize, fontWeight: step.titleWeight, fontFamily }}>
385
+ {step.title}
386
+ </TextWithVariables>
387
+ {!!step.body && (
388
+ <TextWithVariables style={{ marginTop: 4, color: step.bodyColor, fontSize: step.bodySize, fontFamily }}>
389
+ {step.body}
390
+ </TextWithVariables>
391
+ )}
392
+ <View style={s.actionRow}>
393
+ {step.actions.map((action, i) => (
394
+ <ActionButton
395
+ key={i}
396
+ action={action}
397
+ btnPrimaryBg={step.buttonPrimaryBackgroundColor}
398
+ btnPrimaryText={step.buttonPrimaryTextColor}
399
+ btnGhostText={step.buttonGhostTextColor}
400
+ onPress={() => {
401
+ const isMultiStep = config.steps.length > 1;
402
+ const isLastStep = stepIndex === config.steps.length - 1;
403
+ const actionUrl = 'url' in action ? (action as { url: string }).url : undefined;
404
+ request.onExperienceEvent({ type: 'clicked', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip', ctaLabel: action.label, actionType: action.type, actionUrl });
405
+ if (isMultiStep) {
406
+ request.onExperienceEvent({ type: 'step_clicked', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip', ctaLabel: action.label, actionType: action.type, actionUrl });
407
+ }
408
+ if (isMultiStep && isLastStep && action.type !== 'back') {
409
+ request.onExperienceEvent({ type: 'completed', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip' });
410
+ }
411
+ void digiaActionHandler.execute(action, {
412
+ campaign_id: request.payloadId,
413
+ campaign_key: request.campaignKey,
414
+ campaign_type: 'guide',
415
+ source: { kind: 'button', button_label: action.label },
416
+ step_index: stepIndex,
417
+ step_total: config.steps.length,
418
+ }, actionCallbacks());
419
+ }}
420
+ />
421
+ ))}
411
422
  </View>
412
- ))}
423
+ </Pressable>
424
+ </>
425
+ ) : (
426
+ // Off-screen measurement pass to determine bubble size before positioning.
427
+ <View
428
+ pointerEvents="none"
429
+ onLayout={(e) => setFloatingSize({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })}
430
+ style={[s.tooltipBubble, { left: -9999, top: -9999, width: tooltipW, padding: step.padding }]}
431
+ >
432
+ <TextWithVariables style={{ fontSize: step.titleSize, fontFamily }}>{step.title}</TextWithVariables>
433
+ {!!step.body && <TextWithVariables style={{ fontSize: step.bodySize, fontFamily }}>{step.body}</TextWithVariables>}
434
+ <View style={s.actionRow}>
435
+ {step.actions.map((a, i) => (
436
+ <View key={i} style={s.button}>
437
+ <TextWithVariables style={{ fontSize: 13, fontFamily }}>{a.label}</TextWithVariables>
438
+ </View>
439
+ ))}
440
+ </View>
413
441
  </View>
414
- </View>
415
- )}
416
- </Animated.View>
417
- </Modal>
442
+ )}
443
+ </Animated.View>
444
+ </Modal>
445
+ </VariableContext.Provider>
418
446
  );
419
447
  }
420
448
 
@@ -505,12 +533,12 @@ function SpotlightCallout({
505
533
  onLayout={(e) => setFloatingSize({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })}
506
534
  style={[calloutStyle, { position: 'absolute', left: -9999, top: -9999 }]}
507
535
  >
508
- <Text style={{ fontSize: step.titleSize, fontFamily }}>{step.title}</Text>
509
- {!!step.body && <Text style={{ marginTop: 4, fontSize: step.bodySize, fontFamily }}>{step.body}</Text>}
536
+ <TextWithVariables style={{ fontSize: step.titleSize, fontFamily }}>{step.title}</TextWithVariables>
537
+ {!!step.body && <TextWithVariables style={{ marginTop: 4, fontSize: step.bodySize, fontFamily }}>{step.body}</TextWithVariables>}
510
538
  <View style={s.actionRow}>
511
539
  {step.actions.map((a, i) => (
512
540
  <View key={i} style={s.button}>
513
- <Text style={{ fontSize: 13, fontFamily }}>{a.label}</Text>
541
+ <TextWithVariables style={{ fontSize: 13, fontFamily }}>{a.label}</TextWithVariables>
514
542
  </View>
515
543
  ))}
516
544
  </View>
@@ -535,13 +563,13 @@ function SpotlightCallout({
535
563
  arrowOffset={arrowOffset}
536
564
  />
537
565
  )}
538
- <Text style={{ color: step.titleColor, fontSize: step.titleSize, fontWeight: step.titleWeight, fontFamily }}>
566
+ <TextWithVariables style={{ color: step.titleColor, fontSize: step.titleSize, fontWeight: step.titleWeight, fontFamily }}>
539
567
  {step.title}
540
- </Text>
568
+ </TextWithVariables>
541
569
  {!!step.body && (
542
- <Text style={{ marginTop: 4, color: step.bodyColor, fontSize: step.bodySize, fontFamily }}>
570
+ <TextWithVariables style={{ marginTop: 4, color: step.bodyColor, fontSize: step.bodySize, fontFamily }}>
543
571
  {step.body}
544
- </Text>
572
+ </TextWithVariables>
545
573
  )}
546
574
  <View style={s.actionRow}>
547
575
  {step.actions.map((action, i) => (
@@ -575,10 +603,20 @@ function SpotlightOverlay({
575
603
 
576
604
  useEffect(() => {
577
605
  setLayout(null);
578
- return digiaAnchorRegistry.subscribe(step.anchorKey, (l) => {
606
+ let skipCached = false;
607
+ const unsub = digiaAnchorRegistry.subscribe(step.anchorKey, (l) => {
608
+ if (!skipCached) return;
609
+ const { width: screenW, height: screenH } = Dimensions.get('window');
610
+ if (l.pageY + l.height <= 0 || l.pageY >= screenH || l.pageX + l.width <= 0 || l.pageX >= screenW) {
611
+ digiaGuideController.cancel(request.payloadId);
612
+ return;
613
+ }
579
614
  setLayout(l);
580
615
  });
581
- }, [step.anchorKey]);
616
+ skipCached = true;
617
+ digiaAnchorRegistry.remeasure(step.anchorKey);
618
+ return unsub;
619
+ }, [step.anchorKey]); // eslint-disable-line react-hooks/exhaustive-deps
582
620
 
583
621
  useEffect(() => {
584
622
  if (layout && pendingFadeIn.current) {
@@ -676,42 +714,44 @@ function SpotlightOverlay({
676
714
  : '';
677
715
 
678
716
  return (
679
- <Modal transparent statusBarTranslucent animationType="none" visible>
680
- <Animated.View style={[StyleSheet.absoluteFill, { opacity: opacityAnim }]} pointerEvents="box-none">
681
- {layout && (
682
- <>
683
- <Svg
684
- width={screenW}
685
- height={screenH}
686
- style={StyleSheet.absoluteFill}
687
- pointerEvents="none"
688
- >
689
- <Path
690
- fillRule="evenodd"
691
- d={`${screenPath} ${cutoutPath}`}
692
- fill={step.overlayColor}
693
- fillOpacity={step.overlayOpacity}
694
- />
695
- {step.highlightGlowWidth > 0 && (
717
+ <VariableContext.Provider value={request.variables}>
718
+ <Modal transparent statusBarTranslucent animationType="none" visible>
719
+ <Animated.View style={[StyleSheet.absoluteFill, { opacity: opacityAnim }]} pointerEvents="box-none">
720
+ {layout && (
721
+ <>
722
+ <Svg
723
+ width={screenW}
724
+ height={screenH}
725
+ style={StyleSheet.absoluteFill}
726
+ pointerEvents="none"
727
+ >
696
728
  <Path
697
- d={cutoutPath}
698
- fill="none"
699
- stroke={step.highlightGlowColor}
700
- strokeWidth={step.highlightGlowWidth}
729
+ fillRule="evenodd"
730
+ d={`${screenPath} ${cutoutPath}`}
731
+ fill={step.overlayColor}
732
+ fillOpacity={step.overlayOpacity}
701
733
  />
702
- )}
703
- </Svg>
704
- {/* Backdrop with configurable outside-tap behaviour */}
705
- <Pressable style={StyleSheet.absoluteFill} onPress={handleBackdropPress} />
706
- <SpotlightCallout
707
- step={step}
708
- layout={layout}
709
- onActionPress={handleActionPress}
710
- />
711
- </>
712
- )}
713
- </Animated.View>
714
- </Modal>
734
+ {step.highlightGlowWidth > 0 && (
735
+ <Path
736
+ d={cutoutPath}
737
+ fill="none"
738
+ stroke={step.highlightGlowColor}
739
+ strokeWidth={step.highlightGlowWidth}
740
+ />
741
+ )}
742
+ </Svg>
743
+ {/* Backdrop with configurable outside-tap behaviour */}
744
+ <Pressable style={StyleSheet.absoluteFill} onPress={handleBackdropPress} />
745
+ <SpotlightCallout
746
+ step={step}
747
+ layout={layout}
748
+ onActionPress={handleActionPress}
749
+ />
750
+ </>
751
+ )}
752
+ </Animated.View>
753
+ </Modal>
754
+ </VariableContext.Provider>
715
755
  );
716
756
  }
717
757
 
@@ -773,4 +813,4 @@ const s = StyleSheet.create({
773
813
  borderRadius: 8,
774
814
  paddingHorizontal: 12,
775
815
  },
776
- });
816
+ });
@@ -1,8 +1,10 @@
1
1
  type AnchorLayout = { pageX: number; pageY: number; width: number; height: number }
2
2
  type Listener = (layout: AnchorLayout) => void
3
+ type MeasureCallback = () => void
3
4
 
4
5
  const _layouts = new Map<string, AnchorLayout>()
5
6
  const _listeners = new Map<string, Listener>()
7
+ const _measureCallbacks = new Map<string, MeasureCallback>()
6
8
 
7
9
  const setLayout = (key: string, layout: AnchorLayout) => {
8
10
  _layouts.set(key, layout)
@@ -21,7 +23,20 @@ const subscribe = (key: string, listener: Listener): () => void => {
21
23
  const remove = (key: string) => {
22
24
  _layouts.delete(key)
23
25
  _listeners.delete(key)
26
+ _measureCallbacks.delete(key)
27
+ }
28
+
29
+ const registerMeasure = (key: string, cb: MeasureCallback) => {
30
+ _measureCallbacks.set(key, cb)
31
+ }
32
+
33
+ const unregisterMeasure = (key: string, cb: MeasureCallback) => {
34
+ if (_measureCallbacks.get(key) === cb) _measureCallbacks.delete(key)
35
+ }
36
+
37
+ const remeasure = (key: string) => {
38
+ _measureCallbacks.get(key)?.()
24
39
  }
25
40
 
26
41
  export type { AnchorLayout }
27
- export const digiaAnchorRegistry = { setLayout, getLayout, subscribe, remove }
42
+ export const digiaAnchorRegistry = { setLayout, getLayout, subscribe, remove, registerMeasure, unregisterMeasure, remeasure }
@@ -0,0 +1,44 @@
1
+ export type VariableMap = Record<string, string>;
2
+
3
+ const PLACEHOLDER_PATTERN = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
4
+
5
+ export function interpolateVariables(
6
+ text: string,
7
+ variables: VariableMap | undefined,
8
+ ): string {
9
+ if (!variables) return text;
10
+ return text.replace(PLACEHOLDER_PATTERN, (match, name: string) => {
11
+ const value = variables[name];
12
+ return value === undefined ? '' : value;
13
+ });
14
+ }
15
+
16
+ export function parseVariableMap(raw: unknown): VariableMap | undefined {
17
+ const source = parseVariableSource(raw);
18
+ if (!source) return undefined;
19
+
20
+ const variables: VariableMap = {};
21
+ for (const [key, value] of Object.entries(source)) {
22
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
23
+ if (typeof value === 'string') variables[key] = value;
24
+ else if (typeof value === 'number' || typeof value === 'boolean') variables[key] = String(value);
25
+ }
26
+ return Object.keys(variables).length > 0 ? variables : undefined;
27
+ }
28
+
29
+ function parseVariableSource(raw: unknown): Record<string, unknown> | undefined {
30
+ if (!raw) return undefined;
31
+ if (typeof raw === 'string') {
32
+ try {
33
+ const parsed = JSON.parse(raw);
34
+ return isPlainObject(parsed) ? parsed : undefined;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ return isPlainObject(raw) ? raw : undefined;
40
+ }
41
+
42
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
43
+ return !!value && typeof value === 'object' && !Array.isArray(value);
44
+ }