@edwinvakayil/calligraphy 1.4.0 → 1.5.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/dist/index.js CHANGED
@@ -530,6 +530,17 @@ function wrapSplitRise(html) {
530
530
  function wrapThread(html) {
531
531
  return wrapChars(html, "rts-thread-ch", 0.04);
532
532
  }
533
+ /**
534
+ * Called by Typography after dangerouslySetInnerHTML for "thread" animation.
535
+ * Stamps the sine-wave Y offset as --ty on each character span.
536
+ */
537
+ function applyThreadOffsets(container) {
538
+ const spans = container.querySelectorAll(".rts-thread-ch");
539
+ spans.forEach((el, i) => {
540
+ const offset = Math.sin(i * 0.85) * 22;
541
+ el.style.setProperty("--ty", `${offset.toFixed(1)}px`);
542
+ });
543
+ }
533
544
  // ─── Public API ───────────────────────────────────────────────────────────────
534
545
  function buildSplitHTML(animation, html) {
535
546
  switch (animation) {
@@ -570,6 +581,88 @@ function buildSplitHTML(animation, html) {
570
581
  default: return html;
571
582
  }
572
583
  }
584
+ // ─── Custom motion builder ────────────────────────────────────────────────────
585
+ /**
586
+ * Injects a one-off @keyframes rule with a generated name and returns that name.
587
+ * Deduped by a hash of the keyframe body so the same keyframe is only injected once.
588
+ */
589
+ function injectCustomKeyframes(keyframeBody) {
590
+ const hash = keyframeBody
591
+ .split("")
592
+ .reduce((acc, ch) => (Math.imul(31, acc) + ch.charCodeAt(0)) | 0, 0)
593
+ .toString(36)
594
+ .replace("-", "n");
595
+ const name = `rts-custom-${hash}`;
596
+ if (typeof document === "undefined")
597
+ return name;
598
+ if (document.getElementById(name))
599
+ return name;
600
+ const style = document.createElement("style");
601
+ style.id = name;
602
+ style.textContent = `@keyframes ${name} { ${keyframeBody} }`;
603
+ document.head.appendChild(style);
604
+ return name;
605
+ }
606
+ /**
607
+ * Builds the innerHTML for a custom motionConfig.
608
+ * - split "none" → returns the raw html unchanged; caller applies class to element
609
+ * - split "words" → wraps each word in a span with the animation + stagger delay
610
+ * - split "chars" → wraps each character in a span with the animation + stagger delay
611
+ *
612
+ * Returns { html, animationValue } where animationValue is the full CSS animation
613
+ * shorthand to set on each span (or on the element itself when split is "none").
614
+ */
615
+ function buildCustomHTML(html, keyframeName, duration, easing, delay, fillMode, split, staggerDelay) {
616
+ var _a;
617
+ const baseAnimation = `${keyframeName} ${duration} ${easing} ${delay} ${fillMode}`;
618
+ if (split === "none") {
619
+ return { html, baseAnimation };
620
+ }
621
+ if (split === "words") {
622
+ const tokens = (_a = html.match(/(<em>[\s\S]*?<\/em>|[^\s]+)/g)) !== null && _a !== void 0 ? _a : [];
623
+ const result = tokens.map((tok, i) => {
624
+ const totalDelay = i === 0
625
+ ? delay
626
+ : `${(parseFloat(delay) + i * staggerDelay).toFixed(3)}s`;
627
+ const anim = `${keyframeName} ${duration} ${easing} ${totalDelay} ${fillMode}`;
628
+ if (tok.startsWith("<em>")) {
629
+ return `<em><span style="display:inline-block;animation:${anim}">${tok.slice(4, -5)}</span></em>`;
630
+ }
631
+ return `<span style="display:inline-block;animation:${anim}">${tok}</span>`;
632
+ });
633
+ return { html: result.join(" "), baseAnimation };
634
+ }
635
+ // chars
636
+ const result = [];
637
+ let inEm = false, charIndex = 0, i = 0;
638
+ while (i < html.length) {
639
+ if (html.startsWith("<em>", i)) {
640
+ inEm = true;
641
+ i += 4;
642
+ continue;
643
+ }
644
+ if (html.startsWith("</em>", i)) {
645
+ inEm = false;
646
+ i += 5;
647
+ continue;
648
+ }
649
+ const ch = html[i];
650
+ if (ch === " ") {
651
+ result.push(" ");
652
+ i++;
653
+ continue;
654
+ }
655
+ const totalDelay = charIndex === 0
656
+ ? delay
657
+ : `${(parseFloat(delay) + charIndex * staggerDelay).toFixed(3)}s`;
658
+ const anim = `${keyframeName} ${duration} ${easing} ${totalDelay} ${fillMode}`;
659
+ const span = `<span style="display:inline-block;animation:${anim}">${ch}</span>`;
660
+ result.push(inEm ? `<em>${span}</em>` : span);
661
+ charIndex++;
662
+ i++;
663
+ }
664
+ return { html: result.join(""), baseAnimation };
665
+ }
573
666
 
574
667
  // ─── Defaults ─────────────────────────────────────────────────────────────────
575
668
  const DEFAULT_THEME = {
@@ -607,7 +700,7 @@ function useTypographyTheme() {
607
700
  return react.useContext(TypographyContext);
608
701
  }
609
702
 
610
- // ─── Static maps ─────────────────────────────────────────────────────────────
703
+ // ─── Variant HTML tag map ───────────────────────────────────────────────────
611
704
  const variantTagMap = {
612
705
  Display: "h1",
613
706
  H1: "h1",
@@ -622,6 +715,7 @@ const variantTagMap = {
622
715
  Label: "label",
623
716
  Caption: "span",
624
717
  };
718
+ // ─── Variant → base CSS styles ────────────────────────────────────────────────
625
719
  const variantStyleMap = {
626
720
  Display: {
627
721
  fontSize: "clamp(2.5rem, 6vw, 5rem)",
@@ -697,9 +791,15 @@ const variantStyleMap = {
697
791
  letterSpacing: "0.03em",
698
792
  },
699
793
  };
700
- // ─── Constants ───────────────────────────────────────────────────────────────
794
+ // ─── Constants ────────────────────────────────────────────────────────────────
795
+ // Always pre-loaded for hero variants so toggling italic is instant with no FOUC
701
796
  const INSTRUMENT_SERIF_URL = "https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap";
702
- // ─── Helpers ─────────────────────────────────────────────────────────────────
797
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
798
+ /**
799
+ * Serialise React children to a raw HTML string.
800
+ * Handles plain strings and <em>text</em> elements.
801
+ * Used to feed text into the animation split-builders.
802
+ */
703
803
  function childrenToHTML(children) {
704
804
  var _a, _b;
705
805
  return ((_b = (_a = react.Children.map(children, (child) => {
@@ -707,12 +807,19 @@ function childrenToHTML(children) {
707
807
  return String(child);
708
808
  }
709
809
  if (react.isValidElement(child) && child.type === "em") {
710
- const inner = typeof child.props.children === "string" ? child.props.children : "";
810
+ const inner = typeof child.props.children === "string"
811
+ ? child.props.children
812
+ : "";
711
813
  return `<em>${inner}</em>`;
712
814
  }
713
815
  return "";
714
816
  })) === null || _a === void 0 ? void 0 : _a.join("")) !== null && _b !== void 0 ? _b : "");
715
817
  }
818
+ /**
819
+ * Standard (no-animation) render path.
820
+ * Clones <em> children with explicit inline styles so the font switch is
821
+ * guaranteed — parent fontFamily cannot override a child's own inline style.
822
+ */
716
823
  function renderChildrenWithEmStyles(children, italic, accentColor, headingFont) {
717
824
  const italicStyle = {
718
825
  fontFamily: "'Instrument Serif', serif",
@@ -733,6 +840,11 @@ function renderChildrenWithEmStyles(children, italic, accentColor, headingFont)
733
840
  return child;
734
841
  });
735
842
  }
843
+ /**
844
+ * Animation (dangerouslySetInnerHTML) render path.
845
+ * After the DOM is written we walk it and stamp inline styles onto every
846
+ * <em> and <em > span — guaranteed to beat any inherited fontFamily.
847
+ */
736
848
  function applyEmStylesDOM(container, italic, accentColor, headingFont) {
737
849
  const apply = (el) => {
738
850
  if (italic) {
@@ -742,7 +854,9 @@ function applyEmStylesDOM(container, italic, accentColor, headingFont) {
742
854
  el.style.color = accentColor;
743
855
  }
744
856
  else {
745
- el.style.fontFamily = headingFont ? `'${headingFont}', sans-serif` : "inherit";
857
+ el.style.fontFamily = headingFont
858
+ ? `'${headingFont}', sans-serif`
859
+ : "inherit";
746
860
  el.style.fontStyle = "normal";
747
861
  el.style.fontWeight = "inherit";
748
862
  el.style.color = "inherit";
@@ -751,61 +865,98 @@ function applyEmStylesDOM(container, italic, accentColor, headingFont) {
751
865
  container.querySelectorAll("em").forEach(apply);
752
866
  container.querySelectorAll("em > span").forEach(apply);
753
867
  }
754
- // ─── Component ───────────────────────────────────────────────────────────────
868
+ // ─── Component ────────────────────────────────────────────────────────────────
755
869
  const Typography = (_a) => {
756
- var _b;
757
- var { variant = "Body", font: fontProp, color: colorProp, animation: animationProp, italic: italicProp, accentColor: accentColorProp, align, className, style, children, as, truncate, maxLines } = _a, rest = __rest(_a, ["variant", "font", "color", "animation", "italic", "accentColor", "align", "className", "style", "children", "as", "truncate", "maxLines"]);
870
+ var _b, _c, _d, _e, _f, _g, _h, _j, _k;
871
+ var { variant = "Body", font: fontProp, color: colorProp, animation: animationProp, motionConfig, motionRef, italic: italicProp, accentColor: accentColorProp, align, className, style, children, as, truncate, maxLines } = _a, rest = __rest(_a, ["variant", "font", "color", "animation", "motionConfig", "motionRef", "italic", "accentColor", "align", "className", "style", "children", "as", "truncate", "maxLines"]);
758
872
  const theme = useTypographyTheme();
759
873
  const isHero = variant === "Display" || variant === "H1";
760
874
  const ref = react.useRef(null);
761
- // Prop wins; fall back to theme; fall back to built-in default
875
+ // Prop wins theme built-in default
762
876
  const font = fontProp !== null && fontProp !== void 0 ? fontProp : (theme.font || undefined);
763
877
  const color = colorProp !== null && colorProp !== void 0 ? colorProp : (theme.color || undefined);
764
- const animation = isHero ? ((_b = animationProp !== null && animationProp !== void 0 ? animationProp : theme.animation) !== null && _b !== void 0 ? _b : undefined) : undefined;
765
- const italic = italicProp !== null && italicProp !== void 0 ? italicProp : theme.italic;
766
- const accentColor = accentColorProp !== null && accentColorProp !== void 0 ? accentColorProp : theme.accentColor;
767
- // ── useInsertionEffect: inject <link> and <style> tags ────────────────────
768
- //
769
- // WHY useInsertionEffect instead of plain render-phase calls:
770
- //
771
- // 1. Server safety — useInsertionEffect (like all effects) is never called
772
- // on the server, so document.createElement / document.head never run
773
- // during SSR. The isBrowser guard in ssr.ts is a belt-and-suspenders
774
- // backup, but the effect boundary is the real guarantee.
775
- //
776
- // 2. Correctness — React 18 concurrent mode can call the render function
777
- // multiple times before committing. Doing DOM work in render can fire
778
- // those side-effects redundantly or out of order. useInsertionEffect
779
- // fires synchronously before the browser paints, once per commit.
780
- //
781
- // 3. No FOUC — because it fires before paint (earlier than useLayoutEffect),
782
- // the <style> tag is in the DOM before any text is visible, so there is
783
- // no flash of unstyled / wrong-font text.
878
+ const animation = isHero
879
+ ? ((_b = animationProp !== null && animationProp !== void 0 ? animationProp : theme.animation) !== null && _b !== void 0 ? _b : undefined)
880
+ : undefined;
881
+ const italic = (_c = italicProp !== null && italicProp !== void 0 ? italicProp : theme.italic) !== null && _c !== void 0 ? _c : false;
882
+ const accentColor = (_d = accentColorProp !== null && accentColorProp !== void 0 ? accentColorProp : theme.accentColor) !== null && _d !== void 0 ? _d : "#c8b89a";
883
+ // ── Font & style injection ─────────────────────────────────────────────────
784
884
  react.useInsertionEffect(() => {
785
- // Instrument Serif — always pre-load for hero so toggling italic is instant
786
885
  if (isHero) {
787
886
  injectFont(INSTRUMENT_SERIF_URL);
788
887
  }
789
- // Heading Google Font
790
888
  if (font && GOOGLE_FONTS.includes(font)) {
791
889
  injectFont(buildFontUrl(font));
792
890
  }
793
- // Animation keyframe stylesheet
794
891
  if (animation && isHero) {
795
892
  injectAnimationStyles();
796
893
  }
797
- }, [isHero, font, animation]);
798
- // ── useEffect: re-stamp inline styles on <em> after DOM updates ───────────
894
+ // Inject custom keyframes as soon as the prop arrives
895
+ if (motionConfig === null || motionConfig === void 0 ? void 0 : motionConfig.keyframes) {
896
+ injectCustomKeyframes(motionConfig.keyframes);
897
+ }
898
+ }, [isHero, font, animation, motionConfig === null || motionConfig === void 0 ? void 0 : motionConfig.keyframes]);
899
+ // ── Re-stamp <em> inline styles after every relevant change ───────────────
799
900
  react.useEffect(() => {
800
901
  if (!isHero || !animation || !ref.current)
801
902
  return;
802
903
  applyEmStylesDOM(ref.current, italic, accentColor, font);
904
+ if (animation === "thread")
905
+ applyThreadOffsets(ref.current);
803
906
  }, [italic, accentColor, font, animation, isHero]);
907
+ // ── motionRef callback — fires after mount and on every re-render ──────────
908
+ // motionRef wins over animation and motionConfig — the user drives the DOM.
909
+ react.useEffect(() => {
910
+ if (!motionRef)
911
+ return;
912
+ motionRef(ref.current);
913
+ });
914
+ // ── Strip lingering CSS properties after animation ends ───────────────────
915
+ react.useEffect(() => {
916
+ const el = ref.current;
917
+ if (!el || !animation)
918
+ return;
919
+ const cleanup = () => {
920
+ if (animation === "maskSweep") {
921
+ el.style.setProperty("mask-image", "none");
922
+ el.style.setProperty("-webkit-mask-image", "none");
923
+ }
924
+ if (animation === "gradSweep") {
925
+ el.style.animation = "none";
926
+ }
927
+ };
928
+ el.addEventListener("animationend", cleanup, { once: true });
929
+ return () => el.removeEventListener("animationend", cleanup);
930
+ }, [animation]);
804
931
  const Tag = (as !== null && as !== void 0 ? as : variantTagMap[variant]);
805
- // ── Animation path: build inner HTML ─────────────────────────────────────
932
+ // ── Build inner HTML — priority: motionRef > motionConfig > animation ──────
806
933
  let animClass = "";
807
934
  let heroHTML = null;
808
- if (animation && isHero) {
935
+ let customAnimStyle;
936
+ // motionRef — no HTML manipulation needed, user handles everything via ref
937
+ if (motionRef) ;
938
+ // motionConfig — works on any variant, not just heroes
939
+ else if (motionConfig === null || motionConfig === void 0 ? void 0 : motionConfig.keyframes) {
940
+ const keyframeName = injectCustomKeyframes(motionConfig.keyframes);
941
+ const duration = (_e = motionConfig.duration) !== null && _e !== void 0 ? _e : "0.8s";
942
+ const easing = (_f = motionConfig.easing) !== null && _f !== void 0 ? _f : "cubic-bezier(0.16,1,0.3,1)";
943
+ const delay = (_g = motionConfig.delay) !== null && _g !== void 0 ? _g : "0s";
944
+ const fillMode = (_h = motionConfig.fillMode) !== null && _h !== void 0 ? _h : "both";
945
+ const split = (_j = motionConfig.split) !== null && _j !== void 0 ? _j : "none";
946
+ const staggerDelay = (_k = motionConfig.staggerDelay) !== null && _k !== void 0 ? _k : (split === "chars" ? 0.04 : 0.07);
947
+ const rawHTML = childrenToHTML(children);
948
+ const { html, baseAnimation } = buildCustomHTML(rawHTML, keyframeName, duration, easing, delay, fillMode, split, staggerDelay);
949
+ if (split === "none") {
950
+ // Apply animation directly on the element via inline style
951
+ customAnimStyle = baseAnimation;
952
+ heroHTML = rawHTML;
953
+ }
954
+ else {
955
+ heroHTML = html;
956
+ }
957
+ }
958
+ // Built-in animation preset
959
+ else if (animation && isHero) {
809
960
  const rawHTML = childrenToHTML(children);
810
961
  if (isSplitAnimation(animation)) {
811
962
  heroHTML = buildSplitHTML(animation, rawHTML);
@@ -816,8 +967,12 @@ const Typography = (_a) => {
816
967
  }
817
968
  }
818
969
  // ── Computed container styles ─────────────────────────────────────────────
819
- const computedStyle = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, variantStyleMap[variant]), (font ? { fontFamily: `'${font}', sans-serif` } : {})), (color ? { color } : {})), (align ? { textAlign: align } : {})), (truncate
820
- ? { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }
970
+ const computedStyle = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, variantStyleMap[variant]), (font ? { fontFamily: `'${font}', sans-serif` } : {})), (color ? { color } : {})), (align ? { textAlign: align } : {})), (truncate
971
+ ? {
972
+ overflow: "hidden",
973
+ textOverflow: "ellipsis",
974
+ whiteSpace: "nowrap",
975
+ }
821
976
  : {})), (maxLines && !truncate
822
977
  ? {
823
978
  display: "-webkit-box",
@@ -825,12 +980,16 @@ const Typography = (_a) => {
825
980
  WebkitBoxOrient: "vertical",
826
981
  overflow: "hidden",
827
982
  }
828
- : {})), { margin: 0, padding: 0 }), style);
829
- // ── Render: animation path (dangerouslySetInnerHTML) ──────────────────────
983
+ : {})), (customAnimStyle ? { animation: customAnimStyle } : {})), { margin: 0, padding: 0 }), style);
984
+ // ── Render: animation path ────────────────────────────────────────────────
985
+ //
986
+ // key={animation} forces React to unmount + remount the element when the
987
+ // animation value changes, guaranteeing the CSS keyframe fires from frame 0
988
+ // on every switch.
830
989
  if (heroHTML !== null) {
831
990
  return (jsxRuntime.jsx(Tag, Object.assign({ ref: ref, className: [animClass, className].filter(Boolean).join(" "), style: computedStyle, dangerouslySetInnerHTML: { __html: heroHTML } }, rest), animation));
832
991
  }
833
- // ── Render: standard path ────────────────────────────────────────────────
992
+ // ── Render: standard path ─────────────────────────────────────────────────
834
993
  const processedChildren = isHero
835
994
  ? renderChildrenWithEmStyles(children, italic, accentColor, font)
836
995
  : children;
@@ -844,4 +1003,5 @@ exports.buildFontUrl = buildFontUrl;
844
1003
  exports.default = Typography;
845
1004
  exports.injectFont = injectFont;
846
1005
  exports.preloadFonts = preloadFonts;
1006
+ exports.useTypographyTheme = useTypographyTheme;
847
1007
  //# sourceMappingURL=index.js.map