@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/README.md +351 -130
- package/dist/animation.d.ts +19 -0
- package/dist/index.d.ts +104 -3
- package/dist/index.esm.js +201 -42
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +201 -41
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +98 -2
- package/package.json +1 -1
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
|
-
// ───
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
//
|
|
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
|
-
|
|
798
|
-
|
|
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
|
-
// ──
|
|
932
|
+
// ── Build inner HTML — priority: motionRef > motionConfig > animation ──────
|
|
806
933
|
let animClass = "";
|
|
807
934
|
let heroHTML = null;
|
|
808
|
-
|
|
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
|
-
? {
|
|
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
|
|
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
|