@morphika/webframe 0.1.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/LICENSE +21 -0
- package/README.md +46 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +210 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TypewriterRichText — Single-phase typewriter that preserves rich text formatting.
|
|
5
|
+
*
|
|
6
|
+
* Walks the Portable Text AST directly and wraps each character (or word) in a
|
|
7
|
+
* <span> with a staggered opacity transition. The character spans live INSIDE
|
|
8
|
+
* the format wrappers (<strong>, <em>, <a>, etc.), so bold/italic/links are
|
|
9
|
+
* visible throughout the entire animation. No dual render, no swap, no overlay.
|
|
10
|
+
*
|
|
11
|
+
* Cursor tracking: The blinking cursor is positioned absolutely and follows the
|
|
12
|
+
* animation progress via a JS interval that matches the CSS transition timing.
|
|
13
|
+
* On each tick it reads the bounding rect of the current character span and
|
|
14
|
+
* repositions the cursor — zero React re-renders, pure DOM manipulation.
|
|
15
|
+
*
|
|
16
|
+
* Session 133 — Replaces the dual-phase TypewriterWrapper approach.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useMemo, useRef, useEffect, useState, type CSSProperties } from "react";
|
|
20
|
+
import type { TypewriterConfig } from "../../lib/animation/enter-types";
|
|
21
|
+
import { TYPEWRITER_DEFAULTS } from "../../lib/animation/enter-types";
|
|
22
|
+
import type { PortableTextContent, PortableTextBlock } from "../../lib/sanity/types";
|
|
23
|
+
|
|
24
|
+
// ── Types ──
|
|
25
|
+
|
|
26
|
+
interface TypewriterRichTextProps {
|
|
27
|
+
/** The Portable Text content from the text block */
|
|
28
|
+
portableText: PortableTextContent | undefined;
|
|
29
|
+
/** Typewriter configuration (speed, mode) */
|
|
30
|
+
config?: TypewriterConfig;
|
|
31
|
+
/** Whether the animation has been triggered (IntersectionObserver) */
|
|
32
|
+
entered: boolean;
|
|
33
|
+
/** Animation delay before first character (ms) */
|
|
34
|
+
startDelay?: number;
|
|
35
|
+
/** Show blinking cursor (default: true) */
|
|
36
|
+
showCursor?: boolean;
|
|
37
|
+
/** CSS class for the outer wrapper (text styling) */
|
|
38
|
+
className?: string;
|
|
39
|
+
/** Inline styles for the outer wrapper (text styling) */
|
|
40
|
+
style?: CSSProperties;
|
|
41
|
+
/** Callback when animation completes (after cursor pause) */
|
|
42
|
+
onComplete?: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Mark resolution ──
|
|
46
|
+
|
|
47
|
+
/** Resolve markDefs from a block for link marks */
|
|
48
|
+
function getMarkDef(block: PortableTextBlock, markKey: string) {
|
|
49
|
+
return block.markDefs?.find((d) => d._key === markKey);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wrap content in the appropriate mark elements.
|
|
54
|
+
* Handles nested marks by recursively wrapping.
|
|
55
|
+
*/
|
|
56
|
+
function wrapInMarks(
|
|
57
|
+
content: React.ReactNode,
|
|
58
|
+
marks: string[] | undefined,
|
|
59
|
+
block: PortableTextBlock,
|
|
60
|
+
): React.ReactNode {
|
|
61
|
+
if (!marks || marks.length === 0) return content;
|
|
62
|
+
|
|
63
|
+
let result = content;
|
|
64
|
+
for (const mark of marks) {
|
|
65
|
+
switch (mark) {
|
|
66
|
+
case "strong":
|
|
67
|
+
result = <strong>{result}</strong>;
|
|
68
|
+
break;
|
|
69
|
+
case "em":
|
|
70
|
+
result = <em>{result}</em>;
|
|
71
|
+
break;
|
|
72
|
+
case "underline":
|
|
73
|
+
result = <span style={{ textDecoration: "underline" }}>{result}</span>;
|
|
74
|
+
break;
|
|
75
|
+
case "strike-through":
|
|
76
|
+
case "strikethrough":
|
|
77
|
+
result = <span style={{ textDecoration: "line-through" }}>{result}</span>;
|
|
78
|
+
break;
|
|
79
|
+
case "code":
|
|
80
|
+
result = <code>{result}</code>;
|
|
81
|
+
break;
|
|
82
|
+
default: {
|
|
83
|
+
// Check if it's a link (markDef reference)
|
|
84
|
+
const def = getMarkDef(block, mark);
|
|
85
|
+
if (def && def._type === "link" && def.href) {
|
|
86
|
+
result = (
|
|
87
|
+
<a
|
|
88
|
+
href={def.href as string}
|
|
89
|
+
target={(def.blank as boolean) ? "_blank" : undefined}
|
|
90
|
+
rel={(def.blank as boolean) ? "noopener noreferrer" : undefined}
|
|
91
|
+
>
|
|
92
|
+
{result}
|
|
93
|
+
</a>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Character splitting with mark groups ──
|
|
104
|
+
|
|
105
|
+
interface CharUnit {
|
|
106
|
+
/** The character or word to display */
|
|
107
|
+
text: string;
|
|
108
|
+
/** Marks to apply (bold, italic, link keys, etc.) */
|
|
109
|
+
marks: string[] | undefined;
|
|
110
|
+
/** Parent block (for markDefs lookup) */
|
|
111
|
+
block: PortableTextBlock;
|
|
112
|
+
/** Whether this is a space character (for whiteSpace: pre) */
|
|
113
|
+
isSpace: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Flatten Portable Text blocks into a sequence of CharUnits.
|
|
118
|
+
* Each unit carries its marks so we can group consecutive same-mark
|
|
119
|
+
* characters under a single mark wrapper.
|
|
120
|
+
*/
|
|
121
|
+
function flattenToUnits(
|
|
122
|
+
blocks: PortableTextContent,
|
|
123
|
+
mode: "character" | "word",
|
|
124
|
+
): CharUnit[] {
|
|
125
|
+
const units: CharUnit[] = [];
|
|
126
|
+
|
|
127
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
128
|
+
const block = blocks[bi];
|
|
129
|
+
if (!block.children) continue;
|
|
130
|
+
|
|
131
|
+
// Add a newline separator between blocks (except before first)
|
|
132
|
+
if (bi > 0 && units.length > 0) {
|
|
133
|
+
units.push({ text: "\n", marks: undefined, block, isSpace: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const child of block.children) {
|
|
137
|
+
if (child._type !== "span" || !child.text) continue;
|
|
138
|
+
|
|
139
|
+
if (mode === "word") {
|
|
140
|
+
const wordUnits = child.text.split(/(\s+)/).filter(Boolean);
|
|
141
|
+
for (const wu of wordUnits) {
|
|
142
|
+
units.push({
|
|
143
|
+
text: wu,
|
|
144
|
+
marks: child.marks,
|
|
145
|
+
block,
|
|
146
|
+
isSpace: /^\s+$/.test(wu),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
for (const char of child.text) {
|
|
151
|
+
units.push({
|
|
152
|
+
text: char,
|
|
153
|
+
marks: child.marks,
|
|
154
|
+
block,
|
|
155
|
+
isSpace: char === " ",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return units;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Mark grouping for efficient DOM ──
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Groups consecutive CharUnits with identical marks under the same wrapper.
|
|
169
|
+
* This prevents <strong>H</strong><strong>e</strong><strong>l</strong>...
|
|
170
|
+
* and instead produces <strong><span>H</span><span>e</span><span>l</span>...</strong>
|
|
171
|
+
*/
|
|
172
|
+
interface MarkGroup {
|
|
173
|
+
marks: string[] | undefined;
|
|
174
|
+
block: PortableTextBlock;
|
|
175
|
+
units: { text: string; isSpace: boolean; globalIndex: number }[];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function groupByMarks(flatUnits: CharUnit[]): MarkGroup[] {
|
|
179
|
+
const groups: MarkGroup[] = [];
|
|
180
|
+
let current: MarkGroup | null = null;
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < flatUnits.length; i++) {
|
|
183
|
+
const unit = flatUnits[i];
|
|
184
|
+
const marksKey = unit.marks?.join(",") ?? "";
|
|
185
|
+
const currentKey = current?.marks?.join(",") ?? "";
|
|
186
|
+
const sameBlock = current?.block === unit.block;
|
|
187
|
+
// Never merge into a newline group, and never merge newlines into a text group
|
|
188
|
+
const currentIsNewline = current?.units[0]?.text === "\n";
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
current &&
|
|
192
|
+
!currentIsNewline &&
|
|
193
|
+
marksKey === currentKey &&
|
|
194
|
+
sameBlock &&
|
|
195
|
+
unit.text !== "\n"
|
|
196
|
+
) {
|
|
197
|
+
current.units.push({ text: unit.text, isSpace: unit.isSpace, globalIndex: i });
|
|
198
|
+
} else {
|
|
199
|
+
current = {
|
|
200
|
+
marks: unit.marks,
|
|
201
|
+
block: unit.block,
|
|
202
|
+
units: [{ text: unit.text, isSpace: unit.isSpace, globalIndex: i }],
|
|
203
|
+
};
|
|
204
|
+
groups.push(current);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return groups;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Block-level grouping for paragraphs ──
|
|
212
|
+
|
|
213
|
+
interface BlockGroup {
|
|
214
|
+
blockKey: string;
|
|
215
|
+
blockStyle: string;
|
|
216
|
+
markGroups: MarkGroup[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function groupByBlocks(
|
|
220
|
+
blocks: PortableTextContent,
|
|
221
|
+
allGroups: MarkGroup[],
|
|
222
|
+
): BlockGroup[] {
|
|
223
|
+
const result: BlockGroup[] = [];
|
|
224
|
+
let groupIdx = 0;
|
|
225
|
+
|
|
226
|
+
for (const block of blocks) {
|
|
227
|
+
const blockGroups: MarkGroup[] = [];
|
|
228
|
+
|
|
229
|
+
while (groupIdx < allGroups.length) {
|
|
230
|
+
const group = allGroups[groupIdx];
|
|
231
|
+
// Check if this group's first unit is a newline (block separator)
|
|
232
|
+
if (group.units.length === 1 && group.units[0].text === "\n") {
|
|
233
|
+
groupIdx++;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
if (group.block !== block) break;
|
|
237
|
+
blockGroups.push(group);
|
|
238
|
+
groupIdx++;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (blockGroups.length > 0) {
|
|
242
|
+
result.push({
|
|
243
|
+
blockKey: block._key,
|
|
244
|
+
blockStyle: block.style || "normal",
|
|
245
|
+
markGroups: blockGroups,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Block element mapping ──
|
|
254
|
+
|
|
255
|
+
function getBlockElement(style: string): "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" {
|
|
256
|
+
switch (style) {
|
|
257
|
+
case "h1": return "h1";
|
|
258
|
+
case "h2": return "h2";
|
|
259
|
+
case "h3": return "h3";
|
|
260
|
+
case "h4": return "h4";
|
|
261
|
+
case "h5": return "h5";
|
|
262
|
+
case "h6": return "h6";
|
|
263
|
+
case "blockquote": return "blockquote";
|
|
264
|
+
default: return "p";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Component ──
|
|
269
|
+
|
|
270
|
+
export default function TypewriterRichText({
|
|
271
|
+
portableText,
|
|
272
|
+
config,
|
|
273
|
+
entered,
|
|
274
|
+
startDelay = 0,
|
|
275
|
+
showCursor = true,
|
|
276
|
+
className,
|
|
277
|
+
style,
|
|
278
|
+
onComplete,
|
|
279
|
+
}: TypewriterRichTextProps) {
|
|
280
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
281
|
+
const cursorRef = useRef<HTMLSpanElement>(null);
|
|
282
|
+
|
|
283
|
+
// Merge config with defaults
|
|
284
|
+
const speed = config?.speed ?? TYPEWRITER_DEFAULTS.speed;
|
|
285
|
+
const mode = config?.mode ?? TYPEWRITER_DEFAULTS.mode;
|
|
286
|
+
|
|
287
|
+
// Convert speed (chars/sec) to delay between units (ms)
|
|
288
|
+
const unitDelay = Math.round(1000 / speed);
|
|
289
|
+
const unitDuration = mode === "word" ? 150 : 80;
|
|
290
|
+
|
|
291
|
+
// Flatten content into character/word units
|
|
292
|
+
const flatUnits = useMemo(
|
|
293
|
+
() => (portableText ? flattenToUnits(portableText, mode) : []),
|
|
294
|
+
[portableText, mode],
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Group consecutive same-mark units for efficient DOM
|
|
298
|
+
const markGroups = useMemo(() => groupByMarks(flatUnits), [flatUnits]);
|
|
299
|
+
|
|
300
|
+
// Group into block-level paragraphs
|
|
301
|
+
const blockGroups = useMemo(
|
|
302
|
+
() => (portableText ? groupByBlocks(portableText, markGroups) : []),
|
|
303
|
+
[portableText, markGroups],
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Total character count (excluding newlines)
|
|
307
|
+
const totalChars = useMemo(
|
|
308
|
+
() => flatUnits.filter((u) => u.text !== "\n").length,
|
|
309
|
+
[flatUnits],
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Timing
|
|
313
|
+
const typingEndTime = startDelay + totalChars * unitDelay + unitDuration;
|
|
314
|
+
const cursorPause = 600;
|
|
315
|
+
const totalDuration = typingEndTime + cursorPause;
|
|
316
|
+
|
|
317
|
+
// Track completion (controls cursor visibility)
|
|
318
|
+
const [completed, setCompleted] = useState(false);
|
|
319
|
+
|
|
320
|
+
// ── Completion timer: fires onComplete after total animation duration ──
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
if (!entered) return;
|
|
323
|
+
const timer = setTimeout(() => {
|
|
324
|
+
setCompleted(true);
|
|
325
|
+
onComplete?.();
|
|
326
|
+
}, totalDuration);
|
|
327
|
+
return () => clearTimeout(timer);
|
|
328
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
329
|
+
}, [entered]);
|
|
330
|
+
|
|
331
|
+
// ── Cursor tracking: position cursor via JS interval, zero re-renders ──
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!entered || !showCursor) return;
|
|
334
|
+
|
|
335
|
+
const wrapper = wrapperRef.current;
|
|
336
|
+
const cursor = cursorRef.current;
|
|
337
|
+
if (!wrapper || !cursor) return;
|
|
338
|
+
|
|
339
|
+
let count = 0;
|
|
340
|
+
let delayTimer: ReturnType<typeof setTimeout> | null = null;
|
|
341
|
+
let intervalTimer: ReturnType<typeof setInterval> | null = null;
|
|
342
|
+
let blinkTimer: ReturnType<typeof setTimeout> | null = null;
|
|
343
|
+
|
|
344
|
+
// Position cursor next to a character span
|
|
345
|
+
function positionCursor() {
|
|
346
|
+
if (!wrapper || !cursor) return;
|
|
347
|
+
|
|
348
|
+
const targetIdx = Math.max(0, count - 1);
|
|
349
|
+
const charEl = wrapper.querySelector(
|
|
350
|
+
`[data-char-idx="${targetIdx}"]`,
|
|
351
|
+
) as HTMLElement | null;
|
|
352
|
+
|
|
353
|
+
if (charEl) {
|
|
354
|
+
const wrapperRect = wrapper.getBoundingClientRect();
|
|
355
|
+
const charRect = charEl.getBoundingClientRect();
|
|
356
|
+
cursor.style.position = "absolute";
|
|
357
|
+
cursor.style.left = `${charRect.right - wrapperRect.left}px`;
|
|
358
|
+
cursor.style.top = `${charRect.top - wrapperRect.top}px`;
|
|
359
|
+
cursor.style.opacity = "1";
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Wait for startDelay, then advance cursor every unitDelay
|
|
364
|
+
delayTimer = setTimeout(() => {
|
|
365
|
+
cursor.style.opacity = "1";
|
|
366
|
+
positionCursor();
|
|
367
|
+
|
|
368
|
+
intervalTimer = setInterval(() => {
|
|
369
|
+
count++;
|
|
370
|
+
if (count >= totalChars) {
|
|
371
|
+
if (intervalTimer) clearInterval(intervalTimer);
|
|
372
|
+
intervalTimer = null;
|
|
373
|
+
positionCursor();
|
|
374
|
+
// Start blinking, then hide after cursorPause
|
|
375
|
+
cursor.style.animation = `typewriter-cursor-blink 800ms step-end infinite`;
|
|
376
|
+
blinkTimer = setTimeout(() => {
|
|
377
|
+
cursor.style.opacity = "0";
|
|
378
|
+
cursor.style.animation = "none";
|
|
379
|
+
}, cursorPause);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
positionCursor();
|
|
383
|
+
}, unitDelay);
|
|
384
|
+
}, startDelay);
|
|
385
|
+
|
|
386
|
+
return () => {
|
|
387
|
+
if (delayTimer) clearTimeout(delayTimer);
|
|
388
|
+
if (intervalTimer) clearInterval(intervalTimer);
|
|
389
|
+
if (blinkTimer) clearTimeout(blinkTimer);
|
|
390
|
+
};
|
|
391
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
392
|
+
}, [entered]);
|
|
393
|
+
|
|
394
|
+
// Nothing to render
|
|
395
|
+
if (!portableText || flatUnits.length === 0) return null;
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<div
|
|
399
|
+
ref={wrapperRef}
|
|
400
|
+
className={`${className || ""} space-y-[0.75em]`.trim()}
|
|
401
|
+
style={{ ...style, position: "relative" }}
|
|
402
|
+
data-typewriter-rich
|
|
403
|
+
>
|
|
404
|
+
{blockGroups.map((bg) => {
|
|
405
|
+
const Tag = getBlockElement(bg.blockStyle);
|
|
406
|
+
return (
|
|
407
|
+
<Tag key={bg.blockKey}>
|
|
408
|
+
{bg.markGroups.map((mg, mgIdx) => {
|
|
409
|
+
const innerSpans = mg.units.map((unit) => (
|
|
410
|
+
<span
|
|
411
|
+
key={unit.globalIndex}
|
|
412
|
+
data-char-idx={unit.globalIndex}
|
|
413
|
+
aria-hidden="true"
|
|
414
|
+
style={{
|
|
415
|
+
opacity: entered ? 1 : 0,
|
|
416
|
+
transition: entered
|
|
417
|
+
? `opacity ${unitDuration}ms ease-out ${startDelay + unit.globalIndex * unitDelay}ms`
|
|
418
|
+
: "none",
|
|
419
|
+
whiteSpace: unit.isSpace ? "pre" : undefined,
|
|
420
|
+
}}
|
|
421
|
+
>
|
|
422
|
+
{unit.text}
|
|
423
|
+
</span>
|
|
424
|
+
));
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<span key={mgIdx}>
|
|
428
|
+
{wrapInMarks(<>{innerSpans}</>, mg.marks, mg.block)}
|
|
429
|
+
</span>
|
|
430
|
+
);
|
|
431
|
+
})}
|
|
432
|
+
</Tag>
|
|
433
|
+
);
|
|
434
|
+
})}
|
|
435
|
+
|
|
436
|
+
{/* Cursor — positioned absolutely, tracked by JS interval */}
|
|
437
|
+
{showCursor && !completed && (
|
|
438
|
+
<span
|
|
439
|
+
ref={cursorRef}
|
|
440
|
+
className="typewriter-cursor"
|
|
441
|
+
style={{
|
|
442
|
+
position: "absolute",
|
|
443
|
+
top: 0,
|
|
444
|
+
left: 0,
|
|
445
|
+
opacity: 0,
|
|
446
|
+
fontWeight: 100,
|
|
447
|
+
pointerEvents: "none",
|
|
448
|
+
}}
|
|
449
|
+
aria-hidden="true"
|
|
450
|
+
>
|
|
451
|
+
|
|
|
452
|
+
</span>
|
|
453
|
+
)}
|
|
454
|
+
|
|
455
|
+
{/* Screen reader accessible text */}
|
|
456
|
+
<span className="sr-only">
|
|
457
|
+
{flatUnits
|
|
458
|
+
.map((u) => u.text)
|
|
459
|
+
.join("")
|
|
460
|
+
.replace(/\n/g, " ")}
|
|
461
|
+
</span>
|
|
462
|
+
</div>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TypewriterWrapper — single-phase typewriter reveal for textBlock content.
|
|
5
|
+
*
|
|
6
|
+
* Renders the actual rich text (PortableText) with each character individually
|
|
7
|
+
* animated via opacity transitions. All formatting (bold, italic, links) is
|
|
8
|
+
* preserved throughout the animation — no plain-text degradation, no swap.
|
|
9
|
+
*
|
|
10
|
+
* Architecture: One DOM tree. TypewriterRichText walks the Portable Text AST
|
|
11
|
+
* and wraps each character in a <span> inside the appropriate mark wrappers.
|
|
12
|
+
* Characters fade in sequentially with staggered CSS transition delays.
|
|
13
|
+
* When complete, the cursor blinks briefly and is removed.
|
|
14
|
+
*
|
|
15
|
+
* Uses IntersectionObserver with above-the-fold detection (same pattern as
|
|
16
|
+
* EnterAnimationWrapper).
|
|
17
|
+
*
|
|
18
|
+
* prefers-reduced-motion: shows rich text immediately with full opacity, no animation.
|
|
19
|
+
* Builder mode: not animated (builder never renders this component).
|
|
20
|
+
*
|
|
21
|
+
* Session 121 — Original two-phase typewriter.
|
|
22
|
+
* Session 133 — Rewritten as single-phase (TypewriterRichText). Removed
|
|
23
|
+
* extractPlainText, plain-text overlay, visibility swap, Phase 1/Phase 2.
|
|
24
|
+
* All formatting now preserved during animation.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useRef, useEffect, useState, useCallback, type CSSProperties } from "react";
|
|
28
|
+
import type { TypewriterConfig } from "../../lib/animation/enter-types";
|
|
29
|
+
import type { PortableTextContent } from "../../lib/sanity/types";
|
|
30
|
+
import TypewriterRichText from "./TypewriterRichText";
|
|
31
|
+
|
|
32
|
+
// ── Count total characters in PortableText (for empty check) ──
|
|
33
|
+
|
|
34
|
+
function hasTextContent(portableText: PortableTextContent | undefined): boolean {
|
|
35
|
+
if (!portableText || portableText.length === 0) return false;
|
|
36
|
+
return portableText.some(
|
|
37
|
+
(block) =>
|
|
38
|
+
block.children?.some(
|
|
39
|
+
(child) => child._type === "span" && child.text && child.text.length > 0,
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Component ──
|
|
45
|
+
|
|
46
|
+
interface TypewriterWrapperProps {
|
|
47
|
+
/** The Portable Text content from the text block */
|
|
48
|
+
portableText: PortableTextContent | undefined;
|
|
49
|
+
/** Typewriter configuration (speed, mode) */
|
|
50
|
+
config?: TypewriterConfig;
|
|
51
|
+
/** The fully rendered rich text (PortableText output) — fallback for reduced motion */
|
|
52
|
+
children: React.ReactNode;
|
|
53
|
+
/** Animation delay from enter animation config */
|
|
54
|
+
delay?: number;
|
|
55
|
+
/** CSS class for text styling (from getTextBlockStyles) */
|
|
56
|
+
textClassName?: string;
|
|
57
|
+
/** Inline styles for text styling (from getTextBlockStyles) */
|
|
58
|
+
textStyle?: CSSProperties;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function TypewriterWrapper({
|
|
62
|
+
portableText,
|
|
63
|
+
config,
|
|
64
|
+
children,
|
|
65
|
+
delay = 0,
|
|
66
|
+
textClassName,
|
|
67
|
+
textStyle,
|
|
68
|
+
}: TypewriterWrapperProps) {
|
|
69
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
70
|
+
const [hasEntered, setHasEntered] = useState(false);
|
|
71
|
+
const [typewriterDone, setTypewriterDone] = useState(false);
|
|
72
|
+
|
|
73
|
+
// ── IntersectionObserver ──
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const el = ref.current;
|
|
76
|
+
if (!el || hasEntered) return;
|
|
77
|
+
|
|
78
|
+
// Respect prefers-reduced-motion: skip animation entirely
|
|
79
|
+
if (
|
|
80
|
+
typeof window !== "undefined" &&
|
|
81
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
82
|
+
) {
|
|
83
|
+
setHasEntered(true);
|
|
84
|
+
setTypewriterDone(true);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const observer = new IntersectionObserver(
|
|
89
|
+
([entry]) => {
|
|
90
|
+
if (entry.isIntersecting) {
|
|
91
|
+
setHasEntered(true);
|
|
92
|
+
observer.disconnect();
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{ threshold: 0.15 },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
observer.observe(el);
|
|
99
|
+
return () => observer.disconnect();
|
|
100
|
+
}, [hasEntered]);
|
|
101
|
+
|
|
102
|
+
const handleComplete = useCallback(() => {
|
|
103
|
+
setTypewriterDone(true);
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
// If no text content, render children directly (PortableText fallback)
|
|
107
|
+
if (!hasTextContent(portableText)) {
|
|
108
|
+
return <>{children}</>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// prefers-reduced-motion: show the standard PortableText immediately
|
|
112
|
+
if (
|
|
113
|
+
typeof window !== "undefined" &&
|
|
114
|
+
typewriterDone &&
|
|
115
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
116
|
+
) {
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
ref={ref}
|
|
120
|
+
data-enter-animation="typewriter"
|
|
121
|
+
data-entered=""
|
|
122
|
+
>
|
|
123
|
+
{children}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Normal flow: TypewriterRichText stays rendered even after completion.
|
|
129
|
+
// After animation finishes all chars are opacity:1 and the cursor is removed,
|
|
130
|
+
// so the visual output is identical to PortableText — no swap, no layout jump.
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
ref={ref}
|
|
134
|
+
data-enter-animation="typewriter"
|
|
135
|
+
data-entered={hasEntered || typewriterDone ? "" : undefined}
|
|
136
|
+
>
|
|
137
|
+
<TypewriterRichText
|
|
138
|
+
portableText={portableText}
|
|
139
|
+
config={config}
|
|
140
|
+
entered={hasEntered}
|
|
141
|
+
startDelay={delay}
|
|
142
|
+
showCursor={!typewriterDone}
|
|
143
|
+
className={textClassName}
|
|
144
|
+
style={textStyle}
|
|
145
|
+
onComplete={handleComplete}
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|