diff --git a/packages/twenty-website-new/scripts/check-boundaries.mjs b/packages/twenty-website-new/scripts/check-boundaries.mjs index fe0836e5d4..f5e500b4ca 100644 --- a/packages/twenty-website-new/scripts/check-boundaries.mjs +++ b/packages/twenty-website-new/scripts/check-boundaries.mjs @@ -31,6 +31,26 @@ const RULES = [ 'preference defaults.', ].join('\n '), }, + { + id: 'no-raw-animation-frame', + description: + '`requestAnimationFrame(...)` / `cancelAnimationFrame(...)` may only be used inside shared runtime primitives.', + pattern: /\b(?:window\.)?(?:requestAnimationFrame|cancelAnimationFrame)\s*\(/, + appliesTo: (rel) => + rel.startsWith('src/') && /\.(ts|tsx|mjs|js|jsx)$/.test(rel), + exempt: (rel) => + rel.startsWith('src/lib/animation/') || + rel.startsWith('src/lib/visual-runtime/') || + rel.includes('__tests__') || + rel === 'src/app/[locale]/halftone/_lib/exporters.ts' || + rel.endsWith('.d.ts'), + help: [ + 'Use `createAnimationFrameLoop` from `@/lib/animation` for one-shot', + 'or UI frame scheduling. Use `createVisualRenderLoop` from', + '`@/lib/visual-runtime` for canvas/WebGL renderers so tab visibility,', + 'element visibility, cleanup, and render failures are handled consistently.', + ].join('\n '), + }, ]; const SKIP_DIRS = new Set([ diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx b/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx index 38bcdb6b3e..22fd93ffdb 100644 --- a/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx @@ -11,7 +11,6 @@ import { MENU_DATA } from '@/sections/Menu/data'; import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data'; import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components'; import { Pages } from '@/lib/pages'; -import { ArrowRightUpIcon } from '@/icons'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; import { Faq } from '@/sections/Faq/components'; @@ -22,23 +21,15 @@ import { Menu } from '@/sections/Menu/components'; import { Problem } from '@/sections/Problem/components'; import { Testimonials } from '@/sections/Testimonials/components'; import { ThreeCards } from '@/sections/ThreeCards/components'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; import { TrustedBy } from '@/sections/TrustedBy/components'; import { theme } from '@/theme'; import { css } from '@linaria/core'; import { styled } from '@linaria/react'; -export const generateMetadata = buildLocalizedMetadata({ - path: '/', - title: 'Twenty | #1 open source CRM', - description: - 'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.', -}); +export const generateMetadata = buildRouteMetadata('home'); const HOME_TOP_BACKGROUND_COLOR = '#F4F4F4'; -const PRODUCT_HUNT_LAUNCH_URL = - 'https://www.producthunt.com/products/twenty-crm?launch=twenty-2-0'; -const PRODUCT_HUNT_BRAND_COLOR = '#DA552F'; const HeroHeadingGroup = styled.div` align-items: center; @@ -52,58 +43,6 @@ const HeroHeadingGroup = styled.div` } `; -const HeroLaunchChip = styled.a` - align-items: center; - background: ${theme.colors.primary.background[100]}; - border: 1px solid ${theme.colors.primary.border[10]}; - border-radius: 999px; - color: ${theme.colors.primary.text[100]}; - display: inline-flex; - font-family: ${theme.font.family.mono}; - font-size: ${theme.font.size(2.5)}; - font-weight: ${theme.font.weight.medium}; - gap: ${theme.spacing(2)}; - line-height: ${theme.lineHeight(3)}; - padding: ${theme.spacing(2)} ${theme.spacing(3)}; - text-decoration: none; - text-transform: uppercase; - transition: - border-color 180ms ease, - color 180ms ease, - transform 180ms ease; - white-space: nowrap; - - &:is(:hover, :focus-visible) { - border-color: ${PRODUCT_HUNT_BRAND_COLOR}; - color: ${PRODUCT_HUNT_BRAND_COLOR}; - transform: translateY(-1px); - } - - &:focus-visible { - outline: 1px solid ${theme.colors.highlight[100]}; - outline-offset: 2px; - } - - @media (prefers-reduced-motion: reduce) { - transition: none; - } -`; - -const HeroLaunchChipDot = styled.span` - background: ${PRODUCT_HUNT_BRAND_COLOR}; - border-radius: 999px; - display: block; - flex-shrink: 0; - height: ${theme.spacing(2)}; - width: ${theme.spacing(2)}; -`; - -const HeroLaunchChipLabel = styled.span` - align-items: center; - display: inline-flex; - gap: ${theme.spacing(1.5)}; -`; - const HeroIntroGroup = styled.div` align-items: center; display: flex; @@ -177,17 +116,6 @@ export default async function HomePage() { - - - - Live on Product Hunt - - - diff --git a/packages/twenty-website-new/src/app/[locale]/customers/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/page.tsx index 98ee905a50..c2903a45e9 100644 --- a/packages/twenty-website-new/src/app/[locale]/customers/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/customers/page.tsx @@ -14,15 +14,10 @@ import { Menu } from '@/sections/Menu/components'; import { Signoff } from '@/sections/Signoff/components'; import { TrustedBy } from '@/sections/TrustedBy/components'; import { theme } from '@/theme'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; import { css } from '@linaria/core'; -export const generateMetadata = buildLocalizedMetadata({ - path: '/customers', - title: 'Customers | Twenty', - description: - 'Meet the teams running their business on Twenty. Real customer stories on how they shaped the CRM to fit their workflow.', -}); +export const generateMetadata = buildRouteMetadata('customers'); const HERO_HEADING = [ { text: 'See how teams ', fontFamily: 'serif' as const }, diff --git a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx index 680865edf5..f7a0c89a77 100644 --- a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx +++ b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx @@ -5,6 +5,7 @@ import { buttonBaseStyles, } from '@/design-system/components/Button/BaseButton'; import { Body, Heading } from '@/design-system/components'; +import { useTimeoutRegistry } from '@/lib/react'; import { theme } from '@/theme'; import { css } from '@linaria/core'; import { styled } from '@linaria/react'; @@ -133,6 +134,7 @@ const nextStepItemClassName = css` export function EnterpriseActivateClient() { const searchParams = useSearchParams(); const sessionId = searchParams.get('session_id'); + const timeoutRegistry = useTimeoutRegistry(); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -146,14 +148,21 @@ export function EnterpriseActivateClient() { return; } + const abortController = new AbortController(); + const activate = async () => { try { const response = await fetch( `/api/enterprise/activate?session_id=${encodeURIComponent(sessionId)}`, + { signal: abortController.signal }, ); const data: { error?: string } & Partial = await response.json(); + if (abortController.signal.aborted) { + return; + } + if (!response.ok) { setError(data.error ?? 'Activation failed'); @@ -170,13 +179,23 @@ export function EnterpriseActivateClient() { setError('Activation response was incomplete.'); } } catch { + if (abortController.signal.aborted) { + return; + } + setError('Failed to activate enterprise key. Please try again.'); } finally { - setLoading(false); + if (!abortController.signal.aborted) { + setLoading(false); + } } }; void activate(); + + return () => { + abortController.abort(); + }; }, [sessionId]); const handleCopy = async () => { @@ -186,7 +205,7 @@ export function EnterpriseActivateClient() { await navigator.clipboard.writeText(result.enterpriseKey); setCopied(true); - setTimeout(() => { + timeoutRegistry.schedule(() => { setCopied(false); }, 2000); }; diff --git a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx index 299932ff16..058a7047f4 100644 --- a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx @@ -8,16 +8,11 @@ import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels' import { Hero } from '@/sections/Hero/components'; import { Menu } from '@/sections/Menu/components'; import { theme } from '@/theme'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; import { Suspense } from 'react'; import { styled } from '@linaria/react'; -export const generateMetadata = buildLocalizedMetadata({ - path: '/enterprise/activate', - title: 'Enterprise activation | Twenty', - description: - 'Complete activation for your Twenty self-hosted enterprise license.', -}); +export const generateMetadata = buildRouteMetadata('enterpriseActivate'); const ENTERPRISE_ACTIVATE_HEADING: HeadingType[] = [ { text: 'Enterprise ', fontFamily: 'serif' }, diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/HalftoneStudio.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/HalftoneStudio.tsx index fe4b9433b0..34b1f18f20 100644 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/HalftoneStudio.tsx +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/HalftoneStudio.tsx @@ -40,6 +40,7 @@ import { } from '@/lib/halftone/state'; import { Logo as LogoIcon } from '@/icons'; import { LocalizedLink } from '@/lib/i18n'; +import { useTimeoutRegistry } from '@/lib/react'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; import { @@ -57,6 +58,7 @@ const DESKTOP_CONTROLS_PANEL_WIDTH = 320; const DESKTOP_CONTROLS_PANEL_OFFSET = 20; const DESKTOP_CONTROLS_PANEL_FOOTPRINT = DESKTOP_CONTROLS_PANEL_WIDTH + DESKTOP_CONTROLS_PANEL_OFFSET; +const STATUS_CLEAR_DELAY_MS = 2000; const StudioShell = styled.div<{ $background: string }>` background: ${(props) => props.$background}; @@ -389,6 +391,14 @@ export function HalftoneStudio() { shape: { ...DEFAULT_SHAPE_HALFTONE_SETTINGS }, image: { ...DEFAULT_IMAGE_HALFTONE_SETTINGS }, }); + const timeoutRegistry = useTimeoutRegistry(); + + const clearStatusLater = useCallback(() => { + timeoutRegistry.schedule( + () => dispatch({ type: 'clearStatus' }), + STATUS_CLEAR_DELAY_MS, + ); + }, [timeoutRegistry]); const selectedShape = useMemo( () => @@ -525,7 +535,7 @@ export function HalftoneStudio() { .writeText(url) .then(() => { dispatch({ type: 'setStatus', message: 'Link copied to clipboard.' }); - window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000); + clearStatusLater(); }) .catch(() => { dispatch({ @@ -534,7 +544,7 @@ export function HalftoneStudio() { isError: true, }); }); - }, [exportName, previewDistance, state.settings]); + }, [clearStatusLater, exportName, previewDistance, state.settings]); const handleCopyHalftoneImage = useCallback( async (width: number, height: number) => { @@ -580,7 +590,7 @@ export function HalftoneStudio() { type: 'setStatus', message: 'Image copied to clipboard.', }); - window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000); + clearStatusLater(); } catch { dispatch({ type: 'setStatus', @@ -589,7 +599,7 @@ export function HalftoneStudio() { }); } }, - [exportBackground, state.settings.background.color], + [clearStatusLater, exportBackground, state.settings.background.color], ); useEffect(() => { @@ -623,75 +633,81 @@ export function HalftoneStudio() { }; }, [handleCopyHalftoneImage]); - const openFilePicker = useCallback((accept: string) => { - return new Promise((resolve) => { - const input = fileInputReference.current; + const openFilePicker = useCallback( + (accept: string) => { + return new Promise((resolve) => { + const input = fileInputReference.current; - if (!input) { - resolve(null); - return; - } + if (!input) { + resolve(null); + return; + } - pendingFilePickerReference.current = { resolve }; - input.accept = accept; + pendingFilePickerReference.current = { resolve }; + input.accept = accept; - const handleWindowFocus = () => { - window.setTimeout(() => { - const pendingPicker = pendingFilePickerReference.current; - const currentInput = fileInputReference.current; + const handleWindowFocus = () => { + timeoutRegistry.schedule(() => { + const pendingPicker = pendingFilePickerReference.current; + const currentInput = fileInputReference.current; - if (!pendingPicker) { - return; - } + if (!pendingPicker) { + return; + } - if (currentInput?.files?.length) { - return; - } + if (currentInput?.files?.length) { + return; + } - pendingFilePickerReference.current = null; - pendingPicker.resolve(null); - }, 300); - }; + pendingFilePickerReference.current = null; + pendingPicker.resolve(null); + }, 300); + }; - window.addEventListener('focus', handleWindowFocus, { once: true }); - input.click(); - }); - }, []); + window.addEventListener('focus', handleWindowFocus, { once: true }); + input.click(); + }); + }, + [timeoutRegistry], + ); - const openPresetPicker = useCallback((accept: string) => { - return new Promise((resolve) => { - const input = presetFileInputReference.current; + const openPresetPicker = useCallback( + (accept: string) => { + return new Promise((resolve) => { + const input = presetFileInputReference.current; - if (!input) { - resolve([]); - return; - } + if (!input) { + resolve([]); + return; + } - pendingPresetPickerReference.current = { resolve }; - input.accept = accept; + pendingPresetPickerReference.current = { resolve }; + input.accept = accept; - const handleWindowFocus = () => { - window.setTimeout(() => { - const pendingPicker = pendingPresetPickerReference.current; - const currentInput = presetFileInputReference.current; + const handleWindowFocus = () => { + timeoutRegistry.schedule(() => { + const pendingPicker = pendingPresetPickerReference.current; + const currentInput = presetFileInputReference.current; - if (!pendingPicker) { - return; - } + if (!pendingPicker) { + return; + } - if (currentInput?.files?.length) { - return; - } + if (currentInput?.files?.length) { + return; + } - pendingPresetPickerReference.current = null; - pendingPicker.resolve([]); - }, 300); - }; + pendingPresetPickerReference.current = null; + pendingPicker.resolve([]); + }, 300); + }; - window.addEventListener('focus', handleWindowFocus, { once: true }); - input.click(); - }); - }, []); + window.addEventListener('focus', handleWindowFocus, { once: true }); + input.click(); + }); + }, + [timeoutRegistry], + ); const handleFileInputChange = useCallback(() => { const input = fileInputReference.current; @@ -844,7 +860,7 @@ export function HalftoneStudio() { type: 'setStatus', message: 'Image pasted from clipboard.', }); - window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000); + clearStatusLater(); }; window.addEventListener('paste', handlePaste); @@ -852,7 +868,7 @@ export function HalftoneStudio() { return () => { window.removeEventListener('paste', handlePaste); }; - }, [activateUploadedImage]); + }, [activateUploadedImage, clearStatusLater]); const handleUploadSource = useCallback(async () => { const file = await openFilePicker( @@ -1204,9 +1220,9 @@ export function HalftoneStudio() { type: 'setStatus', message: 'Halftone SVG downloaded.', }); - window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000); + clearStatusLater(); }, - [buildHalftoneSvg, exportArtifactNames.fileBaseName], + [buildHalftoneSvg, clearStatusLater, exportArtifactNames.fileBaseName], ); const handleCopyHalftoneSvg = useCallback( @@ -1239,7 +1255,7 @@ export function HalftoneStudio() { type: 'setStatus', message: 'SVG copied to clipboard.', }); - window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000); + clearStatusLater(); return; } catch { // Fall through to plain-text clipboard copy below. @@ -1253,7 +1269,7 @@ export function HalftoneStudio() { type: 'setStatus', message: 'SVG markup copied to clipboard as text.', }); - window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000); + clearStatusLater(); return; } catch { dispatch({ @@ -1271,7 +1287,7 @@ export function HalftoneStudio() { isError: true, }); }, - [buildHalftoneSvg], + [buildHalftoneSvg, clearStatusLater], ); const handleExportHtml = useCallback(async () => { diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/page.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/page.tsx index efdcf19690..aeae69145e 100644 --- a/packages/twenty-website-new/src/app/[locale]/halftone/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/halftone/page.tsx @@ -1,11 +1,7 @@ import { HalftoneStudio } from '@/app/[locale]/halftone/_components/HalftoneStudio'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; -export const generateMetadata = buildLocalizedMetadata({ - path: '/halftone', - title: 'Halftone Generator | Twenty', - description: 'Interactive halftone generator exported from Twenty.', -}); +export const generateMetadata = buildRouteMetadata('halftone'); export default function HalftonePage() { return ; diff --git a/packages/twenty-website-new/src/app/[locale]/partners/page.tsx b/packages/twenty-website-new/src/app/[locale]/partners/page.tsx index b4dd6590d5..dfaaed3021 100644 --- a/packages/twenty-website-new/src/app/[locale]/partners/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/partners/page.tsx @@ -25,7 +25,7 @@ import { ThreeCards } from '@/sections/ThreeCards/components'; import { TrustedBy } from '@/sections/TrustedBy/components'; import type { ThreeCardsScrollLayoutOptions } from '@/sections/ThreeCards/utils/three-cards-scroll-layout'; import { theme } from '@/theme'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; import { styled } from '@linaria/react'; const PARTNER_ILLUSTRATION_CARDS_SCROLL_LAYOUT_OPTIONS: ThreeCardsScrollLayoutOptions = @@ -45,12 +45,7 @@ const PromoSpacing = styled.div` } `; -export const generateMetadata = buildLocalizedMetadata({ - path: '/partners', - title: 'Partners | Twenty', - description: - 'Join our partner ecosystem and grow with us as we build the #1 open source CRM.', -}); +export const generateMetadata = buildRouteMetadata('partners'); export default async function PartnerPage() { const stats = await fetchCommunityStats(); diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx b/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx index 6b53568fbd..2670ee3259 100644 --- a/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx @@ -19,7 +19,7 @@ import { PricingStateProvider } from '@/sections/Plans/context/PricingStateConte import { PlanTable } from '@/sections/PlanTable/components'; import { Salesforce } from '@/sections/Salesforce/components'; import { theme } from '@/theme'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; import { styled } from '@linaria/react'; const PricingPlansContainer = styled.div` @@ -34,12 +34,7 @@ const PricingBannerContainer = styled.div` width: 100%; `; -export const generateMetadata = buildLocalizedMetadata({ - path: '/pricing', - title: 'Pricing | Twenty', - description: - 'Plans that scale with your team. Compare tiers of the #1 open source CRM.', -}); +export const generateMetadata = buildRouteMetadata('pricing'); export default async function PricingPage() { const stats = await fetchCommunityStats(); diff --git a/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx b/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx index c165a7ae5d..95ef27f408 100644 --- a/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx @@ -2,16 +2,11 @@ import { MENU_DATA } from '@/sections/Menu/data'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; import { LegalDocument } from '@/sections/LegalDocument/components'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; import { PrivacyPolicyDocument } from './_components'; -export const generateMetadata = buildLocalizedMetadata({ - path: '/privacy-policy', - title: 'Privacy Policy | Twenty', - description: - 'How Twenty collects, uses, safeguards, and discloses information when you use Twenty.com and related services.', -}); +export const generateMetadata = buildRouteMetadata('privacyPolicy'); export default async function PrivacyPolicyPage() { const stats = await fetchCommunityStats(); diff --git a/packages/twenty-website-new/src/app/[locale]/product/page.tsx b/packages/twenty-website-new/src/app/[locale]/product/page.tsx index 0aada93b71..343d7619a9 100644 --- a/packages/twenty-website-new/src/app/[locale]/product/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/product/page.tsx @@ -20,14 +20,9 @@ import { Signoff } from '@/sections/Signoff/components'; import { ThreeCards } from '@/sections/ThreeCards/components'; import { TrustedBy } from '@/sections/TrustedBy/components'; import { theme } from '@/theme'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; -export const generateMetadata = buildLocalizedMetadata({ - path: '/product', - title: 'Product | Twenty', - description: - 'Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.', -}); +export const generateMetadata = buildRouteMetadata('product'); export default async function ProductPage() { const stats = await fetchCommunityStats(); diff --git a/packages/twenty-website-new/src/app/[locale]/releases/page.tsx b/packages/twenty-website-new/src/app/[locale]/releases/page.tsx index 59434698a1..9248171956 100644 --- a/packages/twenty-website-new/src/app/[locale]/releases/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/releases/page.tsx @@ -15,15 +15,10 @@ import { Hero } from '@/sections/Hero/components'; import { Menu } from '@/sections/Menu/components'; import { ReleaseNotes } from '@/sections/ReleaseNotes/components'; import { theme } from '@/theme'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; import { Fragment } from 'react'; -export const generateMetadata = buildLocalizedMetadata({ - path: '/releases', - title: 'Releases | Twenty', - description: - 'Discover the newest features and improvements in Twenty, the #1 open source CRM.', -}); +export const generateMetadata = buildRouteMetadata('releases'); export default async function ReleasesPage() { const allNotes = loadLocalReleaseNotes(); diff --git a/packages/twenty-website-new/src/app/[locale]/terms/page.tsx b/packages/twenty-website-new/src/app/[locale]/terms/page.tsx index 16c1d024e6..c8dd6938a9 100644 --- a/packages/twenty-website-new/src/app/[locale]/terms/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/terms/page.tsx @@ -2,16 +2,11 @@ import { MENU_DATA } from '@/sections/Menu/data'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; import { LegalDocument } from '@/sections/LegalDocument/components'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; import { TermsDocument } from './_components'; -export const generateMetadata = buildLocalizedMetadata({ - path: '/terms', - title: 'Terms of Service | Twenty', - description: - 'Terms of Service for Twenty.com PBC, including use of Twenty.com, sub-domains, and related services.', -}); +export const generateMetadata = buildRouteMetadata('terms'); export default async function TermsPage() { const stats = await fetchCommunityStats(); diff --git a/packages/twenty-website-new/src/app/[locale]/why-twenty/page.tsx b/packages/twenty-website-new/src/app/[locale]/why-twenty/page.tsx index 80b82d27e1..069a3e00e6 100644 --- a/packages/twenty-website-new/src/app/[locale]/why-twenty/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/why-twenty/page.tsx @@ -15,7 +15,7 @@ import { Marquee } from '@/sections/Marquee/components'; import { Menu } from '@/sections/Menu/components'; import { Signoff } from '@/sections/Signoff/components'; import { theme } from '@/theme'; -import { buildLocalizedMetadata } from '@/lib/seo'; +import { buildRouteMetadata } from '@/lib/seo'; import { css } from '@linaria/core'; const editorialOneIntroClass = css` @@ -57,12 +57,7 @@ const sectionCrosshairRight = { lineColor: crosshairLineColor, }; -export const generateMetadata = buildLocalizedMetadata({ - path: '/why-twenty', - title: 'Why Twenty | Twenty', - description: - 'Most packaged software makes companies more similar. Learn why the future of CRM is built, not bought.', -}); +export const generateMetadata = buildRouteMetadata('whyTwenty'); export default async function WhyTwentyPage() { const stats = await fetchCommunityStats(); @@ -156,11 +151,6 @@ export default async function WhyTwentyPage() { /> - {/* - - - */} - { + it('disallows API and non-indexed utility routes', () => { + const rules = robots().rules; + const rule = Array.isArray(rules) ? rules[0] : rules; + + expect(rule).toMatchObject({ + allow: '/', + disallow: ['/api/', '/halftone', '/enterprise/activate'], + userAgent: '*', + }); + }); +}); diff --git a/packages/twenty-website-new/src/app/__tests__/sitemap.test.ts b/packages/twenty-website-new/src/app/__tests__/sitemap.test.ts new file mode 100644 index 0000000000..9ef4b1ab2c --- /dev/null +++ b/packages/twenty-website-new/src/app/__tests__/sitemap.test.ts @@ -0,0 +1,29 @@ +import sitemap from '@/app/sitemap'; + +const pathnameOf = (url: string): string => new URL(url).pathname; + +describe('sitemap', () => { + it('emits only indexed public website routes', () => { + const entries = sitemap(); + const pathnames = entries.map((entry) => pathnameOf(entry.url)); + + expect(pathnames).toContain('/'); + expect(pathnames).toContain('/product'); + expect(pathnames).toContain('/customers/9dots'); + expect(pathnames).not.toContain('/fr-FR/product'); + expect(pathnames).not.toContain('/halftone'); + expect(pathnames).not.toContain('/enterprise/activate'); + }); + + it('emits hreflang alternates only for published website locales', () => { + const productEntry = sitemap().find( + (entry) => pathnameOf(entry.url) === '/product', + ); + + expect(productEntry?.alternates?.languages).toMatchObject({ + en: expect.stringMatching(/\/product$/), + 'x-default': expect.stringMatching(/\/product$/), + }); + expect(productEntry?.alternates?.languages).not.toHaveProperty('fr-FR'); + }); +}); diff --git a/packages/twenty-website-new/src/app/robots.ts b/packages/twenty-website-new/src/app/robots.ts index 5e33a5ffa0..a5ea16331b 100644 --- a/packages/twenty-website-new/src/app/robots.ts +++ b/packages/twenty-website-new/src/app/robots.ts @@ -1,8 +1,10 @@ import type { MetadataRoute } from 'next'; import { getSiteUrl } from '@/lib/seo'; +import { getRobotsDisallowedRoutePaths } from '@/lib/website-routing'; const SITE_URL = getSiteUrl(); +const ALWAYS_DISALLOW = ['/api/'] as const; export default function robots(): MetadataRoute.Robots { return { @@ -10,13 +12,7 @@ export default function robots(): MetadataRoute.Robots { { userAgent: '*', allow: '/', - disallow: [ - '/api/', - '/halftone', - '/*/halftone', - '/enterprise/activate', - '/*/enterprise/activate', - ], + disallow: [...ALWAYS_DISALLOW, ...getRobotsDisallowedRoutePaths()], }, ], sitemap: `${SITE_URL}/sitemap.xml`, diff --git a/packages/twenty-website-new/src/app/sitemap.ts b/packages/twenty-website-new/src/app/sitemap.ts index 0641da580d..480387761d 100644 --- a/packages/twenty-website-new/src/app/sitemap.ts +++ b/packages/twenty-website-new/src/app/sitemap.ts @@ -1,30 +1,12 @@ import type { MetadataRoute } from 'next'; import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; -import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers'; import { PUBLIC_APP_LOCALE_LIST } from '@/lib/i18n'; import { getSiteUrl } from '@/lib/seo'; +import { getIndexedWebsiteRoutes } from '@/lib/website-routing'; const SITE_URL = getSiteUrl(); -type StaticRoute = { - path: string; - changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency']; - priority: number; -}; - -const STATIC_ROUTES: readonly StaticRoute[] = [ - { path: '/', changeFrequency: 'weekly', priority: 1.0 }, - { path: '/why-twenty', changeFrequency: 'monthly', priority: 0.8 }, - { path: '/product', changeFrequency: 'monthly', priority: 0.8 }, - { path: '/pricing', changeFrequency: 'monthly', priority: 0.9 }, - { path: '/partners', changeFrequency: 'monthly', priority: 0.7 }, - { path: '/releases', changeFrequency: 'weekly', priority: 0.7 }, - { path: '/customers', changeFrequency: 'monthly', priority: 0.7 }, - { path: '/privacy-policy', changeFrequency: 'yearly', priority: 0.3 }, - { path: '/terms', changeFrequency: 'yearly', priority: 0.3 }, -]; - const buildLocalizedUrl = (locale: AppLocale, path: string): string => { const prefix = locale === SOURCE_LOCALE ? '' : `/${locale}`; const tail = path === '/' ? '' : path; @@ -53,14 +35,8 @@ const localize = ( })); export default function sitemap(): MetadataRoute.Sitemap { - const staticEntries = STATIC_ROUTES.flatMap( + return getIndexedWebsiteRoutes().flatMap( ({ path, changeFrequency, priority }) => localize(path, changeFrequency, priority), ); - - const caseStudyEntries = CASE_STUDY_CATALOG_ENTRIES.flatMap((entry) => - localize(entry.href, 'yearly', 0.5), - ); - - return [...staticEntries, ...caseStudyEntries]; } diff --git a/packages/twenty-website-new/src/lib/animation/__tests__/animated-number.test.ts b/packages/twenty-website-new/src/lib/animation/__tests__/animated-number.test.ts new file mode 100644 index 0000000000..2a80652826 --- /dev/null +++ b/packages/twenty-website-new/src/lib/animation/__tests__/animated-number.test.ts @@ -0,0 +1,68 @@ +import { + easeOutCubic, + getAnimatedNumberValue, +} from '@/lib/animation/animated-number'; + +describe('animated number', () => { + it('returns the starting value before time has elapsed', () => { + expect( + getAnimatedNumberValue({ + elapsedMs: 0, + from: 10, + target: 110, + }), + ).toBe(10); + }); + + it('applies cubic easing to the interpolated value', () => { + expect( + getAnimatedNumberValue({ + durationMs: 500, + elapsedMs: 250, + from: 0, + target: 100, + }), + ).toBe(88); + }); + + it('returns the target value after the duration has elapsed', () => { + expect( + getAnimatedNumberValue({ + durationMs: 500, + elapsedMs: 750, + from: 0, + target: 100, + }), + ).toBe(100); + }); + + it('returns the target value when duration is not positive', () => { + expect( + getAnimatedNumberValue({ + durationMs: 0, + elapsedMs: 0, + from: 0, + target: 100, + }), + ).toBe(100); + }); + + it('supports custom easing and rounding', () => { + expect( + getAnimatedNumberValue({ + durationMs: 100, + easing: (progress) => progress, + elapsedMs: 50, + from: 0, + round: Math.floor, + target: 11, + }), + ).toBe(5); + }); + + it('clamps easing input to a unit interval', () => { + expect(easeOutCubic(Number.NaN)).toBe(0); + expect(easeOutCubic(-1)).toBe(0); + expect(easeOutCubic(2)).toBe(1); + }); +}); diff --git a/packages/twenty-website-new/src/lib/animation/__tests__/animation-frame-loop.test.ts b/packages/twenty-website-new/src/lib/animation/__tests__/animation-frame-loop.test.ts new file mode 100644 index 0000000000..7de0cb04ca --- /dev/null +++ b/packages/twenty-website-new/src/lib/animation/__tests__/animation-frame-loop.test.ts @@ -0,0 +1,102 @@ +import { createAnimationFrameLoop } from '@/lib/animation/animation-frame-loop'; + +function createAnimationFrameScheduler() { + let nextHandle = 1; + const callbacks = new Map(); + + return { + callbacks, + cancelAnimationFrame: jest.fn((handle: number) => { + callbacks.delete(handle); + }), + requestAnimationFrame: jest.fn((callback: FrameRequestCallback) => { + const handle = nextHandle; + nextHandle += 1; + callbacks.set(handle, callback); + + return handle; + }), + runFrame: (handle: number, timestamp = 0) => { + const callback = callbacks.get(handle); + callbacks.delete(handle); + callback?.(timestamp); + }, + }; +} + +describe('createAnimationFrameLoop', () => { + it('does not schedule duplicate frames when started repeatedly', () => { + const scheduler = createAnimationFrameScheduler(); + const loop = createAnimationFrameLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + onFrame: jest.fn(() => false), + requestAnimationFrame: scheduler.requestAnimationFrame, + }); + + loop.start(); + loop.start(); + + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + + it('keeps scheduling while onFrame does not return false', () => { + const scheduler = createAnimationFrameScheduler(); + const onFrame = jest + .fn() + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(false); + const loop = createAnimationFrameLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + onFrame, + requestAnimationFrame: scheduler.requestAnimationFrame, + }); + + loop.start(); + scheduler.runFrame(1, 16); + + expect(onFrame).toHaveBeenCalledWith(16); + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2); + + scheduler.runFrame(2, 32); + + expect(onFrame).toHaveBeenCalledWith(32); + expect(loop.isRunning()).toBe(false); + }); + + it('cancels the pending frame on stop', () => { + const scheduler = createAnimationFrameScheduler(); + const loop = createAnimationFrameLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + onFrame: jest.fn(), + requestAnimationFrame: scheduler.requestAnimationFrame, + }); + + loop.start(); + loop.stop(); + + expect(scheduler.cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(loop.isRunning()).toBe(false); + }); + + it('does not reschedule when stopped during onFrame', () => { + const scheduler = createAnimationFrameScheduler(); + let loop: ReturnType; + const onFrame = jest.fn(() => { + loop.stop(); + }); + + loop = createAnimationFrameLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + onFrame, + requestAnimationFrame: scheduler.requestAnimationFrame, + }); + + loop.start(); + scheduler.runFrame(1, 16); + + expect(onFrame).toHaveBeenCalledWith(16); + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1); + expect(scheduler.callbacks.size).toBe(0); + expect(loop.isRunning()).toBe(false); + }); +}); diff --git a/packages/twenty-website-new/src/lib/animation/animated-number.ts b/packages/twenty-website-new/src/lib/animation/animated-number.ts new file mode 100644 index 0000000000..da739435ee --- /dev/null +++ b/packages/twenty-website-new/src/lib/animation/animated-number.ts @@ -0,0 +1,51 @@ +export type AnimatedNumberEasing = (progress: number) => number; +export type AnimatedNumberRounder = (value: number) => number; + +export type GetAnimatedNumberValueParameters = { + durationMs?: number; + easing?: AnimatedNumberEasing; + elapsedMs: number; + from: number; + round?: AnimatedNumberRounder; + target: number; +}; + +export const DEFAULT_ANIMATED_NUMBER_DURATION_MS = 500; + +const DEFAULT_ANIMATED_NUMBER_ROUNDER: AnimatedNumberRounder = Math.round; + +function clampUnitInterval(value: number): number { + if (Number.isNaN(value) || value <= 0) { + return 0; + } + + if (value >= 1) { + return 1; + } + + return value; +} + +export const easeOutCubic: AnimatedNumberEasing = (progress) => { + const clampedProgress = clampUnitInterval(progress); + + return 1 - (1 - clampedProgress) ** 3; +}; + +export function getAnimatedNumberValue({ + durationMs = DEFAULT_ANIMATED_NUMBER_DURATION_MS, + easing = easeOutCubic, + elapsedMs, + from, + round = DEFAULT_ANIMATED_NUMBER_ROUNDER, + target, +}: GetAnimatedNumberValueParameters): number { + if (!Number.isFinite(durationMs) || durationMs <= 0) { + return round(target); + } + + const progress = clampUnitInterval(elapsedMs / durationMs); + const easedProgress = clampUnitInterval(easing(progress)); + + return round(from + (target - from) * easedProgress); +} diff --git a/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts b/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts new file mode 100644 index 0000000000..d0db2df347 --- /dev/null +++ b/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts @@ -0,0 +1,83 @@ +export type AnimationFrameLoopController = { + isRunning: () => boolean; + start: () => void; + stop: () => void; +}; + +export type CreateAnimationFrameLoopOptions = { + cancelAnimationFrame?: (handle: number) => void; + onFrame: (timestamp: number) => boolean | void; + requestAnimationFrame?: (callback: FrameRequestCallback) => number; +}; + +const requestAnimationFrameFromWindow = (callback: FrameRequestCallback) => + window.requestAnimationFrame(callback); + +const cancelAnimationFrameFromWindow = (handle: number) => + window.cancelAnimationFrame(handle); + +export function createAnimationFrameLoop({ + cancelAnimationFrame = cancelAnimationFrameFromWindow, + onFrame, + requestAnimationFrame = requestAnimationFrameFromWindow, +}: CreateAnimationFrameLoopOptions): AnimationFrameLoopController { + let animationFrameId: number | null = null; + let isLoopRunning = false; + let runToken = 0; + + const stop = () => { + if (!isLoopRunning && animationFrameId === null) { + return; + } + + isLoopRunning = false; + runToken += 1; + + if (animationFrameId === null) { + return; + } + + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + }; + + const scheduleNextFrame = (token: number) => { + animationFrameId = requestAnimationFrame((timestamp) => { + animationFrameId = null; + + if (!isLoopRunning || token !== runToken) { + return; + } + + if (onFrame(timestamp) === false) { + if (token === runToken) { + isLoopRunning = false; + } + + return; + } + + if (!isLoopRunning || token !== runToken) { + return; + } + + scheduleNextFrame(token); + }); + }; + + const start = () => { + if (isLoopRunning) { + return; + } + + isLoopRunning = true; + runToken += 1; + scheduleNextFrame(runToken); + }; + + return { + isRunning: () => isLoopRunning, + start, + stop, + }; +} diff --git a/packages/twenty-website-new/src/lib/animation/index.ts b/packages/twenty-website-new/src/lib/animation/index.ts new file mode 100644 index 0000000000..8ee2cdaf0e --- /dev/null +++ b/packages/twenty-website-new/src/lib/animation/index.ts @@ -0,0 +1,17 @@ +export { + createAnimationFrameLoop, + type AnimationFrameLoopController, + type CreateAnimationFrameLoopOptions, +} from './animation-frame-loop'; +export { + DEFAULT_ANIMATED_NUMBER_DURATION_MS, + easeOutCubic, + getAnimatedNumberValue, + type AnimatedNumberEasing, + type AnimatedNumberRounder, + type GetAnimatedNumberValueParameters, +} from './animated-number'; +export { + useAnimatedNumber, + type UseAnimatedNumberOptions, +} from './use-animated-number'; diff --git a/packages/twenty-website-new/src/lib/animation/use-animated-number.ts b/packages/twenty-website-new/src/lib/animation/use-animated-number.ts new file mode 100644 index 0000000000..b88fde7d68 --- /dev/null +++ b/packages/twenty-website-new/src/lib/animation/use-animated-number.ts @@ -0,0 +1,98 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { useLatestRef } from '@/lib/react'; + +import { + DEFAULT_ANIMATED_NUMBER_DURATION_MS, + easeOutCubic, + getAnimatedNumberValue, + type AnimatedNumberEasing, + type AnimatedNumberRounder, +} from './animated-number'; +import { createAnimationFrameLoop } from './animation-frame-loop'; + +export type UseAnimatedNumberOptions = { + disabled?: boolean; + durationMs?: number; + easing?: AnimatedNumberEasing; + round?: AnimatedNumberRounder; +}; + +const DEFAULT_ANIMATED_NUMBER_ROUNDER: AnimatedNumberRounder = Math.round; + +function canScheduleAnimationFrame() { + return ( + typeof window !== 'undefined' && + typeof window.requestAnimationFrame === 'function' && + typeof window.cancelAnimationFrame === 'function' + ); +} + +function getAnimationTimestamp() { + return typeof performance === 'undefined' ? Date.now() : performance.now(); +} + +export function useAnimatedNumber( + target: number, + { + disabled = false, + durationMs = DEFAULT_ANIMATED_NUMBER_DURATION_MS, + easing = easeOutCubic, + round = DEFAULT_ANIMATED_NUMBER_ROUNDER, + }: UseAnimatedNumberOptions = {}, +): number { + const [display, setDisplay] = useState(() => round(target)); + const displayRef = useRef(display); + const easingRef = useLatestRef(easing); + const roundRef = useLatestRef(round); + + useEffect(() => { + const roundedTarget = roundRef.current(target); + const from = displayRef.current; + + if ( + disabled || + !Number.isFinite(durationMs) || + durationMs <= 0 || + from === roundedTarget || + !canScheduleAnimationFrame() + ) { + displayRef.current = roundedTarget; + setDisplay(roundedTarget); + + return; + } + + const startTime = getAnimationTimestamp(); + const animationLoop = createAnimationFrameLoop({ + onFrame: (now) => { + const elapsedMs = now - startTime; + const nextDisplay = getAnimatedNumberValue({ + durationMs, + easing: easingRef.current, + elapsedMs, + from, + round: roundRef.current, + target, + }); + + if (displayRef.current !== nextDisplay) { + displayRef.current = nextDisplay; + setDisplay(nextDisplay); + } + + return elapsedMs < durationMs; + }, + }); + + animationLoop.start(); + + return () => { + animationLoop.stop(); + }; + }, [disabled, durationMs, easingRef, roundRef, target]); + + return display; +} diff --git a/packages/twenty-website-new/src/lib/dom/__tests__/observe-element-size.test.ts b/packages/twenty-website-new/src/lib/dom/__tests__/observe-element-size.test.ts new file mode 100644 index 0000000000..4248a69900 --- /dev/null +++ b/packages/twenty-website-new/src/lib/dom/__tests__/observe-element-size.test.ts @@ -0,0 +1,113 @@ +import { + observeElementSize, + observeElementsSize, +} from '../observe-element-size'; + +type GlobalWithResizeObserver = typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; + window?: Pick; +}; + +const getGlobalWithResizeObserver = () => + globalThis as GlobalWithResizeObserver; + +describe('observeElementSize', () => { + const originalResizeObserver = getGlobalWithResizeObserver().ResizeObserver; + const originalWindow = getGlobalWithResizeObserver().window; + + afterEach(() => { + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: originalResizeObserver, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }); + + jest.restoreAllMocks(); + }); + + it('uses ResizeObserver when available', () => { + const disconnect = jest.fn(); + const observe = jest.fn(); + const element = {} as Element; + + class MockResizeObserver { + observe = observe; + disconnect = disconnect; + } + + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: MockResizeObserver, + }); + + const stopObserving = observeElementSize(element, jest.fn()); + + expect(observe).toHaveBeenCalledWith(element); + + stopObserving(); + + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it('observes multiple elements through one ResizeObserver', () => { + const disconnect = jest.fn(); + const observe = jest.fn(); + const elements = [{} as Element, {} as Element]; + + class MockResizeObserver { + observe = observe; + disconnect = disconnect; + } + + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: MockResizeObserver, + }); + + const stopObserving = observeElementsSize(elements, jest.fn()); + + expect(observe).toHaveBeenCalledTimes(2); + expect(observe).toHaveBeenNthCalledWith(1, elements[0]); + expect(observe).toHaveBeenNthCalledWith(2, elements[1]); + + stopObserving(); + + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it('falls back to window resize events when ResizeObserver is unavailable', () => { + const onResize = jest.fn(); + const element = {} as Element; + const addEventListener = jest.fn(); + const removeEventListener = jest.fn(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener, + removeEventListener, + }, + }); + + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: undefined, + }); + + const stopObserving = observeElementSize(element, onResize); + + expect(addEventListener).toHaveBeenCalledWith('resize', onResize); + + const resizeListener = addEventListener.mock.calls[0]?.[1] as () => void; + resizeListener(); + + expect(onResize).toHaveBeenCalledTimes(1); + + stopObserving(); + + expect(removeEventListener).toHaveBeenCalledWith('resize', onResize); + }); +}); diff --git a/packages/twenty-website-new/src/lib/dom/__tests__/observe-element-visibility.test.ts b/packages/twenty-website-new/src/lib/dom/__tests__/observe-element-visibility.test.ts new file mode 100644 index 0000000000..d96a1e95fe --- /dev/null +++ b/packages/twenty-website-new/src/lib/dom/__tests__/observe-element-visibility.test.ts @@ -0,0 +1,66 @@ +import { observeElementVisibility } from '../observe-element-visibility'; + +type GlobalWithIntersectionObserver = typeof globalThis & { + IntersectionObserver?: typeof IntersectionObserver; +}; + +const getGlobalWithIntersectionObserver = () => + globalThis as GlobalWithIntersectionObserver; + +describe('observeElementVisibility', () => { + const originalIntersectionObserver = + getGlobalWithIntersectionObserver().IntersectionObserver; + + afterEach(() => { + Object.defineProperty(globalThis, 'IntersectionObserver', { + configurable: true, + value: originalIntersectionObserver, + }); + + jest.restoreAllMocks(); + }); + + it('uses IntersectionObserver when available', () => { + const disconnect = jest.fn(); + const observe = jest.fn(); + const element = {} as Element; + + class MockIntersectionObserver { + observe = observe; + disconnect = disconnect; + } + + Object.defineProperty(globalThis, 'IntersectionObserver', { + configurable: true, + value: MockIntersectionObserver, + }); + + const stopObserving = observeElementVisibility(element, jest.fn(), { + rootMargin: '100px', + }); + + expect(observe).toHaveBeenCalledWith(element); + + stopObserving(); + + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it('falls back to a visible state when IntersectionObserver is unavailable', () => { + const onVisibilityChange = jest.fn(); + + Object.defineProperty(globalThis, 'IntersectionObserver', { + configurable: true, + value: undefined, + }); + + const stopObserving = observeElementVisibility( + {} as Element, + onVisibilityChange, + ); + + expect(onVisibilityChange).toHaveBeenCalledWith(true); + + stopObserving(); + }); +}); diff --git a/packages/twenty-website-new/src/lib/dom/__tests__/use-horizontal-drag-scroll.test.ts b/packages/twenty-website-new/src/lib/dom/__tests__/use-horizontal-drag-scroll.test.ts new file mode 100644 index 0000000000..1c46934c06 --- /dev/null +++ b/packages/twenty-website-new/src/lib/dom/__tests__/use-horizontal-drag-scroll.test.ts @@ -0,0 +1,46 @@ +import { getHorizontalWheelScrollLeft } from '../use-horizontal-drag-scroll'; + +describe('getHorizontalWheelScrollLeft', () => { + it('maps vertical wheel movement to horizontal scroll', () => { + expect( + getHorizontalWheelScrollLeft( + { clientWidth: 200, scrollLeft: 40, scrollWidth: 500 }, + { deltaX: 0, deltaY: 80 }, + ), + ).toBe(120); + }); + + it('ignores mostly horizontal wheel movement', () => { + expect( + getHorizontalWheelScrollLeft( + { clientWidth: 200, scrollLeft: 40, scrollWidth: 500 }, + { deltaX: 80, deltaY: 20 }, + ), + ).toBeNull(); + }); + + it('clamps scroll to the available range', () => { + expect( + getHorizontalWheelScrollLeft( + { clientWidth: 200, scrollLeft: 260, scrollWidth: 500 }, + { deltaX: 0, deltaY: 80 }, + ), + ).toBe(300); + + expect( + getHorizontalWheelScrollLeft( + { clientWidth: 200, scrollLeft: 20, scrollWidth: 500 }, + { deltaX: 0, deltaY: -80 }, + ), + ).toBe(0); + }); + + it('returns null when the scroll position would not materially change', () => { + expect( + getHorizontalWheelScrollLeft( + { clientWidth: 200, scrollLeft: 0, scrollWidth: 500 }, + { deltaX: 0, deltaY: -10 }, + ), + ).toBeNull(); + }); +}); diff --git a/packages/twenty-website-new/src/lib/dom/observe-element-size.ts b/packages/twenty-website-new/src/lib/dom/observe-element-size.ts new file mode 100644 index 0000000000..5de5bd07d6 --- /dev/null +++ b/packages/twenty-website-new/src/lib/dom/observe-element-size.ts @@ -0,0 +1,29 @@ +export const observeElementsSize = ( + elements: ReadonlyArray, + onResize: () => void, +): (() => void) => { + if (elements.length === 0) { + return () => {}; + } + + if (typeof ResizeObserver === 'function') { + const resizeObserver = new ResizeObserver(() => onResize()); + + elements.forEach((element) => resizeObserver.observe(element)); + + return () => resizeObserver.disconnect(); + } + + if (typeof window === 'undefined') { + return () => {}; + } + + window.addEventListener('resize', onResize); + + return () => window.removeEventListener('resize', onResize); +}; + +export const observeElementSize = ( + element: Element, + onResize: () => void, +): (() => void) => observeElementsSize([element], onResize); diff --git a/packages/twenty-website-new/src/lib/dom/observe-element-visibility.ts b/packages/twenty-website-new/src/lib/dom/observe-element-visibility.ts new file mode 100644 index 0000000000..0ecd7a9450 --- /dev/null +++ b/packages/twenty-website-new/src/lib/dom/observe-element-visibility.ts @@ -0,0 +1,25 @@ +export type ObserveElementVisibilityOptions = IntersectionObserverInit & { + fallbackIsVisible?: boolean; +}; + +export const observeElementVisibility = ( + element: Element, + onVisibilityChange: (isVisible: boolean) => void, + options: ObserveElementVisibilityOptions = {}, +): (() => void) => { + const { fallbackIsVisible = true, ...intersectionObserverOptions } = options; + + if (typeof IntersectionObserver === 'function') { + const intersectionObserver = new IntersectionObserver((entries) => { + onVisibilityChange(entries.some((entry) => entry.isIntersecting)); + }, intersectionObserverOptions); + + intersectionObserver.observe(element); + + return () => intersectionObserver.disconnect(); + } + + onVisibilityChange(fallbackIsVisible); + + return () => {}; +}; diff --git a/packages/twenty-website-new/src/lib/dom/use-horizontal-drag-scroll.ts b/packages/twenty-website-new/src/lib/dom/use-horizontal-drag-scroll.ts new file mode 100644 index 0000000000..1fa0ae79f1 --- /dev/null +++ b/packages/twenty-website-new/src/lib/dom/use-horizontal-drag-scroll.ts @@ -0,0 +1,165 @@ +import { + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from 'react'; + +type DragState = { + active: boolean; + pointerId: number; + startScrollLeft: number; + startX: number; +}; + +type UseHorizontalDragScrollOptions = { + wheelScrollsHorizontally?: boolean; +}; + +export type HorizontalWheelScrollState = { + clientWidth: number; + scrollLeft: number; + scrollWidth: number; +}; + +export type HorizontalWheelDelta = { + deltaX: number; + deltaY: number; +}; + +const EMPTY_DRAG_STATE: DragState = { + active: false, + pointerId: -1, + startScrollLeft: 0, + startX: 0, +}; + +const releasePointerCapture = (element: HTMLElement, pointerId: number) => { + if (pointerId < 0 || !element.hasPointerCapture(pointerId)) { + return; + } + + element.releasePointerCapture(pointerId); +}; + +export const getHorizontalWheelScrollLeft = ( + state: HorizontalWheelScrollState, + delta: HorizontalWheelDelta, +): number | null => { + if (Math.abs(delta.deltaY) <= Math.abs(delta.deltaX)) { + return null; + } + + const maxScrollLeft = Math.max(state.scrollWidth - state.clientWidth, 0); + const nextScrollLeft = Math.min( + Math.max(state.scrollLeft + delta.deltaY, 0), + maxScrollLeft, + ); + + return Math.abs(nextScrollLeft - state.scrollLeft) < 0.5 + ? null + : nextScrollLeft; +}; + +export function useHorizontalDragScroll({ + wheelScrollsHorizontally = false, +}: UseHorizontalDragScrollOptions = {}) { + const viewportRef = useRef(null); + const dragRef = useRef(EMPTY_DRAG_STATE); + const [dragging, setDragging] = useState(false); + + const endDragging = () => { + const viewport = viewportRef.current; + const pointerId = dragRef.current.pointerId; + + if (viewport) { + releasePointerCapture(viewport, pointerId); + } + + dragRef.current = EMPTY_DRAG_STATE; + setDragging(false); + }; + + const handlePointerDown = (event: ReactPointerEvent) => { + const viewport = viewportRef.current; + + if (event.pointerType !== 'mouse' || event.button !== 0 || !viewport) { + return; + } + + dragRef.current = { + active: true, + pointerId: event.pointerId, + startScrollLeft: viewport.scrollLeft, + startX: event.clientX, + }; + + viewport.setPointerCapture(event.pointerId); + setDragging(true); + event.preventDefault(); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const viewport = viewportRef.current; + const drag = dragRef.current; + + if (!drag.active || !viewport) { + return; + } + + viewport.scrollLeft = drag.startScrollLeft - (event.clientX - drag.startX); + }; + + const handlePointerUp = (event: ReactPointerEvent) => { + if (dragRef.current.pointerId !== event.pointerId) { + return; + } + + endDragging(); + }; + + useEffect(() => { + const viewport = viewportRef.current; + + if (!wheelScrollsHorizontally || !viewport) { + return; + } + + const handleWheel = (event: WheelEvent) => { + const nextScrollLeft = getHorizontalWheelScrollLeft( + { + clientWidth: viewport.clientWidth, + scrollLeft: viewport.scrollLeft, + scrollWidth: viewport.scrollWidth, + }, + { + deltaX: event.deltaX, + deltaY: event.deltaY, + }, + ); + + if (nextScrollLeft === null) { + return; + } + + viewport.scrollLeft = nextScrollLeft; + event.preventDefault(); + }; + + viewport.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + viewport.removeEventListener('wheel', handleWheel); + }; + }, [wheelScrollsHorizontally]); + + return { + dragging, + onPointerCancel: endDragging, + onPointerDown: handlePointerDown, + onPointerLeave: endDragging, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + viewportRef, + }; +} diff --git a/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts b/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts index 3e314d2ed2..3cb1d59761 100644 --- a/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts +++ b/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts @@ -25,6 +25,24 @@ describe('halftone footprint helpers', () => { }); }); + it('computes the visible covered image rect', () => { + expect( + getContainedImageRect({ + imageFit: 'cover', + imageHeight: 500, + imageWidth: 1000, + viewportHeight: 800, + viewportWidth: 800, + zoom: 1, + }), + ).toEqual({ + x: 0, + y: 0, + width: 800, + height: 800, + }); + }); + it('derives image footprint scale from preview distance', () => { expect( getImageFootprintScale({ @@ -82,6 +100,7 @@ return { getContainedImageRect, getImageFootprintScale, getMeshFootprintScale }; }; const imageArgs = { + imageFit: 'cover' as const, imageHeight: 1000, imageWidth: 1600, previewDistance: 6, diff --git a/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts b/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts new file mode 100644 index 0000000000..6d499a6732 --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts @@ -0,0 +1,85 @@ +import { DEFAULT_HALFTONE_SETTINGS } from '../state'; +import { + applySpringStep, + createHalftoneInteractionState, + resetHalftoneInteractionState, +} from '../interaction-state'; + +describe('halftone interaction state', () => { + it('hydrates pose fields while defaulting transient pointer state', () => { + expect( + createHalftoneInteractionState({ + autoElapsed: 10, + rotateElapsed: 2, + rotationX: 0.1, + rotationY: 0.2, + rotationZ: 0.3, + targetRotationX: 0.4, + targetRotationY: 0.5, + }), + ).toMatchObject({ + activePointerId: null, + autoElapsed: 10, + mouseX: 0.5, + pointerInside: false, + rotateElapsed: 2, + rotationX: 0.1, + rotationY: 0.2, + rotationZ: 0.3, + targetRotationX: 0.4, + targetRotationY: 0.5, + }); + }); + + it('applies one damped spring integration step', () => { + expect( + applySpringStep({ + current: 0, + damping: 0.5, + strength: 0.2, + target: 10, + velocity: 1, + }), + ).toEqual({ + value: 1.5, + velocity: 1.5, + }); + }); + + it('resets pointer and velocity fields without always resetting elapsed auto rotation', () => { + const interaction = createHalftoneInteractionState({ autoElapsed: 10 }); + + interaction.activePointerId = 7; + interaction.dragging = true; + interaction.mouseX = 0.1; + interaction.pointerVelocityX = 2; + interaction.rotationVelocityY = 3; + interaction.targetRotationX = 4; + + resetHalftoneInteractionState(interaction, { + ...DEFAULT_HALFTONE_SETTINGS.animation, + autoRotateEnabled: false, + }); + + expect(interaction).toMatchObject({ + activePointerId: null, + autoElapsed: 10, + dragging: false, + mouseX: 0.5, + pointerVelocityX: 0, + rotationVelocityY: 0, + targetRotationX: 0, + }); + }); + + it('resets auto rotation elapsed time when auto rotation is enabled', () => { + const interaction = createHalftoneInteractionState({ autoElapsed: 10 }); + + resetHalftoneInteractionState(interaction, { + ...DEFAULT_HALFTONE_SETTINGS.animation, + autoRotateEnabled: true, + }); + + expect(interaction.autoElapsed).toBe(0); + }); +}); diff --git a/packages/twenty-website-new/src/lib/halftone/footprint.ts b/packages/twenty-website-new/src/lib/halftone/footprint.ts index aed5df6663..c5ac2f4e51 100644 --- a/packages/twenty-website-new/src/lib/halftone/footprint.ts +++ b/packages/twenty-website-new/src/lib/halftone/footprint.ts @@ -7,7 +7,10 @@ export interface HalftoneRect { y: number; } +export type HalftoneImageFit = 'contain' | 'cover'; + export interface ImageFootprintScaleArgs { + imageFit?: HalftoneImageFit; imageHeight: number; imageWidth: number; previewDistance: number; @@ -82,12 +85,14 @@ export function getImagePreviewZoom(previewDistance: number) { } export function getContainedImageRect({ + imageFit = 'contain', imageHeight, imageWidth, viewportHeight, viewportWidth, zoom, }: { + imageFit?: HalftoneImageFit; imageHeight: number; imageWidth: number; viewportHeight: number; @@ -110,9 +115,17 @@ export function getContainedImageRect({ let fittedHeight = viewportHeight; if (imageAspect > viewAspect) { - fittedHeight = viewportWidth / imageAspect; + if (imageFit === 'cover') { + fittedWidth = viewportHeight * imageAspect; + } else { + fittedHeight = viewportWidth / imageAspect; + } } else { - fittedWidth = viewportHeight * imageAspect; + if (imageFit === 'cover') { + fittedHeight = viewportWidth / imageAspect; + } else { + fittedWidth = viewportHeight * imageAspect; + } } const scaledWidth = fittedWidth * zoom; @@ -145,6 +158,7 @@ export function getFootprintScaleFromRects( } export function getImageFootprintScale({ + imageFit = 'contain', imageHeight, imageWidth, previewDistance, @@ -152,6 +166,7 @@ export function getImageFootprintScale({ viewportWidth, }: ImageFootprintScaleArgs) { const currentRect = getContainedImageRect({ + imageFit, imageHeight, imageWidth, viewportHeight, @@ -159,6 +174,7 @@ export function getImageFootprintScale({ zoom: getImagePreviewZoom(previewDistance), }); const referenceRect = getContainedImageRect({ + imageFit, imageHeight, imageWidth, viewportHeight, @@ -312,6 +328,7 @@ function getImagePreviewZoom(previewDistance) { } function getContainedImageRect({ + imageFit = 'contain', imageHeight, imageWidth, viewportHeight, @@ -334,9 +351,17 @@ function getContainedImageRect({ let fittedHeight = viewportHeight; if (imageAspect > viewAspect) { - fittedHeight = viewportWidth / imageAspect; + if (imageFit === 'cover') { + fittedWidth = viewportHeight * imageAspect; + } else { + fittedHeight = viewportWidth / imageAspect; + } } else { - fittedWidth = viewportHeight * imageAspect; + if (imageFit === 'cover') { + fittedHeight = viewportWidth / imageAspect; + } else { + fittedWidth = viewportHeight * imageAspect; + } } const scaledWidth = fittedWidth * zoom; @@ -369,6 +394,7 @@ function getFootprintScaleFromRects(currentRect, referenceRect) { } function getImageFootprintScale({ + imageFit = 'contain', imageHeight, imageWidth, previewDistance, @@ -376,6 +402,7 @@ function getImageFootprintScale({ viewportWidth, }) { const currentRect = getContainedImageRect({ + imageFit, imageHeight, imageWidth, viewportHeight, @@ -383,6 +410,7 @@ function getImageFootprintScale({ zoom: getImagePreviewZoom(previewDistance), }); const referenceRect = getContainedImageRect({ + imageFit, imageHeight, imageWidth, viewportHeight, diff --git a/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts b/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts index 1c10cb08b2..bedbf19434 100644 --- a/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts +++ b/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts @@ -19,6 +19,13 @@ interface HalftoneGeometrySpec { type GeometryCacheEntry = THREE.BufferGeometry | Promise; +export type ImportedGeometryNormalizationOptions = { + postRotateZ?: number; + useLegacyNormalization?: boolean; +}; + +const LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET = 2.75; + function mergeGeometries(geometries: THREE.BufferGeometry[]) { if (geometries.length === 1) { return geometries[0]; @@ -117,7 +124,12 @@ function mergeGeometries(geometries: THREE.BufferGeometry[]) { const EMPTY_TEXTURE_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; -function normalizeImportedGeometry(geometry: THREE.BufferGeometry) { +function normalizeImportedGeometry( + geometry: THREE.BufferGeometry, + options: ImportedGeometryNormalizationOptions = {}, +) { + const { postRotateZ = 0, useLegacyNormalization = false } = options; + geometry.computeBoundingBox(); let boundingBox = geometry.boundingBox; @@ -128,20 +140,25 @@ function normalizeImportedGeometry(geometry: THREE.BufferGeometry) { boundingBox?.getSize(size); geometry.translate(-center.x, -center.y, -center.z); - const dimensions = [size.x, size.y, size.z]; - const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); + if (!useLegacyNormalization) { + const dimensions = [size.x, size.y, size.z]; + const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); - if (thinnestAxis === 0) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); - } else if (thinnestAxis === 1) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + if (thinnestAxis === 0) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); + } else if (thinnestAxis === 1) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + } } geometry.computeBoundingBox(); geometry.computeBoundingSphere(); const radius = geometry.boundingSphere?.radius || 1; - const scale = 1.6 / radius; + const scale = useLegacyNormalization + ? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET / + Math.max(size.x, size.y, size.z, 0.001) + : 1.6 / radius; geometry.scale(scale, scale, scale); geometry.computeBoundingBox(); @@ -150,6 +167,10 @@ function normalizeImportedGeometry(geometry: THREE.BufferGeometry) { boundingBox?.getCenter(center); geometry.translate(-center.x, -center.y, -center.z); + if (postRotateZ !== 0) { + geometry.rotateZ(postRotateZ); + } + geometry.computeVertexNormals(); geometry.computeBoundingBox(); geometry.computeBoundingSphere(); @@ -166,7 +187,11 @@ function createLoadingManager() { return loadingManager; } -function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) { +function extractMergedGeometry( + root: THREE.Object3D, + emptyMessage: string, + geometryOptions?: ImportedGeometryNormalizationOptions, +) { root.updateMatrixWorld(true); const geometries: THREE.BufferGeometry[] = []; @@ -190,13 +215,17 @@ function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) { throw new Error(emptyMessage); } - return normalizeImportedGeometry(mergeGeometries(geometries)); + return normalizeImportedGeometry( + mergeGeometries(geometries), + geometryOptions, + ); } function parseFbxGeometry( buffer: ArrayBuffer, resourcePath: string, label: string, + geometryOptions?: ImportedGeometryNormalizationOptions, ) { const originalWarn = console.warn; @@ -217,6 +246,7 @@ function parseFbxGeometry( return extractMergedGeometry( root, `${label} did not contain any mesh geometry.`, + geometryOptions, ); } finally { console.warn = originalWarn; @@ -227,6 +257,7 @@ function parseGlbGeometry( buffer: ArrayBuffer, resourcePath: string, label: string, + geometryOptions?: ImportedGeometryNormalizationOptions, ) { const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath(DRACO_DECODER_PATH); @@ -244,6 +275,7 @@ function parseGlbGeometry( extractMergedGeometry( gltf.scene, `${label} did not contain any mesh geometry.`, + geometryOptions, ), ); } catch (error) { @@ -275,6 +307,7 @@ export async function loadImportedGeometryFromUrl( loader: HalftoneModelLoader, modelUrl: string, label: string, + geometryOptions?: ImportedGeometryNormalizationOptions, ) { const response = await fetch(modelUrl); @@ -285,10 +318,10 @@ export async function loadImportedGeometryFromUrl( const buffer = await response.arrayBuffer(); if (loader === 'fbx') { - return parseFbxGeometry(buffer, '', label); + return parseFbxGeometry(buffer, '', label, geometryOptions); } - return parseGlbGeometry(buffer, '', label); + return parseGlbGeometry(buffer, '', label, geometryOptions); } function makePolarShape( diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx index 6ac5a9e6a4..c8683f2991 100644 --- a/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx +++ b/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx @@ -4,8 +4,10 @@ import { getImageFootprintScale, getImagePreviewZoom, getMeshFootprintScale, + type HalftoneImageFit, VIRTUAL_RENDER_HEIGHT, } from '@/lib/halftone/footprint'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; import { applyHalftoneMaterialSettings, createHalftoneMaterial, @@ -15,11 +17,24 @@ import { type HalftoneTransmissionMaterial, renderHalftoneMaterialScene, } from '@/lib/halftone/materials'; +import { + applySpringStep, + createHalftoneInteractionState, + resetHalftoneInteractionState, + type HalftoneInteractionState, +} from '@/lib/halftone/interaction-state'; import type { HalftoneExportPose, HalftoneStudioSettings, } from '@/lib/halftone/state'; -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; +import { runCleanupTasks } from '@/lib/lifecycle/run-cleanup-tasks'; +import { useLatestRef } from '@/lib/react'; +import { + createSiteWebGlRenderer, + createVisualRenderLoop, + evaluateWebGlPolicy, + type VisualRenderLoop, +} from '@/lib/visual-runtime'; import { styled } from '@linaria/react'; import { type MutableRefObject, useEffect, useRef } from 'react'; import * as THREE from 'three'; @@ -73,6 +88,7 @@ const imagePassthroughFragmentShader = ` uniform vec2 viewportSize; uniform float zoom; uniform float contrast; + uniform float imageFit; varying vec2 vUv; @@ -84,10 +100,18 @@ const imagePassthroughFragmentShader = ` if (imageAspect > viewAspect) { float scale = viewAspect / imageAspect; - uv.y = (uv.y - 0.5) / scale + 0.5; + if (imageFit > 0.5) { + uv.x = (uv.x - 0.5) * scale + 0.5; + } else { + uv.y = (uv.y - 0.5) / scale + 0.5; + } } else { float scale = imageAspect / viewAspect; - uv.x = (uv.x - 0.5) / scale + 0.5; + if (imageFit > 0.5) { + uv.y = (uv.y - 0.5) * scale + 0.5; + } else { + uv.x = (uv.x - 0.5) / scale + 0.5; + } } uv = (uv - 0.5) / zoom + 0.5; @@ -285,15 +309,32 @@ export type HalftoneSnapshotFn = ( }, ) => Promise; +export type HalftoneImageInteractionSettings = { + hoverFadeIn: number; + hoverFadeOut: number; + pointerFollow: number; + pointerVelocityDamping: number; +}; + +const DEFAULT_IMAGE_INTERACTION_SETTINGS: HalftoneImageInteractionSettings = { + hoverFadeIn: IMAGE_HOVER_FADE_IN, + hoverFadeOut: IMAGE_HOVER_FADE_OUT, + pointerFollow: IMAGE_POINTER_FOLLOW, + pointerVelocityDamping: IMAGE_POINTER_VELOCITY_DAMPING, +}; + type HalftoneCanvasProps = { geometry: THREE.BufferGeometry | null; initialPose?: Partial; imageElement: HTMLImageElement | null; + imageFit?: HalftoneImageFit; + imageInteraction?: Partial; onFirstInteraction: () => void; onPoseChange: (pose: HalftoneExportPose) => void; previewDistance: number; settings: HalftoneStudioSettings; snapshotRef?: MutableRefObject; + virtualRenderHeight?: number; }; type SceneResources = { @@ -325,33 +366,6 @@ type SceneResources = { transmissionTarget: THREE.WebGLRenderTarget; }; -type InteractionState = { - activePointerId: number | null; - autoElapsed: number; - dragging: boolean; - hoverStrength: number; - mouseX: number; - mouseY: number; - pointerInside: boolean; - pointerVelocityX: number; - pointerVelocityY: number; - pointerX: number; - pointerY: number; - rotateElapsed: number; - rotationX: number; - rotationVelocityX: number; - rotationY: number; - rotationVelocityY: number; - rotationZ: number; - rotationVelocityZ: number; - smoothedMouseX: number; - smoothedMouseY: number; - targetRotationX: number; - targetRotationY: number; - velocityX: number; - velocityY: number; -}; - type PixelBounds = { maxX: number; maxY: number; @@ -367,6 +381,15 @@ function createRenderTarget(width: number, height: number) { }); } +function resolveImageInteractionSettings( + settings?: Partial, +): HalftoneImageInteractionSettings { + return { + ...DEFAULT_IMAGE_INTERACTION_SETTINGS, + ...settings, + }; +} + function syncImageElementTexture( resources: SceneResources, imageElement: HTMLImageElement | null, @@ -399,37 +422,6 @@ function syncImageElementTexture( ); } -function createInteractionState( - initialPose?: Partial, -): InteractionState { - return { - activePointerId: null, - autoElapsed: initialPose?.autoElapsed ?? 0, - dragging: false, - hoverStrength: 0, - mouseX: 0.5, - mouseY: 0.5, - pointerInside: false, - pointerVelocityX: 0, - pointerVelocityY: 0, - pointerX: 0, - pointerY: 0, - rotateElapsed: initialPose?.rotateElapsed ?? 0, - rotationX: initialPose?.rotationX ?? 0, - rotationVelocityX: 0, - rotationY: initialPose?.rotationY ?? 0, - rotationVelocityY: 0, - rotationZ: initialPose?.rotationZ ?? 0, - rotationVelocityZ: 0, - smoothedMouseX: 0.5, - smoothedMouseY: 0.5, - targetRotationX: initialPose?.targetRotationX ?? 0, - targetRotationY: initialPose?.targetRotationY ?? 0, - velocityX: 0, - velocityY: 0, - }; -} - function getAlphaCropBounds( pixels: Uint8Array, width: number, @@ -556,83 +548,43 @@ function syncResources( updateHalftone(resources, settings); } -function applySpringStep( - current: number, - target: number, - velocity: number, - strength: number, - damping: number, -) { - const nextVelocity = (velocity + (target - current) * strength) * damping; - const nextValue = current + nextVelocity; - - return { - value: nextValue, - velocity: nextVelocity, - }; -} - -function resetInteractionState( - interactionState: InteractionState, - animation: HalftoneStudioSettings['animation'], -) { - interactionState.activePointerId = null; - interactionState.dragging = false; - interactionState.hoverStrength = 0; - interactionState.mouseX = 0.5; - interactionState.mouseY = 0.5; - interactionState.pointerInside = false; - interactionState.pointerVelocityX = 0; - interactionState.pointerVelocityY = 0; - interactionState.smoothedMouseX = 0.5; - interactionState.smoothedMouseY = 0.5; - interactionState.targetRotationX = 0; - interactionState.targetRotationY = 0; - interactionState.velocityX = 0; - interactionState.velocityY = 0; - interactionState.rotationVelocityX = 0; - interactionState.rotationVelocityY = 0; - interactionState.rotationVelocityZ = 0; - - if (animation.autoRotateEnabled) { - interactionState.autoElapsed = 0; - } -} - export function HalftoneCanvas({ geometry, initialPose, imageElement, + imageFit = 'contain', + imageInteraction, onFirstInteraction, onPoseChange, previewDistance, settings, snapshotRef, + virtualRenderHeight = VIRTUAL_RENDER_HEIGHT, }: HalftoneCanvasProps) { const mountReference = useRef(null); const resourcesReference = useRef(null); const settingsReference = useRef(settings); - const interactionReference = useRef( - createInteractionState(initialPose), + const interactionReference = useRef( + createHalftoneInteractionState(initialPose), ); const animationReference = useRef(settings.animation); const didInteractReference = useRef(false); const initialPoseReference = useRef(initialPose); - const poseChangeReference = useRef(onPoseChange); + const poseChangeReference = useLatestRef(onPoseChange); const previewDistanceReference = useRef(previewDistance); const geometryReference = useRef(geometry); const imageElementReference = useRef(imageElement); - const snapshotReference = useRef(snapshotRef); + const imageFitReference = useRef(imageFit); + const imageInteractionReference = useRef( + resolveImageInteractionSettings(imageInteraction), + ); + const snapshotReference = useLatestRef(snapshotRef); useEffect(() => { initialPoseReference.current = initialPose; - interactionReference.current = createInteractionState(initialPose); + interactionReference.current = createHalftoneInteractionState(initialPose); }, [initialPose]); - useEffect(() => { - poseChangeReference.current = onPoseChange; - }, [onPoseChange]); - useEffect(() => { previewDistanceReference.current = previewDistance; @@ -666,7 +618,7 @@ export function HalftoneCanvas({ prev.hoverLightEnabled !== next.hoverLightEnabled || prev.dragFlowEnabled !== next.dragFlowEnabled ) { - resetInteractionState(interactionReference.current, next); + resetHalftoneInteractionState(interactionReference.current, next); } if ( @@ -696,10 +648,6 @@ export function HalftoneCanvas({ resources.mesh.geometry = geometry; }, [geometry]); - useEffect(() => { - snapshotReference.current = snapshotRef; - }, [snapshotRef]); - useEffect(() => { imageElementReference.current = imageElement; @@ -712,6 +660,24 @@ export function HalftoneCanvas({ syncImageElementTexture(resources, imageElement); }, [imageElement]); + useEffect(() => { + imageFitReference.current = imageFit; + + const resources = resourcesReference.current; + + if (!resources) { + return; + } + + resources.imageMaterial.uniforms.imageFit.value = + imageFit === 'cover' ? 1 : 0; + }, [imageFit]); + + useEffect(() => { + imageInteractionReference.current = + resolveImageInteractionSettings(imageInteraction); + }, [imageInteraction]); + useEffect(() => { const container = mountReference.current; const initialSettings = settingsReference.current; @@ -722,15 +688,12 @@ export function HalftoneCanvas({ return; } - let animationFrameId = 0; let cancelled = false; - let isVisible = typeof document === 'undefined' ? true : !document.hidden; - let isIntersecting = true; - const shouldRender = () => isVisible && isIntersecting; + let renderLoop: VisualRenderLoop | null = null; const getWidth = () => Math.max(container.clientWidth, 1); const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); + const getVirtualHeight = () => Math.max(virtualRenderHeight, getHeight()); const getVirtualWidth = () => Math.max( Math.round( @@ -745,7 +708,30 @@ export function HalftoneCanvas({ const getRenderWidth = () => Math.max(Math.round(getVirtualWidth() * getRenderScale()), 1); - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); + const webGlPolicy = evaluateWebGlPolicy(); + + if (!webGlPolicy.allowed) { + return; + } + + let renderer: THREE.WebGLRenderer; + + try { + renderer = createSiteWebGlRenderer({ + antialias: false, + alpha: true, + onContextLost: () => { + renderLoop?.stop(); + }, + }); + } catch (error) { + if (process.env.NODE_ENV !== 'production') { + console.error('Halftone renderer failed:', error); + } + + return; + } + renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setPixelRatio(1); renderer.setClearColor(0x000000, 0); @@ -760,16 +746,22 @@ export function HalftoneCanvas({ container.appendChild(canvas); let cleanup = () => { - renderer.dispose(); - resourcesReference.current = null; - - if (activeSnapshotRef) { - activeSnapshotRef.current = null; - } - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } + runCleanupTasks([ + () => renderer.dispose(), + () => { + resourcesReference.current = null; + }, + () => { + if (activeSnapshotRef) { + activeSnapshotRef.current = null; + } + }, + () => { + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + }, + ]); }; void (async () => { @@ -943,6 +935,7 @@ export function HalftoneCanvas({ }, zoom: { value: getImagePreviewZoom(initialPreviewDistance) }, contrast: { value: initialSettings.halftone.imageContrast }, + imageFit: { value: imageFitReference.current === 'cover' ? 1 : 0 }, }, vertexShader: passThroughVertexShader, fragmentShader: imagePassthroughFragmentShader, @@ -1015,6 +1008,7 @@ export function HalftoneCanvas({ return getImageFootprintScale({ imageHeight: imageSize.y, + imageFit: imageFitReference.current, imageWidth: imageSize.x, previewDistance: activePreviewDistance, viewportHeight, @@ -1318,8 +1312,7 @@ export function HalftoneCanvas({ ); }; - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); + const stopObservingSize = observeElementSize(container, syncSize); const updatePointerPosition = ( event: PointerEvent, @@ -1568,9 +1561,6 @@ export function HalftoneCanvas({ return; } - animationFrameId = shouldRender() - ? window.requestAnimationFrame(renderFrame) - : 0; clock.update(timestamp); const interaction = interactionReference.current; @@ -1601,13 +1591,14 @@ export function HalftoneCanvas({ halftoneMaterial.uniforms.cropToBounds.value = isImageMode ? 1 : 0; if (isImageMode) { + const imageInteractionSettings = imageInteractionReference.current; const hoverEasing = 1 - Math.exp( -delta * (interaction.pointerInside - ? IMAGE_HOVER_FADE_IN - : IMAGE_HOVER_FADE_OUT), + ? imageInteractionSettings.hoverFadeIn + : imageInteractionSettings.hoverFadeOut), ); interaction.hoverStrength += ((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) * @@ -1615,12 +1606,14 @@ export function HalftoneCanvas({ interaction.smoothedMouseX += (interaction.mouseX - interaction.smoothedMouseX) * - IMAGE_POINTER_FOLLOW; + imageInteractionSettings.pointerFollow; interaction.smoothedMouseY += (interaction.mouseY - interaction.smoothedMouseY) * - IMAGE_POINTER_FOLLOW; - interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING; - interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING; + imageInteractionSettings.pointerFollow; + interaction.pointerVelocityX *= + imageInteractionSettings.pointerVelocityDamping; + interaction.pointerVelocityY *= + imageInteractionSettings.pointerVelocityDamping; halftoneMaterial.uniforms.interactionUv.value.set( interaction.smoothedMouseX, @@ -1842,27 +1835,27 @@ export function HalftoneCanvas({ } if (activeSettings.animation.springReturnEnabled) { - const springX = applySpringStep( - interaction.rotationX, - targetX, - interaction.rotationVelocityX, - activeSettings.animation.springStrength, - activeSettings.animation.springDamping, - ); - const springY = applySpringStep( - interaction.rotationY, - targetY, - interaction.rotationVelocityY, - activeSettings.animation.springStrength, - activeSettings.animation.springDamping, - ); - const springZ = applySpringStep( - interaction.rotationZ, - baseRotationZ, - interaction.rotationVelocityZ, - activeSettings.animation.springStrength, - activeSettings.animation.springDamping, - ); + const springX = applySpringStep({ + current: interaction.rotationX, + damping: activeSettings.animation.springDamping, + strength: activeSettings.animation.springStrength, + target: targetX, + velocity: interaction.rotationVelocityX, + }); + const springY = applySpringStep({ + current: interaction.rotationY, + damping: activeSettings.animation.springDamping, + strength: activeSettings.animation.springStrength, + target: targetY, + velocity: interaction.rotationVelocityY, + }); + const springZ = applySpringStep({ + current: interaction.rotationZ, + damping: activeSettings.animation.springDamping, + strength: activeSettings.animation.springStrength, + target: baseRotationZ, + velocity: interaction.rotationVelocityZ, + }); interaction.rotationX = springX.value; interaction.rotationY = springY.value; @@ -1992,82 +1985,61 @@ export function HalftoneCanvas({ renderer.render(postScene, orthographicCamera); }; - const resumeIfNeeded = () => { - if (!cancelled && shouldRender() && animationFrameId === 0) { - animationFrameId = window.requestAnimationFrame(renderFrame); - } - }; - - const handleVisibilityChange = () => { - isVisible = !document.hidden; - if (!shouldRender() && animationFrameId !== 0) { - window.cancelAnimationFrame(animationFrameId); - animationFrameId = 0; - } else { - resumeIfNeeded(); - } - }; - document.addEventListener('visibilitychange', handleVisibilityChange); - - const intersectionObserver = new IntersectionObserver( - (entries) => { - isIntersecting = entries.some((entry) => entry.isIntersecting); - if (!shouldRender() && animationFrameId !== 0) { - window.cancelAnimationFrame(animationFrameId); - animationFrameId = 0; - } else { - resumeIfNeeded(); - } - }, - { rootMargin: '100px' }, - ); - intersectionObserver.observe(container); - - renderFrame(); + renderLoop = createVisualRenderLoop({ + renderFrame, + shouldRender: () => !cancelled, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); cleanup = () => { - resizeObserver.disconnect(); - intersectionObserver.disconnect(); - document.removeEventListener( - 'visibilitychange', - handleVisibilityChange, - ); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - canvas.removeEventListener('pointerup', handlePointerUp); - canvas.removeEventListener('pointercancel', handlePointerCancel); - window.removeEventListener('blur', handleWindowBlur); - canvas.removeEventListener('pointerdown', handlePointerDown); - window.cancelAnimationFrame(animationFrameId); - clock.dispose(); + runCleanupTasks([ + () => stopObservingSize(), + () => renderLoop?.dispose(), + () => canvas.removeEventListener('pointermove', handlePointerMove), + () => canvas.removeEventListener('pointerleave', handlePointerLeave), + () => canvas.removeEventListener('pointerup', handlePointerUp), + () => + canvas.removeEventListener('pointercancel', handlePointerCancel), + () => window.removeEventListener('blur', handleWindowBlur), + () => canvas.removeEventListener('pointerdown', handlePointerDown), + () => clock.dispose(), - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - imageMaterial.dispose(); + () => blurHorizontalMaterial.dispose(), + () => blurVerticalMaterial.dispose(), + () => halftoneMaterial.dispose(), + () => imageMaterial.dispose(), - if (resources.imageTexture) { - resources.imageTexture.dispose(); - } + () => { + if (resources.imageTexture) { + resources.imageTexture.dispose(); + } + }, - fullScreenGeometry.dispose(); - material.dispose(); - sceneTarget.dispose(); - transmissionBacksideTarget.dispose(); - transmissionTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - disposeHalftoneMaterialAssets(materialAssets); - renderer.dispose(); - resourcesReference.current = null; - - if (activeSnapshotRef) { - activeSnapshotRef.current = null; - } - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } + () => fullScreenGeometry.dispose(), + () => material.dispose(), + () => sceneTarget.dispose(), + () => transmissionBacksideTarget.dispose(), + () => transmissionTarget.dispose(), + () => blurTargetA.dispose(), + () => blurTargetB.dispose(), + () => disposeHalftoneMaterialAssets(materialAssets), + () => renderer.dispose(), + () => { + resourcesReference.current = null; + }, + () => { + if (activeSnapshotRef) { + activeSnapshotRef.current = null; + } + }, + () => { + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + }, + ]); }; })(); @@ -2075,7 +2047,12 @@ export function HalftoneCanvas({ cancelled = true; cleanup(); }; - }, [onFirstInteraction]); + }, [ + onFirstInteraction, + poseChangeReference, + snapshotReference, + virtualRenderHeight, + ]); return ( ; + imageUrl: string; + initialPose?: Partial; + onFirstInteraction?: () => void; + onImageLoadError?: (error: Error) => void; + onPoseChange?: (pose: HalftoneExportPose) => void; + previewDistance: number; + settings: HalftoneStudioSettings; + snapshotRef?: MutableRefObject; + virtualRenderHeight?: number; +}; + +const noopFirstInteraction = () => {}; +const noopPoseChange = (_pose: HalftoneExportPose) => {}; + +function createImageLoadError(imageUrl: string) { + return new Error(`Halftone image failed to load: ${imageUrl}`); +} + +function usePlaneGeometry() { + const geometryReference = useRef(null); + + if (geometryReference.current === null) { + geometryReference.current = new THREE.PlaneGeometry(1, 1); + } + + useEffect(() => { + const geometry = geometryReference.current; + + return () => { + geometry?.dispose(); + geometryReference.current = null; + }; + }, []); + + return geometryReference.current; +} + +function useImageElement({ + crossOrigin, + imageUrl, + onImageLoadErrorReference, +}: { + crossOrigin: HTMLImageElement['crossOrigin'] | undefined; + imageUrl: string; + onImageLoadErrorReference: RefObject<((error: Error) => void) | undefined>; +}) { + const [imageElement, setImageElement] = useState( + null, + ); + + useEffect(() => { + let cancelled = false; + const image = new Image(); + + setImageElement(null); + + if (typeof crossOrigin !== 'undefined') { + image.crossOrigin = crossOrigin; + } + + image.decoding = 'async'; + image.onload = () => { + if (!cancelled) { + setImageElement(image); + } + }; + image.onerror = () => { + if (!cancelled) { + const handler = onImageLoadErrorReference.current; + const error = createImageLoadError(imageUrl); + + if (handler) { + handler(error); + return; + } + + if (process.env.NODE_ENV !== 'production') { + console.error(error); + } + } + }; + image.src = imageUrl; + + return () => { + cancelled = true; + image.onload = null; + image.onerror = null; + image.src = ''; + }; + }, [crossOrigin, imageUrl, onImageLoadErrorReference]); + + return imageElement; +} + +export function HalftoneImageCanvas({ + crossOrigin, + imageFit, + imageInteraction, + imageUrl, + initialPose, + onFirstInteraction = noopFirstInteraction, + onImageLoadError, + onPoseChange = noopPoseChange, + previewDistance, + settings, + snapshotRef, + virtualRenderHeight, +}: HalftoneImageCanvasProps) { + const imageLoadErrorReference = useLatestRef(onImageLoadError); + const imageElement = useImageElement({ + crossOrigin, + imageUrl, + onImageLoadErrorReference: imageLoadErrorReference, + }); + const geometry = usePlaneGeometry(); + + if (!imageElement || !geometry) { + return null; + } + + return ( + + ); +} diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx new file mode 100644 index 0000000000..67d2a7c8f9 --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useLatestRef } from '@/lib/react'; +import { + type MutableRefObject, + type RefObject, + useEffect, + useState, +} from 'react'; +import type * as THREE from 'three'; + +import { + createFallbackGeometry, + loadImportedGeometryFromUrl, + type ImportedGeometryNormalizationOptions, +} from './geometry-registry'; +import { HalftoneCanvas, type HalftoneSnapshotFn } from './halftone-canvas'; +import type { + HalftoneExportPose, + HalftoneModelLoader, + HalftoneStudioSettings, +} from './state'; + +type HalftoneModelCanvasProps = { + geometryOptions?: ImportedGeometryNormalizationOptions; + initialPose?: Partial; + loader?: HalftoneModelLoader; + modelLabel?: string; + modelUrl: string; + onFirstInteraction?: () => void; + onGeometryLoadError?: (error: Error) => void; + onPoseChange?: (pose: HalftoneExportPose) => void; + previewDistance: number; + settings: HalftoneStudioSettings; + snapshotRef?: MutableRefObject; + virtualRenderHeight?: number; +}; + +const noopFirstInteraction = () => {}; +const noopPoseChange = (_pose: HalftoneExportPose) => {}; + +function toGeometryLoadError(error: unknown, modelUrl: string) { + if (error instanceof Error) { + return error; + } + + return new Error(`Halftone model failed to load: ${modelUrl}`); +} + +function useModelGeometry({ + geometryOptions, + loader, + modelLabel, + modelUrl, + onGeometryLoadErrorReference, +}: { + geometryOptions: ImportedGeometryNormalizationOptions | undefined; + loader: HalftoneModelLoader; + modelLabel: string; + modelUrl: string; + onGeometryLoadErrorReference: RefObject<((error: Error) => void) | undefined>; +}) { + const [geometry, setGeometry] = useState(null); + + useEffect(() => { + let cancelled = false; + let loadedGeometry: THREE.BufferGeometry | null = null; + + setGeometry(null); + + void loadImportedGeometryFromUrl( + loader, + modelUrl, + modelLabel, + geometryOptions, + ) + .catch((error: unknown) => { + const normalizedError = toGeometryLoadError(error, modelUrl); + const handler = onGeometryLoadErrorReference.current; + + if (handler) { + handler(normalizedError); + } else if (process.env.NODE_ENV !== 'production') { + console.error(normalizedError); + } + + return createFallbackGeometry(); + }) + .then((nextGeometry) => { + if (cancelled) { + nextGeometry.dispose(); + return; + } + + loadedGeometry = nextGeometry; + setGeometry(nextGeometry); + }); + + return () => { + cancelled = true; + loadedGeometry?.dispose(); + }; + }, [ + geometryOptions, + loader, + modelLabel, + modelUrl, + onGeometryLoadErrorReference, + ]); + + return geometry; +} + +export function HalftoneModelCanvas({ + geometryOptions, + initialPose, + loader = 'glb', + modelLabel, + modelUrl, + onFirstInteraction = noopFirstInteraction, + onGeometryLoadError, + onPoseChange = noopPoseChange, + previewDistance, + settings, + snapshotRef, + virtualRenderHeight, +}: HalftoneModelCanvasProps) { + const geometryLoadErrorReference = useLatestRef(onGeometryLoadError); + const geometry = useModelGeometry({ + geometryOptions, + loader, + modelLabel: modelLabel ?? modelUrl.split('/').pop() ?? 'halftone model', + modelUrl, + onGeometryLoadErrorReference: geometryLoadErrorReference, + }); + + if (!geometry) { + return null; + } + + return ( + + ); +} diff --git a/packages/twenty-website-new/src/lib/halftone/index.ts b/packages/twenty-website-new/src/lib/halftone/index.ts index 1d7cad3163..f3d0ccb274 100644 --- a/packages/twenty-website-new/src/lib/halftone/index.ts +++ b/packages/twenty-website-new/src/lib/halftone/index.ts @@ -1,6 +1,16 @@ -export { HalftoneCanvas } from './halftone-canvas'; +export { + HalftoneCanvas, + type HalftoneImageInteractionSettings, + type HalftoneSnapshotFn, +} from './halftone-canvas'; +export { HalftoneImageCanvas } from './halftone-image-canvas'; +export { HalftoneModelCanvas } from './halftone-model-canvas'; -export { loadImportedGeometryFromUrl } from './geometry-registry'; +export { + createFallbackGeometry, + loadImportedGeometryFromUrl, + type ImportedGeometryNormalizationOptions, +} from './geometry-registry'; export { DEFAULT_GLASS_ANIMATION_SETTINGS, @@ -27,6 +37,7 @@ export type { HalftoneMaterialSettings, HalftoneMaterialSurface, HalftoneStudioSettings, + HalftoneStudioSettingsOverrides, HalftoneToneTarget, } from './state'; @@ -35,6 +46,7 @@ export { getImageFootprintScale, getImagePreviewZoom, getMeshFootprintScale, + type HalftoneImageFit, HALFTONE_FOOTPRINT_RUNTIME_SOURCE, REFERENCE_PREVIEW_DISTANCE, VIRTUAL_RENDER_HEIGHT, diff --git a/packages/twenty-website-new/src/lib/halftone/interaction-state.ts b/packages/twenty-website-new/src/lib/halftone/interaction-state.ts new file mode 100644 index 0000000000..f65a72097c --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/interaction-state.ts @@ -0,0 +1,108 @@ +import type { + HalftoneExportPose, + HalftoneStudioSettings, +} from '@/lib/halftone/state'; + +export type HalftoneInteractionState = { + activePointerId: number | null; + autoElapsed: number; + dragging: boolean; + hoverStrength: number; + mouseX: number; + mouseY: number; + pointerInside: boolean; + pointerVelocityX: number; + pointerVelocityY: number; + pointerX: number; + pointerY: number; + rotateElapsed: number; + rotationVelocityX: number; + rotationVelocityY: number; + rotationVelocityZ: number; + rotationX: number; + rotationY: number; + rotationZ: number; + smoothedMouseX: number; + smoothedMouseY: number; + targetRotationX: number; + targetRotationY: number; + velocityX: number; + velocityY: number; +}; + +export const createHalftoneInteractionState = ( + initialPose?: Partial, +): HalftoneInteractionState => ({ + activePointerId: null, + autoElapsed: initialPose?.autoElapsed ?? 0, + dragging: false, + hoverStrength: 0, + mouseX: 0.5, + mouseY: 0.5, + pointerInside: false, + pointerVelocityX: 0, + pointerVelocityY: 0, + pointerX: 0, + pointerY: 0, + rotateElapsed: initialPose?.rotateElapsed ?? 0, + rotationVelocityX: 0, + rotationVelocityY: 0, + rotationVelocityZ: 0, + rotationX: initialPose?.rotationX ?? 0, + rotationY: initialPose?.rotationY ?? 0, + rotationZ: initialPose?.rotationZ ?? 0, + smoothedMouseX: 0.5, + smoothedMouseY: 0.5, + targetRotationX: initialPose?.targetRotationX ?? 0, + targetRotationY: initialPose?.targetRotationY ?? 0, + velocityX: 0, + velocityY: 0, +}); + +export const applySpringStep = ({ + current, + damping, + strength, + target, + velocity, +}: { + current: number; + damping: number; + strength: number; + target: number; + velocity: number; +}) => { + const nextVelocity = (velocity + (target - current) * strength) * damping; + + return { + value: current + nextVelocity, + velocity: nextVelocity, + }; +}; + +export const resetHalftoneInteractionState = ( + interactionState: HalftoneInteractionState, + animation: HalftoneStudioSettings['animation'], +) => { + interactionState.activePointerId = null; + interactionState.dragging = false; + interactionState.hoverStrength = 0; + interactionState.mouseX = 0.5; + interactionState.mouseY = 0.5; + interactionState.pointerInside = false; + interactionState.pointerVelocityX = 0; + interactionState.pointerVelocityY = 0; + interactionState.smoothedMouseX = 0.5; + interactionState.smoothedMouseY = 0.5; + interactionState.targetRotationX = 0; + interactionState.targetRotationY = 0; + interactionState.velocityX = 0; + interactionState.velocityY = 0; + interactionState.rotationVelocityX = 0; + interactionState.rotationVelocityY = 0; + interactionState.rotationVelocityZ = 0; + + if (animation.autoRotateEnabled) { + interactionState.autoElapsed = 0; + } +}; diff --git a/packages/twenty-website-new/src/lib/halftone/state.ts b/packages/twenty-website-new/src/lib/halftone/state.ts index 9f98f0a6a3..79c1b0a0e4 100644 --- a/packages/twenty-website-new/src/lib/halftone/state.ts +++ b/packages/twenty-website-new/src/lib/halftone/state.ts @@ -111,6 +111,19 @@ export interface HalftoneStudioSettings { animation: HalftoneAnimationSettings; } +export type HalftoneStudioSettingsOverrides = Partial< + Omit< + HalftoneStudioSettings, + 'lighting' | 'material' | 'halftone' | 'background' | 'animation' + > +> & { + lighting?: Partial; + material?: Partial; + halftone?: Partial; + background?: Partial; + animation?: Partial; +}; + export interface HalftoneGeometrySpec { key: string; label: string; @@ -533,7 +546,7 @@ function materialMatches( } export function normalizeHalftoneStudioSettings( - settings?: Partial, + settings?: HalftoneStudioSettingsOverrides, ): HalftoneStudioSettings { const sourceMode = settings?.sourceMode ?? DEFAULT_HALFTONE_SETTINGS.sourceMode; diff --git a/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts b/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts index a44b1e9394..0385ae71ad 100644 --- a/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts +++ b/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts @@ -1,46 +1,46 @@ import { detectLocale } from '../detect-locale'; describe('detectLocale', () => { - it('returns the cookie value when it is a supported locale', () => { + it('returns the cookie value when it is a published website locale', () => { expect( detectLocale({ - cookieValue: 'fr-FR', + cookieValue: 'en', acceptLanguageHeader: 'de-DE,de;q=0.9', }), - ).toBe('fr-FR'); + ).toBe('en'); }); - it('ignores an unsupported cookie value and falls back to Accept-Language', () => { + it('ignores unsupported cookie and Accept-Language values', () => { expect( detectLocale({ cookieValue: 'xx-YY', acceptLanguageHeader: 'de-DE,de;q=0.9', }), - ).toBe('de-DE'); + ).toBe('en'); }); - it('matches the highest-quality Accept-Language entry first', () => { + it('matches the highest-quality published Accept-Language entry first', () => { expect( detectLocale({ acceptLanguageHeader: 'en;q=0.5,fr-FR;q=0.9,de;q=0.7', }), - ).toBe('fr-FR'); + ).toBe('en'); }); - it('falls back from a regional tag to the language family', () => { + it('falls back to the source locale when a regional language is not published', () => { expect( detectLocale({ acceptLanguageHeader: 'fr-CA', }), - ).toBe('fr-FR'); + ).toBe('en'); }); - it('falls back from a bare language tag to the first matching regional locale', () => { + it('falls back to the source locale when a bare language is not published', () => { expect( detectLocale({ acceptLanguageHeader: 'pt;q=1.0,en;q=0.5', }), - ).toBe('pt-BR'); + ).toBe('en'); }); it('returns the source locale when no input is provided', () => { @@ -60,7 +60,7 @@ describe('detectLocale', () => { detectLocale({ acceptLanguageHeader: 'fr-FR;q=not-a-number,de-DE;q=0.5', }), - ).toBe('de-DE'); + ).toBe('en'); }); it('parses quality values when parameters carry leading whitespace around the semicolon', () => { @@ -68,6 +68,6 @@ describe('detectLocale', () => { detectLocale({ acceptLanguageHeader: 'en;q=0.1, fr-FR ; q=0.9, de-DE ; q=0.5', }), - ).toBe('fr-FR'); + ).toBe('en'); }); }); diff --git a/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts b/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts index c11453a062..6ba2cf0347 100644 --- a/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts +++ b/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts @@ -1,8 +1,9 @@ import { localizeHref, stripLocale } from '../localize-href'; describe('localizeHref', () => { - it('prefixes a non-default locale onto an internal absolute path', () => { - expect(localizeHref('fr-FR', '/pricing')).toBe('/fr-FR/pricing'); + it('does not emit locale prefixes for locales the website does not publish yet', () => { + expect(localizeHref('fr-FR', '/pricing')).toBe('/pricing'); + expect(localizeHref('de-DE', '/pricing')).toBe('/pricing'); }); it('returns paths unprefixed for the default locale (English at root)', () => { @@ -10,24 +11,22 @@ describe('localizeHref', () => { expect(localizeHref('en', '/')).toBe('/'); }); - it('prefixes a non-default locale onto the root path', () => { - expect(localizeHref('fr-FR', '/')).toBe('/fr-FR/'); + it('keeps the root path unprefixed for unpublished locales', () => { + expect(localizeHref('fr-FR', '/')).toBe('/'); }); it('preserves query strings and hash fragments', () => { expect(localizeHref('de-DE', '/customers?ref=hero#top')).toBe( - '/de-DE/customers?ref=hero#top', + '/customers?ref=hero#top', ); expect(localizeHref('en', '/customers?ref=hero#top')).toBe( '/customers?ref=hero#top', ); }); - it('does not double-prefix paths that already start with a non-default locale', () => { - expect(localizeHref('fr-FR', '/de-DE/why-twenty')).toBe( - '/de-DE/why-twenty', - ); - expect(localizeHref('fr-FR', '/fr-FR/pricing')).toBe('/fr-FR/pricing'); + it('strips legacy locale prefixes when targeting an unpublished locale', () => { + expect(localizeHref('fr-FR', '/de-DE/why-twenty')).toBe('/why-twenty'); + expect(localizeHref('fr-FR', '/fr-FR/pricing')).toBe('/pricing'); }); it('strips a redundant /en prefix when targeting the default locale', () => { @@ -35,9 +34,9 @@ describe('localizeHref', () => { expect(localizeHref('en', '/en')).toBe('/'); }); - it('rewrites an /en-prefixed path onto the active non-default locale', () => { - expect(localizeHref('fr-FR', '/en/why-twenty')).toBe('/fr-FR/why-twenty'); - expect(localizeHref('fr-FR', '/en')).toBe('/fr-FR/'); + it('strips an /en-prefixed path when targeting an unpublished locale', () => { + expect(localizeHref('fr-FR', '/en/why-twenty')).toBe('/why-twenty'); + expect(localizeHref('fr-FR', '/en')).toBe('/'); }); it('passes external https URLs through unchanged', () => { @@ -67,14 +66,14 @@ describe('localizeHref', () => { it('handles a locale segment immediately followed by a query string', () => { expect(localizeHref('en', '/en?ref=hero')).toBe('/?ref=hero'); - expect(localizeHref('fr-FR', '/en?ref=hero')).toBe('/fr-FR/?ref=hero'); - expect(localizeHref('fr-FR', '/de-DE?ref=hero')).toBe('/de-DE?ref=hero'); + expect(localizeHref('fr-FR', '/en?ref=hero')).toBe('/?ref=hero'); + expect(localizeHref('fr-FR', '/de-DE?ref=hero')).toBe('/?ref=hero'); }); it('handles a locale segment immediately followed by a hash fragment', () => { expect(localizeHref('en', '/en#anchor')).toBe('/#anchor'); - expect(localizeHref('fr-FR', '/en#anchor')).toBe('/fr-FR/#anchor'); - expect(localizeHref('fr-FR', '/de-DE#anchor')).toBe('/de-DE#anchor'); + expect(localizeHref('fr-FR', '/en#anchor')).toBe('/#anchor'); + expect(localizeHref('fr-FR', '/de-DE#anchor')).toBe('/#anchor'); }); }); diff --git a/packages/twenty-website-new/src/lib/i18n/app-locale-set.ts b/packages/twenty-website-new/src/lib/i18n/app-locale-set.ts index c7c089b589..7e858b0712 100644 --- a/packages/twenty-website-new/src/lib/i18n/app-locale-set.ts +++ b/packages/twenty-website-new/src/lib/i18n/app-locale-set.ts @@ -1,14 +1,30 @@ -import { APP_LOCALES, type AppLocale } from 'twenty-shared/translations'; +import { + APP_LOCALES, + SOURCE_LOCALE, + type AppLocale, +} from 'twenty-shared/translations'; const APP_LOCALE_VALUES: readonly AppLocale[] = Object.values(APP_LOCALES); -export const isPublicAppLocale = (locale: AppLocale): boolean => +const isKnownPublicLocale = (locale: AppLocale): boolean => !locale.startsWith('pseudo-'); +export const KNOWN_PUBLIC_APP_LOCALE_LIST: readonly AppLocale[] = + APP_LOCALE_VALUES.filter(isKnownPublicLocale); + +export const WEBSITE_LOCALE_LIST: readonly AppLocale[] = [SOURCE_LOCALE]; + +const WEBSITE_LOCALE_SET: ReadonlySet = new Set(WEBSITE_LOCALE_LIST); + +export const isPublicAppLocale = (locale: AppLocale): boolean => + WEBSITE_LOCALE_SET.has(locale); + export const APP_LOCALE_LIST: readonly AppLocale[] = APP_LOCALE_VALUES; -export const PUBLIC_APP_LOCALE_LIST: readonly AppLocale[] = - APP_LOCALE_VALUES.filter(isPublicAppLocale); +export const PUBLIC_APP_LOCALE_LIST: readonly AppLocale[] = WEBSITE_LOCALE_LIST; + +export const KNOWN_PUBLIC_APP_LOCALE_BY_RAW: ReadonlyMap = + new Map(KNOWN_PUBLIC_APP_LOCALE_LIST.map((locale) => [locale, locale])); export const APP_LOCALE_BY_RAW: ReadonlyMap = new Map( PUBLIC_APP_LOCALE_LIST.map((locale) => [locale, locale]), diff --git a/packages/twenty-website-new/src/lib/i18n/index.ts b/packages/twenty-website-new/src/lib/i18n/index.ts index 7bd9dbf54a..7b34b19dd2 100644 --- a/packages/twenty-website-new/src/lib/i18n/index.ts +++ b/packages/twenty-website-new/src/lib/i18n/index.ts @@ -2,7 +2,10 @@ export { APP_LOCALE_BY_LANGUAGE, APP_LOCALE_BY_RAW, APP_LOCALE_LIST, + KNOWN_PUBLIC_APP_LOCALE_BY_RAW, + KNOWN_PUBLIC_APP_LOCALE_LIST, PUBLIC_APP_LOCALE_LIST, + WEBSITE_LOCALE_LIST, isPublicAppLocale, } from './app-locale-set'; export { createI18nInstance } from './create-i18n-instance'; diff --git a/packages/twenty-website-new/src/lib/i18n/localize-href.ts b/packages/twenty-website-new/src/lib/i18n/localize-href.ts index 6a0d999143..9928cc6e56 100644 --- a/packages/twenty-website-new/src/lib/i18n/localize-href.ts +++ b/packages/twenty-website-new/src/lib/i18n/localize-href.ts @@ -1,6 +1,9 @@ import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; -import { APP_LOCALE_BY_RAW } from './app-locale-set'; +import { + KNOWN_PUBLIC_APP_LOCALE_BY_RAW, + isPublicAppLocale, +} from './app-locale-set'; const findFirstSegmentEnd = (path: string): number => { for (let i = 1; i < path.length; i += 1) { @@ -22,18 +25,16 @@ export const localizeHref = (locale: AppLocale, href: string): string => { const segmentEnd = findFirstSegmentEnd(href); const firstSegment = href.slice(1, segmentEnd); - const existingLocale = APP_LOCALE_BY_RAW.get(firstSegment); - - if (existingLocale !== undefined && existingLocale !== SOURCE_LOCALE) { - return href; - } + const existingLocale = KNOWN_PUBLIC_APP_LOCALE_BY_RAW.get(firstSegment); const unprefixed = - existingLocale === SOURCE_LOCALE + existingLocale !== undefined ? buildTailFromSegmentEnd(href, segmentEnd) : href; - return locale === SOURCE_LOCALE ? unprefixed : `/${locale}${unprefixed}`; + return locale === SOURCE_LOCALE || !isPublicAppLocale(locale) + ? unprefixed + : `/${locale}${unprefixed}`; }; export const stripLocale = (pathname: string): string => { @@ -41,7 +42,7 @@ export const stripLocale = (pathname: string): string => { const segmentEnd = findFirstSegmentEnd(pathname); const firstSegment = pathname.slice(1, segmentEnd); - if (!APP_LOCALE_BY_RAW.has(firstSegment)) return pathname; + if (!KNOWN_PUBLIC_APP_LOCALE_BY_RAW.has(firstSegment)) return pathname; return buildTailFromSegmentEnd(pathname, segmentEnd); }; diff --git a/packages/twenty-website-new/src/lib/lifecycle/__tests__/run-cleanup-tasks.test.ts b/packages/twenty-website-new/src/lib/lifecycle/__tests__/run-cleanup-tasks.test.ts new file mode 100644 index 0000000000..5097f79118 --- /dev/null +++ b/packages/twenty-website-new/src/lib/lifecycle/__tests__/run-cleanup-tasks.test.ts @@ -0,0 +1,34 @@ +import { runCleanupTasks } from '../run-cleanup-tasks'; + +describe('runCleanupTasks', () => { + it('runs cleanup tasks in declaration order', () => { + const calls: string[] = []; + + runCleanupTasks( + [() => calls.push('first'), () => calls.push('second')], + jest.fn(), + ); + + expect(calls).toEqual(['first', 'second']); + }); + + it('continues cleanup after a task throws', () => { + const error = new Error('dispose failed'); + const calls: string[] = []; + const onError = jest.fn(); + + runCleanupTasks( + [ + () => calls.push('first'), + () => { + throw error; + }, + () => calls.push('third'), + ], + onError, + ); + + expect(calls).toEqual(['first', 'third']); + expect(onError).toHaveBeenCalledWith(error); + }); +}); diff --git a/packages/twenty-website-new/src/lib/lifecycle/__tests__/timeout-registry.test.ts b/packages/twenty-website-new/src/lib/lifecycle/__tests__/timeout-registry.test.ts new file mode 100644 index 0000000000..8a046386f6 --- /dev/null +++ b/packages/twenty-website-new/src/lib/lifecycle/__tests__/timeout-registry.test.ts @@ -0,0 +1,73 @@ +import { createTimeoutRegistry } from '../timeout-registry'; + +function createManualTimerScheduler() { + let nextHandle = 1; + const callbacks = new Map void>(); + + return { + callbacks, + clearTimeout: jest.fn((handle: number) => { + callbacks.delete(handle); + }), + run: (handle: number) => { + callbacks.get(handle)?.(); + }, + setTimeout: jest.fn((callback: () => void) => { + const handle = nextHandle; + nextHandle += 1; + callbacks.set(handle, callback); + return handle; + }), + }; +} + +describe('createTimeoutRegistry', () => { + it('tracks and runs scheduled callbacks once', () => { + const scheduler = createManualTimerScheduler(); + const registry = createTimeoutRegistry(scheduler); + const callback = jest.fn(); + + registry.schedule(callback, 200); + + expect(registry.pendingCount()).toBe(1); + scheduler.run(1); + + expect(callback).toHaveBeenCalledTimes(1); + expect(registry.pendingCount()).toBe(0); + }); + + it('cancels one scheduled callback without clearing unrelated callbacks', () => { + const scheduler = createManualTimerScheduler(); + const registry = createTimeoutRegistry(scheduler); + const firstCallback = jest.fn(); + const secondCallback = jest.fn(); + + const cancelFirst = registry.schedule(firstCallback, 200); + registry.schedule(secondCallback, 300); + + cancelFirst(); + + expect(scheduler.clearTimeout).toHaveBeenCalledWith(1); + expect(registry.pendingCount()).toBe(1); + + scheduler.run(1); + scheduler.run(2); + + expect(firstCallback).not.toHaveBeenCalled(); + expect(secondCallback).toHaveBeenCalledTimes(1); + expect(registry.pendingCount()).toBe(0); + }); + + it('clears every pending timeout', () => { + const scheduler = createManualTimerScheduler(); + const registry = createTimeoutRegistry(scheduler); + + registry.schedule(jest.fn(), 200); + registry.schedule(jest.fn(), 300); + registry.clearAll(); + + expect(scheduler.clearTimeout).toHaveBeenCalledWith(1); + expect(scheduler.clearTimeout).toHaveBeenCalledWith(2); + expect(registry.pendingCount()).toBe(0); + }); +}); diff --git a/packages/twenty-website-new/src/lib/lifecycle/run-cleanup-tasks.ts b/packages/twenty-website-new/src/lib/lifecycle/run-cleanup-tasks.ts new file mode 100644 index 0000000000..c848d7d649 --- /dev/null +++ b/packages/twenty-website-new/src/lib/lifecycle/run-cleanup-tasks.ts @@ -0,0 +1,20 @@ +export type CleanupErrorHandler = (error: unknown) => void; + +export const reportCleanupErrorInDevelopment: CleanupErrorHandler = (error) => { + if (process.env.NODE_ENV !== 'production') { + console.error('Cleanup task failed:', error); + } +}; + +export const runCleanupTasks = ( + cleanupTasks: ReadonlyArray<() => void>, + onError: CleanupErrorHandler = reportCleanupErrorInDevelopment, +) => { + cleanupTasks.forEach((cleanupTask) => { + try { + cleanupTask(); + } catch (error) { + onError(error); + } + }); +}; diff --git a/packages/twenty-website-new/src/lib/lifecycle/timeout-registry.ts b/packages/twenty-website-new/src/lib/lifecycle/timeout-registry.ts new file mode 100644 index 0000000000..cec49a10fc --- /dev/null +++ b/packages/twenty-website-new/src/lib/lifecycle/timeout-registry.ts @@ -0,0 +1,53 @@ +export type TimeoutHandle = number | ReturnType; + +export type TimeoutRegistry = { + clearAll: () => void; + pendingCount: () => number; + schedule: (callback: () => void, delayMs: number) => () => void; +}; + +type CreateTimeoutRegistryOptions = { + clearTimeout?: (handle: Handle) => void; + setTimeout?: (callback: () => void, delayMs: number) => Handle; +}; + +export function createTimeoutRegistry({ + clearTimeout: clearTimeoutFn = globalThis.clearTimeout as unknown as ( + handle: Handle, + ) => void, + setTimeout: setTimeoutFn = globalThis.setTimeout as unknown as ( + callback: () => void, + delayMs: number, + ) => Handle, +}: CreateTimeoutRegistryOptions = {}): TimeoutRegistry { + const handles = new Set(); + + const cancel = (handle: Handle) => { + if (!handles.delete(handle)) { + return; + } + + clearTimeoutFn(handle); + }; + + const clearAll = () => { + Array.from(handles).forEach(cancel); + }; + + const schedule = (callback: () => void, delayMs: number) => { + const handle = setTimeoutFn(() => { + handles.delete(handle); + callback(); + }, delayMs); + + handles.add(handle); + + return () => cancel(handle); + }; + + return { + clearAll, + pendingCount: () => handles.size, + schedule, + }; +} diff --git a/packages/twenty-website-new/src/lib/react/index.ts b/packages/twenty-website-new/src/lib/react/index.ts new file mode 100644 index 0000000000..853765abc7 --- /dev/null +++ b/packages/twenty-website-new/src/lib/react/index.ts @@ -0,0 +1,2 @@ +export { useLatestRef } from './use-latest-ref'; +export { useTimeoutRegistry } from './use-timeout-registry'; diff --git a/packages/twenty-website-new/src/lib/react/use-latest-ref.ts b/packages/twenty-website-new/src/lib/react/use-latest-ref.ts new file mode 100644 index 0000000000..1cfc6ba22b --- /dev/null +++ b/packages/twenty-website-new/src/lib/react/use-latest-ref.ts @@ -0,0 +1,7 @@ +import { useRef, type RefObject } from 'react'; + +export const useLatestRef = (value: Value): RefObject => { + const reference = useRef(value); + reference.current = value; + return reference; +}; diff --git a/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts b/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts new file mode 100644 index 0000000000..18e3dfa259 --- /dev/null +++ b/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts @@ -0,0 +1,21 @@ +import { + createTimeoutRegistry, + type TimeoutRegistry, +} from '@/lib/lifecycle/timeout-registry'; +import { useEffect, useRef } from 'react'; + +export function useTimeoutRegistry(): TimeoutRegistry { + const registryReference = useRef(null); + + registryReference.current ??= createTimeoutRegistry(); + + useEffect(() => { + const registry = registryReference.current; + + return () => { + registry?.clearAll(); + }; + }, []); + + return registryReference.current; +} diff --git a/packages/twenty-website-new/src/lib/scroll/use-scheduled-on-scroll.ts b/packages/twenty-website-new/src/lib/scroll/use-scheduled-on-scroll.ts index 2b37a79233..c0e31ff53f 100644 --- a/packages/twenty-website-new/src/lib/scroll/use-scheduled-on-scroll.ts +++ b/packages/twenty-website-new/src/lib/scroll/use-scheduled-on-scroll.ts @@ -2,6 +2,8 @@ import { useEffect } from 'react'; +import { createAnimationFrameLoop } from '@/lib/animation'; + type UseScheduledOnScrollOptions = { enabled?: boolean; fireImmediately?: boolean; @@ -18,19 +20,14 @@ export function useScheduledOnScroll( return; } - let rafId: number | null = null; + const scrollTask = createAnimationFrameLoop({ + onFrame: () => { + callback(); + return false; + }, + }); - const flush = () => { - rafId = null; - callback(); - }; - - const schedule = () => { - if (rafId !== null) { - return; - } - rafId = window.requestAnimationFrame(flush); - }; + const schedule = scrollTask.start; if (fireImmediately) { callback(); @@ -41,9 +38,7 @@ export function useScheduledOnScroll( return () => { window.removeEventListener('scroll', schedule); window.removeEventListener('resize', schedule); - if (rafId !== null) { - window.cancelAnimationFrame(rafId); - } + scrollTask.stop(); }; }, [callback, enabled, fireImmediately]); } diff --git a/packages/twenty-website-new/src/lib/seo/__tests__/build-page-metadata.test.ts b/packages/twenty-website-new/src/lib/seo/__tests__/build-page-metadata.test.ts index 704a133e5b..1d6e5e1c57 100644 --- a/packages/twenty-website-new/src/lib/seo/__tests__/build-page-metadata.test.ts +++ b/packages/twenty-website-new/src/lib/seo/__tests__/build-page-metadata.test.ts @@ -19,6 +19,7 @@ describe('buildPageMetadata', () => { description: 'Product page description.', }); + expect(metadata.title).toEqual({ absolute: 'Product | Twenty' }); expect(metadata.alternates).toMatchObject({ canonical: '/product' }); expect(metadata.openGraph).toMatchObject({ title: 'Product | Twenty', @@ -35,7 +36,7 @@ describe('buildPageMetadata', () => { }); }); - it('emits a prefixed canonical for non-default locales', () => { + it('falls back to the source canonical for locales the website does not publish', () => { const metadata = buildPageMetadata({ locale: 'fr-FR', path: '/product', @@ -43,10 +44,11 @@ describe('buildPageMetadata', () => { description: 'd', }); - expect(metadata.alternates).toMatchObject({ canonical: '/fr-FR/product' }); + expect(metadata.alternates).toMatchObject({ canonical: '/product' }); + expect(metadata.openGraph).toMatchObject({ locale: 'en' }); }); - it('emits hreflang language alternates: English unprefixed, others prefixed, x-default unprefixed', () => { + it('emits hreflang alternates only for published website locales', () => { const metadata = buildPageMetadata({ locale: 'fr-FR', path: '/pricing', @@ -58,8 +60,8 @@ describe('buildPageMetadata', () => { | Record | undefined; expect(languages?.en).toBe('/pricing'); - expect(languages?.['fr-FR']).toBe('/fr-FR/pricing'); - expect(languages?.['zh-CN']).toBe('/zh-CN/pricing'); + expect(languages?.['fr-FR']).toBeUndefined(); + expect(languages?.['zh-CN']).toBeUndefined(); expect(languages?.['x-default']).toBe('/pricing'); }); @@ -85,11 +87,11 @@ describe('buildPageMetadata', () => { description: 'd', }); - expect(metadata.alternates).toMatchObject({ canonical: '/de-DE' }); + expect(metadata.alternates).toMatchObject({ canonical: '/' }); const languages = metadata.alternates?.languages as | Record | undefined; - expect(languages?.['de-DE']).toBe('/de-DE'); + expect(languages?.['de-DE']).toBeUndefined(); expect(languages?.en).toBe('/'); expect(languages?.['x-default']).toBe('/'); }); diff --git a/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts b/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts index 10180e4e39..0b2070eb21 100644 --- a/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts +++ b/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts @@ -1,7 +1,10 @@ import type { Metadata } from 'next'; import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; -import { PUBLIC_APP_LOCALE_LIST } from '@/lib/i18n/app-locale-set'; +import { + PUBLIC_APP_LOCALE_LIST, + isPublicAppLocale, +} from '@/lib/i18n/app-locale-set'; import { getSiteUrl } from './site-url'; @@ -22,7 +25,7 @@ const normalizePath = (path: string): string => path.startsWith('/') ? path : `/${path}`; const localizePath = (locale: AppLocale, normalizedPath: string): string => { - if (locale === SOURCE_LOCALE) { + if (locale === SOURCE_LOCALE || !isPublicAppLocale(locale)) { return normalizedPath; } return normalizedPath === '/' ? `/${locale}` : `/${locale}${normalizedPath}`; @@ -50,7 +53,8 @@ export function buildPageMetadata({ }: BuildPageMetadataInput): Metadata { const siteUrl = getSiteUrl(); const normalizedPath = normalizePath(path); - const canonical = localizePath(locale, normalizedPath); + const metadataLocale = isPublicAppLocale(locale) ? locale : SOURCE_LOCALE; + const canonical = localizePath(metadataLocale, normalizedPath); const ogImages = ogImage === undefined @@ -64,7 +68,7 @@ export function buildPageMetadata({ ]; const baseMetadata: Metadata = { - title, + title: { absolute: title }, description, alternates: { canonical, @@ -75,7 +79,7 @@ export function buildPageMetadata({ description, url: canonical, siteName: SITE_NAME, - locale, + locale: metadataLocale, type, ...(ogImages && { images: ogImages }), }, diff --git a/packages/twenty-website-new/src/lib/seo/build-route-metadata.ts b/packages/twenty-website-new/src/lib/seo/build-route-metadata.ts new file mode 100644 index 0000000000..8c54fa813b --- /dev/null +++ b/packages/twenty-website-new/src/lib/seo/build-route-metadata.ts @@ -0,0 +1,26 @@ +import { WEBSITE_ROUTES, type WebsiteRouteId } from '@/lib/website-routing'; + +import { buildLocalizedMetadata } from './build-localized-metadata'; +import type { BuildPageMetadataInput } from './build-page-metadata'; + +type BuildRouteMetadataOptions = Partial< + Omit +>; + +export const buildRouteMetadata = ( + routeId: WebsiteRouteId, + options?: BuildRouteMetadataOptions, +) => { + const route = WEBSITE_ROUTES[routeId]; + + if (route === undefined) { + throw new Error(`Unknown website route: ${routeId}`); + } + + return buildLocalizedMetadata({ + path: route.path, + title: route.title, + description: route.description, + ...options, + }); +}; diff --git a/packages/twenty-website-new/src/lib/seo/index.ts b/packages/twenty-website-new/src/lib/seo/index.ts index 067bf5b00e..232200f171 100644 --- a/packages/twenty-website-new/src/lib/seo/index.ts +++ b/packages/twenty-website-new/src/lib/seo/index.ts @@ -3,4 +3,5 @@ export { buildPageMetadata, type BuildPageMetadataInput, } from './build-page-metadata'; +export { buildRouteMetadata } from './build-route-metadata'; export { getAbsoluteUrl, getSiteUrl } from './site-url'; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts new file mode 100644 index 0000000000..b8d18b3feb --- /dev/null +++ b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts @@ -0,0 +1,205 @@ +import { + createVisualRenderLoop, + type VisualRenderLoopDocument, +} from '../visual-render-loop'; + +function createAnimationFrameScheduler() { + let nextHandle = 1; + const callbacks = new Map(); + + return { + cancelAnimationFrame: jest.fn((handle: number) => { + callbacks.delete(handle); + }), + requestAnimationFrame: jest.fn((callback: FrameRequestCallback) => { + const handle = nextHandle; + nextHandle += 1; + callbacks.set(handle, callback); + + return handle; + }), + runFrame: (handle: number, timestamp = 0) => { + const callback = callbacks.get(handle); + callbacks.delete(handle); + callback?.(timestamp); + }, + }; +} + +function createDocumentVisibilityStub(initialHidden = false) { + const listeners = new Set(); + const documentStub: VisualRenderLoopDocument = { + addEventListener: jest.fn((_type, listener) => { + listeners.add(listener); + }), + hidden: initialHidden, + removeEventListener: jest.fn((_type, listener) => { + listeners.delete(listener); + }), + }; + + return { + documentStub, + setHidden: (hidden: boolean) => { + documentStub.hidden = hidden; + listeners.forEach((listener) => { + if (typeof listener === 'function') { + listener(new Event('visibilitychange')); + return; + } + + listener.handleEvent(new Event('visibilitychange')); + }); + }, + }; +} + +describe('createVisualRenderLoop', () => { + it('schedules one frame at a time', () => { + const scheduler = createAnimationFrameScheduler(); + const loop = createVisualRenderLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + document: null, + renderFrame: jest.fn(), + requestAnimationFrame: scheduler.requestAnimationFrame, + }); + + loop.start(); + loop.start(); + + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + + it('continues scheduling after each rendered frame', () => { + const scheduler = createAnimationFrameScheduler(); + const renderFrame = jest.fn(); + const loop = createVisualRenderLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + document: null, + renderFrame, + requestAnimationFrame: scheduler.requestAnimationFrame, + }); + + loop.start(); + scheduler.runFrame(1, 16); + + expect(renderFrame).toHaveBeenCalledWith(16, { + deltaSeconds: 0, + elapsedSeconds: 0, + timestamp: 16, + }); + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2); + + scheduler.runFrame(2, 32); + + expect(renderFrame).toHaveBeenLastCalledWith(32, { + deltaSeconds: 0.016, + elapsedSeconds: 0.016, + timestamp: 32, + }); + }); + + it('stops until explicitly restarted when a frame returns false', () => { + const scheduler = createAnimationFrameScheduler(); + const renderFrame = jest.fn(() => false); + const loop = createVisualRenderLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + document: null, + renderFrame, + requestAnimationFrame: scheduler.requestAnimationFrame, + }); + + loop.start(); + scheduler.runFrame(1, 16); + + expect(renderFrame).toHaveBeenCalledWith(16, { + deltaSeconds: 0, + elapsedSeconds: 0, + timestamp: 16, + }); + expect(loop.isRunning()).toBe(false); + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1); + + loop.start(); + + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2); + }); + + it('pauses and resumes with document visibility', () => { + const scheduler = createAnimationFrameScheduler(); + const { documentStub, setHidden } = createDocumentVisibilityStub(); + const loop = createVisualRenderLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + document: documentStub, + renderFrame: jest.fn(), + requestAnimationFrame: scheduler.requestAnimationFrame, + }); + + loop.start(); + setHidden(true); + + expect(scheduler.cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(loop.isRunning()).toBe(false); + + setHidden(false); + + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2); + }); + + it('pauses and resumes with target visibility', () => { + const scheduler = createAnimationFrameScheduler(); + const targetVisibilityController: { + update: ((isVisible: boolean) => void) | null; + } = { update: null }; + const loop = createVisualRenderLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + document: null, + renderFrame: jest.fn(), + requestAnimationFrame: scheduler.requestAnimationFrame, + target: {} as Element, + visibilityObserver: (_target, onVisibilityChange) => { + targetVisibilityController.update = onVisibilityChange; + + return jest.fn(); + }, + }); + + loop.start(); + const updateTargetVisibility = targetVisibilityController.update; + + if (!updateTargetVisibility) { + throw new Error('Target visibility observer was not registered'); + } + + updateTargetVisibility(false); + + expect(scheduler.cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(loop.isRunning()).toBe(false); + + updateTargetVisibility(true); + + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2); + }); + + it('contains render errors and stops the loop', () => { + const scheduler = createAnimationFrameScheduler(); + const error = new Error('frame failed'); + const onFrameError = jest.fn(); + const loop = createVisualRenderLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + document: null, + onFrameError, + renderFrame: () => { + throw error; + }, + requestAnimationFrame: scheduler.requestAnimationFrame, + }); + + loop.start(); + scheduler.runFrame(1, 16); + + expect(onFrameError).toHaveBeenCalledWith(error); + expect(loop.isRunning()).toBe(false); + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts b/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts index 394a2f7d0d..c0e7d3a713 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts @@ -52,3 +52,24 @@ export function createSiteWebGlRenderer( return renderer; } + +export type SiteWebGlRendererCreationFailureHandler = (error: unknown) => void; + +export const reportSiteWebGlRendererCreationFailure: SiteWebGlRendererCreationFailureHandler = + (error) => { + if (process.env.NODE_ENV !== 'production') { + console.error('WebGL renderer creation failed:', error); + } + }; + +export function tryCreateSiteWebGlRenderer( + parameters?: SiteWebGlRendererParameters, + onError: SiteWebGlRendererCreationFailureHandler = reportSiteWebGlRendererCreationFailure, +): THREE.WebGLRenderer | null { + try { + return createSiteWebGlRenderer(parameters); + } catch (error) { + onError(error); + return null; + } +} diff --git a/packages/twenty-website-new/src/lib/visual-runtime/frame-timer.ts b/packages/twenty-website-new/src/lib/visual-runtime/frame-timer.ts deleted file mode 100644 index 6a35a1ff14..0000000000 --- a/packages/twenty-website-new/src/lib/visual-runtime/frame-timer.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type FrameTimer = { - tick: () => number; - getElapsed: () => number; -}; - -export function createFrameTimer(): FrameTimer { - const startedAt = performance.now(); - let previousTickAt: number | null = null; - - return { - tick: () => { - const now = performance.now(); - if (previousTickAt === null) { - previousTickAt = now; - return 0; - } - const deltaSeconds = (now - previousTickAt) / 1000; - previousTickAt = now; - return deltaSeconds; - }, - getElapsed: () => (performance.now() - startedAt) / 1000, - }; -} diff --git a/packages/twenty-website-new/src/lib/visual-runtime/index.ts b/packages/twenty-website-new/src/lib/visual-runtime/index.ts index 2b0d0d1086..97219956a3 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/index.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/index.ts @@ -20,10 +20,24 @@ export { WebGlErrorBoundary } from './webgl-error-boundary'; export { WebGlMount } from './webgl-mount'; export { createSiteWebGlRenderer, + reportSiteWebGlRendererCreationFailure, + tryCreateSiteWebGlRenderer, + type SiteWebGlRendererCreationFailureHandler, type SiteWebGlRendererParameters, } from './create-site-webgl-renderer'; -export { createFrameTimer, type FrameTimer } from './frame-timer'; +export { + createVisualRenderLoop, + reportVisualRenderLoopErrorInDevelopment, + type CreateVisualRenderLoopOptions, + type VisualRenderLoop, + type VisualRenderLoopCanceller, + type VisualRenderLoopDocument, + type VisualRenderLoopErrorHandler, + type VisualRenderLoopFrame, + type VisualRenderLoopFrameRenderer, + type VisualRenderLoopScheduler, +} from './visual-render-loop'; export { createBoundedFailureCache, diff --git a/packages/twenty-website-new/src/lib/visual-runtime/textures/glass-environment.ts b/packages/twenty-website-new/src/lib/visual-runtime/textures/glass-environment.ts deleted file mode 100644 index a26acfe134..0000000000 --- a/packages/twenty-website-new/src/lib/visual-runtime/textures/glass-environment.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const GLASS_ENVIRONMENT_TEXTURE_URL = - '/illustrations/common/glass-environment.jpg' as const; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/use-webgl-policy.ts b/packages/twenty-website-new/src/lib/visual-runtime/use-webgl-policy.ts index 8dfe2cdcbf..c967a87c03 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/use-webgl-policy.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/use-webgl-policy.ts @@ -29,10 +29,19 @@ export function useWebGlPolicy(): WebGlPolicyDecision { '(prefers-reduced-motion: reduce)', ); const handleMotionChange = () => setDecision(evaluateWebGlPolicy()); - mediaQueryList.addEventListener('change', handleMotionChange); + + if (typeof mediaQueryList.addEventListener === 'function') { + mediaQueryList.addEventListener('change', handleMotionChange); + + return () => { + mediaQueryList.removeEventListener('change', handleMotionChange); + }; + } + + mediaQueryList.addListener(handleMotionChange); return () => { - mediaQueryList.removeEventListener('change', handleMotionChange); + mediaQueryList.removeListener(handleMotionChange); }; }, []); diff --git a/packages/twenty-website-new/src/lib/visual-runtime/visual-render-loop.ts b/packages/twenty-website-new/src/lib/visual-runtime/visual-render-loop.ts new file mode 100644 index 0000000000..5ed9c61ba1 --- /dev/null +++ b/packages/twenty-website-new/src/lib/visual-runtime/visual-render-loop.ts @@ -0,0 +1,221 @@ +import { + observeElementVisibility, + type ObserveElementVisibilityOptions, +} from '@/lib/dom/observe-element-visibility'; +import { runCleanupTasks } from '@/lib/lifecycle/run-cleanup-tasks'; + +export type VisualRenderLoop = { + dispose: () => void; + isRunning: () => boolean; + start: () => void; + stop: () => void; +}; + +export type VisualRenderLoopErrorHandler = (error: unknown) => void; + +export type VisualRenderLoopDocument = { + addEventListener: Document['addEventListener']; + hidden: boolean; + removeEventListener: Document['removeEventListener']; +}; + +export type VisualRenderLoopScheduler = ( + callback: FrameRequestCallback, +) => number; + +export type VisualRenderLoopCanceller = (handle: number) => void; +export type VisualRenderLoopFrame = { + deltaSeconds: number; + elapsedSeconds: number; + timestamp: DOMHighResTimeStamp; +}; +export type VisualRenderLoopFrameRenderer = ( + timestamp: DOMHighResTimeStamp, + frame: VisualRenderLoopFrame, +) => boolean | void; + +export type CreateVisualRenderLoopOptions = { + cancelAnimationFrame?: VisualRenderLoopCanceller; + document?: VisualRenderLoopDocument | null; + onFrameError?: VisualRenderLoopErrorHandler; + pauseWhenDocumentHidden?: boolean; + renderFrame: VisualRenderLoopFrameRenderer; + requestAnimationFrame?: VisualRenderLoopScheduler; + shouldRender?: () => boolean; + target?: Element | null; + targetVisibilityOptions?: ObserveElementVisibilityOptions; + visibilityObserver?: typeof observeElementVisibility; +}; + +export const reportVisualRenderLoopErrorInDevelopment: VisualRenderLoopErrorHandler = + (error) => { + if (process.env.NODE_ENV !== 'production') { + console.error('Visual render loop failed:', error); + } + }; + +const MAX_FRAME_DELTA_SECONDS = 0.1; + +function getDefaultDocument(): VisualRenderLoopDocument | null { + return typeof document === 'undefined' ? null : document; +} + +function requestAnimationFrameFromWindow(callback: FrameRequestCallback) { + return window.requestAnimationFrame(callback); +} + +function cancelAnimationFrameFromWindow(handle: number) { + window.cancelAnimationFrame(handle); +} + +export function createVisualRenderLoop({ + cancelAnimationFrame = cancelAnimationFrameFromWindow, + document: documentReference = getDefaultDocument(), + onFrameError = reportVisualRenderLoopErrorInDevelopment, + pauseWhenDocumentHidden = true, + renderFrame, + requestAnimationFrame = requestAnimationFrameFromWindow, + shouldRender, + target = null, + targetVisibilityOptions, + visibilityObserver = observeElementVisibility, +}: CreateVisualRenderLoopOptions): VisualRenderLoop { + let disposed = false; + let frameId: number | null = null; + let isDocumentVisible = + !pauseWhenDocumentHidden || !documentReference?.hidden; + let isTargetVisible = true; + let firstFrameAt: DOMHighResTimeStamp | null = null; + let previousFrameAt: DOMHighResTimeStamp | null = null; + let wantsRunning = false; + + const cleanupTasks: Array<() => void> = []; + + const canRender = () => + wantsRunning && + !disposed && + isDocumentVisible && + isTargetVisible && + (shouldRender?.() ?? true); + + const cancelPendingFrame = () => { + if (frameId === null) { + return; + } + + cancelAnimationFrame(frameId); + frameId = null; + previousFrameAt = null; + }; + + const scheduleNextFrame = () => { + if (frameId !== null || !canRender()) { + return; + } + + frameId = requestAnimationFrame((timestamp) => { + frameId = null; + + if (!canRender()) { + return; + } + + try { + if (firstFrameAt === null) { + firstFrameAt = timestamp; + } + + const deltaSeconds = + previousFrameAt === null + ? 0 + : Math.min( + Math.max((timestamp - previousFrameAt) / 1000, 0), + MAX_FRAME_DELTA_SECONDS, + ); + previousFrameAt = timestamp; + + if ( + renderFrame(timestamp, { + deltaSeconds, + elapsedSeconds: Math.max((timestamp - firstFrameAt) / 1000, 0), + timestamp, + }) === false + ) { + wantsRunning = false; + previousFrameAt = null; + return; + } + } catch (error) { + wantsRunning = false; + previousFrameAt = null; + onFrameError(error); + return; + } + + scheduleNextFrame(); + }); + }; + + const syncSchedule = () => { + if (canRender()) { + scheduleNextFrame(); + return; + } + + cancelPendingFrame(); + }; + + if (pauseWhenDocumentHidden && documentReference) { + const handleVisibilityChange = () => { + isDocumentVisible = !documentReference.hidden; + syncSchedule(); + }; + + documentReference.addEventListener( + 'visibilitychange', + handleVisibilityChange, + ); + cleanupTasks.push(() => + documentReference.removeEventListener( + 'visibilitychange', + handleVisibilityChange, + ), + ); + } + + if (target) { + cleanupTasks.push( + visibilityObserver( + target, + (isVisible) => { + isTargetVisible = isVisible; + syncSchedule(); + }, + targetVisibilityOptions, + ), + ); + } + + const stop = () => { + wantsRunning = false; + cancelPendingFrame(); + }; + + return { + dispose: () => { + if (disposed) { + return; + } + + disposed = true; + stop(); + runCleanupTasks(cleanupTasks); + }, + isRunning: () => frameId !== null, + start: () => { + wantsRunning = true; + syncSchedule(); + }, + stop, + }; +} diff --git a/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx b/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx index 5d51bb0888..7fbc181f72 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx +++ b/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx @@ -3,6 +3,8 @@ import { styled } from '@linaria/react'; import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { observeElementVisibility } from '@/lib/dom/observe-element-visibility'; + import { subscribeToActiveWebGlContextCount, tryReserveWebGlContextSlot, @@ -76,9 +78,10 @@ export function WebGlMount({ } }; - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { + const stopObservingVisibility = observeElementVisibility( + element, + (isIntersecting) => { + if (isIntersecting) { clearDisposeTimer(); setIsInViewport(true); return; @@ -93,11 +96,9 @@ export function WebGlMount({ { root: null, rootMargin: NON_PRIORITY_ROOT_MARGIN, threshold: 0 }, ); - observer.observe(element); - return () => { clearDisposeTimer(); - observer.disconnect(); + stopObservingVisibility(); }; }, [priority]); diff --git a/packages/twenty-website-new/src/lib/website-routing/__tests__/website-routes.test.ts b/packages/twenty-website-new/src/lib/website-routing/__tests__/website-routes.test.ts new file mode 100644 index 0000000000..87a62b8873 --- /dev/null +++ b/packages/twenty-website-new/src/lib/website-routing/__tests__/website-routes.test.ts @@ -0,0 +1,48 @@ +import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers'; + +import { + WEBSITE_ROUTE_LIST, + WEBSITE_ROUTES, + getIndexedWebsiteRoutes, + getRobotsDisallowedRoutePaths, +} from '..'; + +describe('website route registry', () => { + it('keeps route ids and paths unique', () => { + const routeIds = WEBSITE_ROUTE_LIST.map((route) => route.id); + const routePaths = WEBSITE_ROUTE_LIST.map((route) => route.path); + + expect(new Set(routeIds).size).toBe(routeIds.length); + expect(new Set(routePaths).size).toBe(routePaths.length); + }); + + it('registers every customer story from the catalog', () => { + const routePaths = new Set(WEBSITE_ROUTE_LIST.map((route) => route.path)); + + for (const entry of CASE_STUDY_CATALOG_ENTRIES) { + expect(routePaths.has(entry.href)).toBe(true); + expect( + WEBSITE_ROUTES[`customer:${entry.href.slice('/customers/'.length)}`], + ).toMatchObject({ + path: entry.href, + indexed: true, + }); + } + }); + + it('excludes private utility routes from indexed routes', () => { + const indexedPaths = getIndexedWebsiteRoutes().map((route) => route.path); + + expect(indexedPaths).toContain('/'); + expect(indexedPaths).toContain('/product'); + expect(indexedPaths).not.toContain('/halftone'); + expect(indexedPaths).not.toContain('/enterprise/activate'); + }); + + it('derives robots disallow paths from routes marked private', () => { + expect(getRobotsDisallowedRoutePaths()).toEqual([ + '/halftone', + '/enterprise/activate', + ]); + }); +}); diff --git a/packages/twenty-website-new/src/lib/website-routing/customer-story-routes.ts b/packages/twenty-website-new/src/lib/website-routing/customer-story-routes.ts new file mode 100644 index 0000000000..b74c0df060 --- /dev/null +++ b/packages/twenty-website-new/src/lib/website-routing/customer-story-routes.ts @@ -0,0 +1,14 @@ +import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers'; + +import type { WebsiteRoute } from './types'; + +export const CUSTOMER_STORY_ROUTES: readonly WebsiteRoute[] = + CASE_STUDY_CATALOG_ENTRIES.map((entry) => ({ + id: `customer:${entry.href.slice('/customers/'.length)}`, + path: entry.href, + title: `${entry.hero.author} | Twenty Customer Story`, + description: entry.catalogCard.summary, + changeFrequency: 'yearly', + priority: 0.5, + indexed: true, + })); diff --git a/packages/twenty-website-new/src/lib/website-routing/index.ts b/packages/twenty-website-new/src/lib/website-routing/index.ts new file mode 100644 index 0000000000..42a5551f3c --- /dev/null +++ b/packages/twenty-website-new/src/lib/website-routing/index.ts @@ -0,0 +1,9 @@ +export { CUSTOMER_STORY_ROUTES } from './customer-story-routes'; +export { STATIC_WEBSITE_ROUTES } from './static-website-routes'; +export type { WebsiteRoute, WebsiteRouteId } from './types'; +export { WEBSITE_ROUTE_LIST } from './website-route-list'; +export { WEBSITE_ROUTE_BY_ID, WEBSITE_ROUTES } from './website-route-map'; +export { + getIndexedWebsiteRoutes, + getRobotsDisallowedRoutePaths, +} from './website-route-selectors'; diff --git a/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts b/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts new file mode 100644 index 0000000000..cbfd516938 --- /dev/null +++ b/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts @@ -0,0 +1,115 @@ +import type { WebsiteRoute } from './types'; + +export const STATIC_WEBSITE_ROUTES = [ + { + id: 'home', + path: '/', + title: 'Twenty | #1 open source CRM', + description: + 'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.', + changeFrequency: 'weekly', + priority: 1, + indexed: true, + }, + { + id: 'whyTwenty', + path: '/why-twenty', + title: 'Why Twenty | Twenty', + description: + 'Most packaged software makes companies more similar. Learn why the future of CRM is built, not bought.', + changeFrequency: 'monthly', + priority: 0.8, + indexed: true, + }, + { + id: 'product', + path: '/product', + title: 'Product | Twenty', + description: + 'Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.', + changeFrequency: 'monthly', + priority: 0.8, + indexed: true, + }, + { + id: 'pricing', + path: '/pricing', + title: 'Pricing | Twenty', + description: + 'Plans that scale with your team. Compare tiers of the #1 open source CRM.', + changeFrequency: 'monthly', + priority: 0.9, + indexed: true, + }, + { + id: 'partners', + path: '/partners', + title: 'Partners | Twenty', + description: + 'Join our partner ecosystem and grow with us as we build the #1 open source CRM.', + changeFrequency: 'monthly', + priority: 0.7, + indexed: true, + }, + { + id: 'releases', + path: '/releases', + title: 'Releases | Twenty', + description: + 'Discover the newest features and improvements in Twenty, the #1 open source CRM.', + changeFrequency: 'weekly', + priority: 0.7, + indexed: true, + }, + { + id: 'customers', + path: '/customers', + title: 'Customers | Twenty', + description: + 'Meet the teams running their business on Twenty. Real customer stories on how they shaped the CRM to fit their workflow.', + changeFrequency: 'monthly', + priority: 0.7, + indexed: true, + }, + { + id: 'privacyPolicy', + path: '/privacy-policy', + title: 'Privacy Policy | Twenty', + description: + 'How Twenty collects, uses, safeguards, and discloses information when you use Twenty.com and related services.', + changeFrequency: 'yearly', + priority: 0.3, + indexed: true, + }, + { + id: 'terms', + path: '/terms', + title: 'Terms of Service | Twenty', + description: + 'Terms of Service for Twenty.com PBC, including use of Twenty.com, sub-domains, and related services.', + changeFrequency: 'yearly', + priority: 0.3, + indexed: true, + }, + { + id: 'halftone', + path: '/halftone', + title: 'Halftone Generator | Twenty', + description: 'Interactive halftone generator exported from Twenty.', + changeFrequency: 'monthly', + priority: 0, + indexed: false, + robotsDisallow: true, + }, + { + id: 'enterpriseActivate', + path: '/enterprise/activate', + title: 'Enterprise activation | Twenty', + description: + 'Complete activation for your Twenty self-hosted enterprise license.', + changeFrequency: 'yearly', + priority: 0, + indexed: false, + robotsDisallow: true, + }, +] as const satisfies readonly WebsiteRoute[]; diff --git a/packages/twenty-website-new/src/lib/website-routing/types.ts b/packages/twenty-website-new/src/lib/website-routing/types.ts new file mode 100644 index 0000000000..85b14355ef --- /dev/null +++ b/packages/twenty-website-new/src/lib/website-routing/types.ts @@ -0,0 +1,26 @@ +import type { MetadataRoute } from 'next'; + +export type WebsiteRouteId = + | 'home' + | 'whyTwenty' + | 'product' + | 'pricing' + | 'partners' + | 'releases' + | 'customers' + | 'privacyPolicy' + | 'terms' + | 'halftone' + | 'enterpriseActivate' + | `customer:${string}`; + +export type WebsiteRoute = { + changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency']; + description: string; + id: WebsiteRouteId; + indexed: boolean; + path: string; + priority: number; + robotsDisallow?: boolean; + title: string; +}; diff --git a/packages/twenty-website-new/src/lib/website-routing/website-route-list.ts b/packages/twenty-website-new/src/lib/website-routing/website-route-list.ts new file mode 100644 index 0000000000..85025c3b71 --- /dev/null +++ b/packages/twenty-website-new/src/lib/website-routing/website-route-list.ts @@ -0,0 +1,8 @@ +import { CUSTOMER_STORY_ROUTES } from './customer-story-routes'; +import { STATIC_WEBSITE_ROUTES } from './static-website-routes'; +import type { WebsiteRoute } from './types'; + +export const WEBSITE_ROUTE_LIST: readonly WebsiteRoute[] = [ + ...STATIC_WEBSITE_ROUTES, + ...CUSTOMER_STORY_ROUTES, +]; diff --git a/packages/twenty-website-new/src/lib/website-routing/website-route-map.ts b/packages/twenty-website-new/src/lib/website-routing/website-route-map.ts new file mode 100644 index 0000000000..86d95258ae --- /dev/null +++ b/packages/twenty-website-new/src/lib/website-routing/website-route-map.ts @@ -0,0 +1,10 @@ +import type { WebsiteRoute, WebsiteRouteId } from './types'; +import { WEBSITE_ROUTE_LIST } from './website-route-list'; + +export const WEBSITE_ROUTES: Readonly< + Partial> +> = Object.fromEntries( + WEBSITE_ROUTE_LIST.map((route) => [route.id, route]), +) as Readonly>>; + +export const WEBSITE_ROUTE_BY_ID = WEBSITE_ROUTES; diff --git a/packages/twenty-website-new/src/lib/website-routing/website-route-selectors.ts b/packages/twenty-website-new/src/lib/website-routing/website-route-selectors.ts new file mode 100644 index 0000000000..aa983781c6 --- /dev/null +++ b/packages/twenty-website-new/src/lib/website-routing/website-route-selectors.ts @@ -0,0 +1,10 @@ +import { WEBSITE_ROUTE_LIST } from './website-route-list'; +import type { WebsiteRoute } from './types'; + +export const getIndexedWebsiteRoutes = (): readonly WebsiteRoute[] => + WEBSITE_ROUTE_LIST.filter((route) => route.indexed); + +export const getRobotsDisallowedRoutePaths = (): readonly string[] => + WEBSITE_ROUTE_LIST.filter((route) => route.robotsDisallow).map( + (route) => route.path, + ); diff --git a/packages/twenty-website-new/src/middleware.ts b/packages/twenty-website-new/src/proxy.ts similarity index 81% rename from packages/twenty-website-new/src/middleware.ts rename to packages/twenty-website-new/src/proxy.ts index ea2c56e4c3..088857c2c2 100644 --- a/packages/twenty-website-new/src/middleware.ts +++ b/packages/twenty-website-new/src/proxy.ts @@ -1,7 +1,10 @@ import { NextResponse, type NextRequest } from 'next/server'; import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; -import { APP_LOCALE_BY_RAW } from '@/lib/i18n/app-locale-set'; +import { + APP_LOCALE_BY_RAW, + KNOWN_PUBLIC_APP_LOCALE_BY_RAW, +} from '@/lib/i18n/app-locale-set'; import { LOCALE_COOKIE_NAME, detectLocale } from '@/lib/i18n/detect-locale'; const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; @@ -16,13 +19,14 @@ const setLocaleCookie = (response: NextResponse, locale: AppLocale) => { }); }; -export const middleware = (request: NextRequest) => { +export const proxy = (request: NextRequest) => { const { pathname, search } = request.nextUrl; const firstSlash = pathname.indexOf('/', 1); const firstSegment = firstSlash === -1 ? pathname.slice(1) : pathname.slice(1, firstSlash); const localeFromPath = APP_LOCALE_BY_RAW.get(firstSegment); + const knownLocaleFromPath = KNOWN_PUBLIC_APP_LOCALE_BY_RAW.get(firstSegment); if (localeFromPath === SOURCE_LOCALE) { const canonicalPath = firstSlash === -1 ? '/' : pathname.slice(firstSlash); @@ -42,6 +46,14 @@ export const middleware = (request: NextRequest) => { return response; } + if (knownLocaleFromPath !== undefined) { + const canonicalPath = firstSlash === -1 ? '/' : pathname.slice(firstSlash); + const target = request.nextUrl.clone(); + target.pathname = canonicalPath; + target.search = search; + return NextResponse.redirect(target, 308); + } + const detected = detectLocale({ cookieValue: request.cookies.get(LOCALE_COOKIE_NAME)?.value, acceptLanguageHeader: request.headers.get('accept-language') ?? undefined, diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/CustomerCasesCover.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/CustomerCasesCover.tsx index 96b6b0ada8..a89e131f6d 100644 --- a/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/CustomerCasesCover.tsx +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/CustomerCasesCover.tsx @@ -1,503 +1,19 @@ -// @ts-nocheck 'use client'; -import { useEffect, useRef, type CSSProperties } from 'react'; -import * as THREE from 'three'; +import { HalftoneImageCanvas } from '@/lib/halftone'; import { styled } from '@linaria/react'; - -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; - -const DEFAULT_DASH_COLOR = '#4A38F5'; -const DEFAULT_HOVER_DASH_COLOR = '#8B7FF8'; - -const CSS_VAR_PATTERN = /^var\(\s*(--[^,)]+)(?:\s*,\s*([^)]+))?\s*\)$/; - -function resolveColorValue(value: string, fallback: string) { - if (typeof value !== 'string') { - return fallback; - } - const match = value.trim().match(CSS_VAR_PATTERN); - if (!match) { - return value; - } - if (typeof window === 'undefined') { - return match[2]?.trim() ?? fallback; - } - const resolved = getComputedStyle(document.documentElement) - .getPropertyValue(match[1]) - .trim(); - return resolved || match[2]?.trim() || fallback; -} - -const settings = { - halftone: { - scale: 24.72, - power: -0.07, - toneTarget: 'light', - width: 0.46, - imageContrast: 1, - }, - animation: { - hoverFadeIn: 10, - hoverFadeOut: 5, - hoverHalftonePowerShift: 0.45, - hoverHalftoneRadius: 0.6, - hoverHalftoneWidthShift: -0.1, - pointerFollow: 0.3, - waveSpeed: 1, - }, -}; -const previewDistance = 4; -const VIRTUAL_RENDER_HEIGHT = 512; -const REFERENCE_CONTAINER_HEIGHT = 240; -const passThroughVertexShader = - '\n varying vec2 vUv;\n\n void main() {\n vUv = uv;\n gl_Position = vec4(position, 1.0);\n }\n'; -const blurFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tInput;\n uniform vec2 dir;\n uniform vec2 res;\n\n varying vec2 vUv;\n\n void main() {\n vec4 sum = vec4(0.0);\n vec2 px = dir / res;\n\n float w[5];\n w[0] = 0.227027;\n w[1] = 0.1945946;\n w[2] = 0.1216216;\n w[3] = 0.054054;\n w[4] = 0.016216;\n\n sum += texture2D(tInput, vUv) * w[0];\n\n for (int i = 1; i < 5; i++) {\n float fi = float(i) * 3.0;\n sum += texture2D(tInput, vUv + px * fi) * w[i];\n sum += texture2D(tInput, vUv - px * fi) * w[i];\n }\n\n gl_FragColor = sum;\n }\n'; -const halftoneFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tScene;\n uniform vec2 effectResolution;\n uniform vec2 logicalResolution;\n uniform float tile;\n uniform float s_3;\n uniform float s_4;\n uniform float applyToDarkAreas;\n uniform vec3 dashColor;\n uniform vec3 hoverDashColor;\n uniform float time;\n uniform float waveAmount;\n uniform float waveSpeed;\n uniform float footprintScale;\n uniform vec2 interactionUv;\n uniform float hoverHalftoneActive;\n uniform float hoverHalftonePowerShift;\n uniform float hoverHalftoneRadius;\n uniform float hoverHalftoneWidthShift;\n\n varying vec2 vUv;\n\n float distSegment(in vec2 p, in vec2 a, in vec2 b) {\n vec2 pa = p - a;\n vec2 ba = b - a;\n float denom = max(dot(ba, ba), 0.000001);\n float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);\n return length(pa - ba * h);\n }\n\n float lineSimpleEt(in vec2 p, in float r, in float thickness) {\n vec2 a = vec2(0.5) + vec2(-r, 0.0);\n vec2 b = vec2(0.5) + vec2(r, 0.0);\n float distToSegment = distSegment(p, a, b);\n float halfThickness = thickness * r;\n return distToSegment - halfThickness;\n }\n\n void main() {\n vec2 fragCoord =\n (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;\n float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);\n\n float bandRow = floor(fragCoord.y / halftoneSize);\n float waveOffset =\n waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;\n vec2 effectCoord = fragCoord;\n effectCoord.x += waveOffset;\n\n vec2 cellIndex = floor(effectCoord / halftoneSize);\n vec2 sampleUv = clamp(\n (cellIndex + 0.5) * halftoneSize / logicalResolution,\n vec2(0.0),\n vec2(1.0)\n );\n vec2 cellUv = fract(effectCoord / halftoneSize);\n\n float hoverHalftoneMask = 0.0;\n if (hoverHalftoneActive > 0.0) {\n vec2 pointerPx = interactionUv * logicalResolution;\n float fragDist = length(fragCoord - pointerPx);\n float hoverRadiusPx = hoverHalftoneRadius * logicalResolution.y;\n hoverHalftoneMask =\n smoothstep(hoverRadiusPx, 0.0, fragDist) *\n clamp(hoverHalftoneActive, 0.0, 1.0);\n }\n\n vec4 sceneSample = texture2D(tScene, sampleUv);\n float toneValue =\n (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);\n if (applyToDarkAreas > 0.5) {\n toneValue = 1.0 - toneValue;\n }\n float localPower = clamp(\n s_3 + hoverHalftonePowerShift * hoverHalftoneMask,\n -1.5,\n 1.5\n );\n float localWidth = clamp(\n s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,\n 0.05,\n 1.4\n );\n float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0);\n float bandRadius = clamp(\n toneValue + powerBias,\n 0.0,\n 1.0\n ) * 1.86 * 0.5;\n\n float alpha = 0.0;\n if (bandRadius > 0.0001) {\n float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);\n float edge = 0.02;\n alpha = 1.0 - smoothstep(0.0, edge, signedDistance);\n }\n\n vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);\n vec3 color = activeDashColor * alpha;\n gl_FragColor = vec4(color, alpha);\n\n #include \n #include \n }\n'; -const imagePassthroughFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tImage;\n uniform vec2 imageSize;\n uniform vec2 viewportSize;\n uniform float zoom;\n uniform float contrast;\n\n varying vec2 vUv;\n\n void main() {\n float imageAspect = imageSize.x / imageSize.y;\n float viewAspect = viewportSize.x / viewportSize.y;\n\n vec2 uv = vUv;\n\n if (imageAspect > viewAspect) {\n float scale = viewAspect / imageAspect;\n uv.x = (uv.x - 0.5) * scale + 0.5;\n } else {\n float scale = imageAspect / viewAspect;\n uv.y = (uv.y - 0.5) * scale + 0.5;\n }\n\n uv = (uv - 0.5) / zoom + 0.5;\n\n vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));\n vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);\n\n gl_FragColor = vec4(contrastColor, 1.0);\n }\n'; - -const REFERENCE_PREVIEW_DISTANCE = 4; -const MIN_FOOTPRINT_SCALE = 0.001; - -function clampRectToViewport(rect, viewportWidth, viewportHeight) { - const minX = Math.max(rect.x, 0); - const minY = Math.max(rect.y, 0); - const maxX = Math.min(rect.x + rect.width, viewportWidth); - const maxY = Math.min(rect.y + rect.height, viewportHeight); - - if (maxX <= minX || maxY <= minY) { - return null; - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; -} - -function getRectArea(rect) { - if (!rect) { - return 0; - } - - return Math.max(rect.width, 0) * Math.max(rect.height, 0); -} - -function getImagePreviewZoom(distance) { - return REFERENCE_PREVIEW_DISTANCE / Math.max(distance, 0.001); -} - -function getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom, -}) { - if ( - imageWidth <= 0 || - imageHeight <= 0 || - viewportWidth <= 0 || - viewportHeight <= 0 - ) { - return null; - } - - const imageAspect = imageWidth / imageHeight; - const viewAspect = viewportWidth / viewportHeight; - - let fittedWidth = viewportWidth; - let fittedHeight = viewportHeight; - - if (imageAspect > viewAspect) { - fittedHeight = viewportWidth / imageAspect; - } else { - fittedWidth = viewportHeight * imageAspect; - } - - const scaledWidth = fittedWidth * zoom; - const scaledHeight = fittedHeight * zoom; - - return clampRectToViewport( - { - x: (viewportWidth - scaledWidth) * 0.5, - y: (viewportHeight - scaledHeight) * 0.5, - width: scaledWidth, - height: scaledHeight, - }, - viewportWidth, - viewportHeight, - ); -} - -function getFootprintScaleFromRects(currentRect, referenceRect) { - const currentArea = getRectArea(currentRect); - const referenceArea = getRectArea(referenceRect); - - if (currentArea <= 0 || referenceArea <= 0) { - return 1; - } - - return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE); -} - -function getImageFootprintScale({ - imageHeight, - imageWidth, - distance, - viewportHeight, - viewportWidth, -}) { - const currentRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: getImagePreviewZoom(distance), - }); - const referenceRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: 1, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function createRenderTarget(width, height) { - return new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); -} - -async function mountHalftoneCanvas(options) { - const { container, imageUrl } = options; - const dashColor = resolveColorValue(options.dashColor, DEFAULT_DASH_COLOR); - const hoverDashColor = resolveColorValue( - options.hoverDashColor, - DEFAULT_HOVER_DASH_COLOR, - ); - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))), - 1, - ); - - const image = await new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => resolve(img); - img.onerror = () => reject(new Error('Failed to load image')); - img.src = imageUrl; - }); - - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor(0x000000, 0); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const imageTexture = new THREE.Texture(image); - imageTexture.colorSpace = THREE.SRGBColorSpace; - imageTexture.needsUpdate = true; - - const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const imageMaterial = new THREE.ShaderMaterial({ - uniforms: { - tImage: { value: imageTexture }, - imageSize: { value: new THREE.Vector2(image.width, image.height) }, - viewportSize: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - zoom: { value: getImagePreviewZoom(previewDistance) }, - contrast: { value: settings.halftone.imageContrast }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: imagePassthroughFragmentShader, - }); - - const imageScene = new THREE.Scene(); - imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial)); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - effectResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - logicalResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - tile: { value: settings.halftone.scale }, - s_3: { value: settings.halftone.power }, - s_4: { value: settings.halftone.width }, - applyToDarkAreas: { - value: settings.halftone.toneTarget === 'dark' ? 1 : 0, - }, - dashColor: { value: new THREE.Color(dashColor) }, - hoverDashColor: { - value: new THREE.Color(hoverDashColor), - }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: settings.animation.waveSpeed }, - footprintScale: { value: 1.0 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - hoverHalftoneActive: { value: 0 }, - hoverHalftonePowerShift: { - value: settings.animation.hoverHalftonePowerShift, - }, - hoverHalftoneRadius: { value: settings.animation.hoverHalftoneRadius }, - hoverHalftoneWidthShift: { - value: settings.animation.hoverHalftoneWidthShift, - }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add( - new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), - ); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const updateViewportUniforms = ( - logicalWidth, - logicalHeight, - effectWidth, - effectHeight, - ) => { - blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - halftoneMaterial.uniforms.effectResolution.value.set( - effectWidth, - effectHeight, - ); - halftoneMaterial.uniforms.logicalResolution.value.set( - logicalWidth, - logicalHeight, - ); - imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight); - }; - - const getHalftoneScale = () => - getImageFootprintScale({ - imageHeight: image.height, - imageWidth: image.width, - distance: previewDistance, - viewportHeight: getVirtualHeight(), - viewportWidth: getVirtualWidth(), - }); - - const TARGET_TILE_CSS_SIZE = - (settings.halftone.scale * REFERENCE_CONTAINER_HEIGHT) / - VIRTUAL_RENDER_HEIGHT; - - const updateTileUniform = () => { - const virtualHeight = getVirtualHeight(); - const containerHeight = Math.max(getHeight(), 1); - halftoneMaterial.uniforms.tile.value = - (TARGET_TILE_CSS_SIZE * virtualHeight) / containerHeight; - }; - - const syncSize = () => { - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - updateViewportUniforms( - virtualWidth, - virtualHeight, - virtualWidth, - virtualHeight, - ); - updateTileUniform(); - }; - - updateTileUniform(); - - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); - - const interaction = { - pointerInside: false, - mouseX: 0.5, - mouseY: 0.5, - smoothedMouseX: 0.5, - smoothedMouseY: 0.5, - hoverStrength: 0, - }; - - const pointerFollow = settings.animation.pointerFollow; - const hoverFadeIn = settings.animation.hoverFadeIn; - const hoverFadeOut = settings.animation.hoverFadeOut; - - const updatePointer = (event) => { - const rect = container.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - interaction.mouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, - 0, - 1, - ); - interaction.mouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, - 0, - 1, - ); - }; - - const handlePointerEnter = (event) => { - updatePointer(event); - interaction.smoothedMouseX = interaction.mouseX; - interaction.smoothedMouseY = interaction.mouseY; - interaction.pointerInside = true; - }; - - const handlePointerMove = (event) => { - updatePointer(event); - interaction.pointerInside = true; - }; - - const handlePointerLeave = () => { - interaction.pointerInside = false; - }; - - container.addEventListener('pointerenter', handlePointerEnter); - container.addEventListener('pointermove', handlePointerMove); - container.addEventListener('pointerleave', handlePointerLeave); - - const clock = new THREE.Timer(); - clock.connect(document); - let animationFrameId = 0; - - const renderFrame = (timestamp) => { - animationFrameId = window.requestAnimationFrame(renderFrame); - clock.update(timestamp); - - const deltaSeconds = clock.getDelta(); - const hoverEasing = - 1 - - Math.exp( - -deltaSeconds * - (interaction.pointerInside ? hoverFadeIn : hoverFadeOut), - ); - interaction.hoverStrength += - ((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) * - hoverEasing; - interaction.smoothedMouseX += - (interaction.mouseX - interaction.smoothedMouseX) * pointerFollow; - interaction.smoothedMouseY += - (interaction.mouseY - interaction.smoothedMouseY) * pointerFollow; - - halftoneMaterial.uniforms.interactionUv.value.set( - interaction.smoothedMouseX, - 1 - interaction.smoothedMouseY, - ); - halftoneMaterial.uniforms.hoverHalftoneActive.value = - interaction.hoverStrength; - halftoneMaterial.uniforms.time.value = clock.getElapsed(); - imageMaterial.uniforms.zoom.value = getImagePreviewZoom(previewDistance); - halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale(); - - renderer.setRenderTarget(sceneTarget); - renderer.render(imageScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderFrame(); - - return () => { - window.cancelAnimationFrame(animationFrameId); - clock.dispose(); - resizeObserver.disconnect(); - container.removeEventListener('pointerenter', handlePointerEnter); - container.removeEventListener('pointermove', handlePointerMove); - container.removeEventListener('pointerleave', handlePointerLeave); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - imageMaterial.dispose(); - imageTexture.dispose(); - fullScreenGeometry.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; -} +import { type CSSProperties } from 'react'; + +import { + buildCustomerCasesCoverSettings, + CUSTOMER_CASES_COVER_DEFAULT_DASH_COLOR, + CUSTOMER_CASES_COVER_DEFAULT_HOVER_DASH_COLOR, + CUSTOMER_CASES_COVER_IMAGE_FIT, + CUSTOMER_CASES_COVER_IMAGE_INTERACTION, + CUSTOMER_CASES_COVER_PREVIEW_DISTANCE, + CUSTOMER_CASES_COVER_VIRTUAL_RENDER_HEIGHT, + resolveColorValue, +} from './customer-cases-cover-config'; const StyledVisualMount = styled.div` background: #000; @@ -508,43 +24,41 @@ const StyledVisualMount = styled.div` `; type CustomerCasesCoverProps = { - imageUrl: string; - style?: CSSProperties; dashColor?: string; hoverDashColor?: string; + imageUrl: string; + style?: CSSProperties; }; export function CustomerCasesCover({ + dashColor = CUSTOMER_CASES_COVER_DEFAULT_DASH_COLOR, + hoverDashColor = CUSTOMER_CASES_COVER_DEFAULT_HOVER_DASH_COLOR, imageUrl, style, - dashColor = DEFAULT_DASH_COLOR, - hoverDashColor = DEFAULT_HOVER_DASH_COLOR, }: CustomerCasesCoverProps) { - const mountReference = useRef(null); + const resolvedDashColor = resolveColorValue( + dashColor, + CUSTOMER_CASES_COVER_DEFAULT_DASH_COLOR, + ); + const resolvedHoverDashColor = resolveColorValue( + hoverDashColor, + CUSTOMER_CASES_COVER_DEFAULT_HOVER_DASH_COLOR, + ); - useEffect(() => { - const container = mountReference.current; - - if (!container) { - return; - } - - const unmountPromise = mountHalftoneCanvas({ - container, - imageUrl, - dashColor, - hoverDashColor, - }).catch((error) => { - if (process.env.NODE_ENV !== 'production') { - console.error(error); - } - return undefined; - }); - - return () => { - void unmountPromise.then((dispose) => dispose?.()); - }; - }, [imageUrl, dashColor, hoverDashColor]); - - return ; + return ( + + + + ); } diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/PromoMic.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/PromoMic.tsx index 75c5953064..e88bf7a62b 100644 --- a/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/PromoMic.tsx +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/PromoMic.tsx @@ -1,690 +1,16 @@ -// @ts-nocheck 'use client'; -import { useEffect, useRef, type CSSProperties } from 'react'; -import * as THREE from 'three'; +import { HalftoneImageCanvas } from '@/lib/halftone'; import { styled } from '@linaria/react'; - -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; - -const settings = { - sourceMode: 'image', - shapeKey: 'torusKnot', - lighting: { - intensity: 1.5, - fillIntensity: 0.15, - ambientIntensity: 0.08, - angleDegrees: 45, - height: 2, - }, - material: { - surface: 'solid', - color: '#d4d0c8', - roughness: 0.42, - metalness: 0.16, - thickness: 150, - refraction: 2, - environmentPower: 5, - }, - halftone: { - enabled: true, - scale: 16, - power: -0.07, - toneTarget: 'dark', - width: 0.46, - imageContrast: 1, - dashColor: '#777', - hoverDashColor: '#4A38F5', - }, - background: { - transparent: true, - color: '#f3f3f3', - }, - animation: { - autoRotateEnabled: true, - breatheEnabled: false, - cameraParallaxEnabled: false, - followHoverEnabled: false, - followDragEnabled: false, - floatEnabled: false, - hoverHalftoneEnabled: false, - hoverLightEnabled: true, - dragFlowEnabled: false, - lightSweepEnabled: false, - rotateEnabled: false, - autoSpeed: 0.2, - autoWobble: 0.3, - breatheAmount: 0.04, - breatheSpeed: 0.8, - cameraParallaxAmount: 0.3, - cameraParallaxEase: 0.08, - driftAmount: 8, - hoverRange: 25, - hoverEase: 0.08, - hoverReturn: true, - dragSens: 0.008, - dragFriction: 0.08, - dragMomentum: true, - rotateAxis: 'y', - rotatePreset: 'axis', - rotateSpeed: 0.2, - rotatePingPong: false, - floatAmplitude: 0.16, - floatSpeed: 0.8, - lightSweepHeightRange: 0.5, - lightSweepRange: 28, - lightSweepSpeed: 0.7, - springDamping: 0.72, - springReturnEnabled: false, - springStrength: 0.18, - hoverHalftonePowerShift: 0.42, - hoverHalftoneRadius: 0.2, - hoverHalftoneWidthShift: -0.18, - hoverLightIntensity: 1.2, - hoverLightRadius: 0.32, - dragFlowDecay: 0.08, - dragFlowRadius: 0.24, - dragFlowStrength: 1.8, - hoverWarpStrength: 3, - hoverWarpRadius: 0.15, - dragWarpStrength: 5, - waveEnabled: false, - waveSpeed: 1, - waveAmount: 2, - }, -}; -const initialPose = { - autoElapsed: 0, - rotateElapsed: 0, - rotationX: 0, - rotationY: 0, - rotationZ: 0, - targetRotationX: 0, - targetRotationY: 0, - timeElapsed: 86.56079999985694, -}; -const previewDistance = 4; -const VIRTUAL_RENDER_HEIGHT = 768; -const passThroughVertexShader = - '\n varying vec2 vUv;\n\n void main() {\n vUv = uv;\n gl_Position = vec4(position, 1.0);\n }\n'; -const blurFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tInput;\n uniform vec2 dir;\n uniform vec2 res;\n\n varying vec2 vUv;\n\n void main() {\n vec4 sum = vec4(0.0);\n vec2 px = dir / res;\n\n float w[5];\n w[0] = 0.227027;\n w[1] = 0.1945946;\n w[2] = 0.1216216;\n w[3] = 0.054054;\n w[4] = 0.016216;\n\n sum += texture2D(tInput, vUv) * w[0];\n\n for (int i = 1; i < 5; i++) {\n float fi = float(i) * 3.0;\n sum += texture2D(tInput, vUv + px * fi) * w[i];\n sum += texture2D(tInput, vUv - px * fi) * w[i];\n }\n\n gl_FragColor = sum;\n }\n'; -const halftoneFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tScene;\n uniform sampler2D tGlow;\n uniform vec2 effectResolution;\n uniform vec2 logicalResolution;\n uniform float tile;\n uniform float s_3;\n uniform float s_4;\n uniform float applyToDarkAreas;\n uniform vec3 dashColor;\n uniform vec3 hoverDashColor;\n uniform float time;\n uniform float waveAmount;\n uniform float waveSpeed;\n uniform float footprintScale;\n uniform vec2 interactionUv;\n uniform vec2 interactionVelocity;\n uniform vec2 dragOffset;\n uniform float hoverHalftoneActive;\n uniform float hoverHalftonePowerShift;\n uniform float hoverHalftoneRadius;\n uniform float hoverHalftoneWidthShift;\n uniform float hoverLightStrength;\n uniform float hoverLightRadius;\n uniform float hoverFlowStrength;\n uniform float hoverFlowRadius;\n uniform float dragFlowStrength;\n uniform float cropToBounds;\n\n varying vec2 vUv;\n\n float distSegment(in vec2 p, in vec2 a, in vec2 b) {\n vec2 pa = p - a;\n vec2 ba = b - a;\n float denom = max(dot(ba, ba), 0.000001);\n float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);\n return length(pa - ba * h);\n }\n\n float lineSimpleEt(in vec2 p, in float r, in float thickness) {\n vec2 a = vec2(0.5) + vec2(-r, 0.0);\n vec2 b = vec2(0.5) + vec2(r, 0.0);\n float distToSegment = distSegment(p, a, b);\n float halfThickness = thickness * r;\n return distToSegment - halfThickness;\n }\n\n void main() {\n if (cropToBounds > 0.5) {\n vec4 boundsCheck = texture2D(tScene, vUv);\n if (boundsCheck.a < 0.01) {\n gl_FragColor = vec4(0.0);\n return;\n }\n }\n\n vec2 fragCoord =\n (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;\n float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);\n vec2 pointerPx = interactionUv * logicalResolution;\n vec2 fragDelta = fragCoord - pointerPx;\n float fragDist = length(fragDelta);\n vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);\n float velocityMagnitude = length(interactionVelocity);\n vec2 motionDir = velocityMagnitude > 0.001\n ? interactionVelocity / velocityMagnitude\n : vec2(0.0, 0.0);\n float motionBias = velocityMagnitude > 0.001\n ? dot(-radialDir, motionDir) * 0.5 + 0.5\n : 0.5;\n\n float hoverLightMask = 0.0;\n if (hoverLightStrength > 0.0) {\n float lightRadiusPx = hoverLightRadius * logicalResolution.y;\n hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);\n }\n\n float hoverHalftoneMask = 0.0;\n if (hoverHalftoneActive > 0.0) {\n float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;\n hoverHalftoneMask =\n smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *\n clamp(hoverHalftoneActive, 0.0, 1.0);\n }\n\n float hoverFlowMask = 0.0;\n if (hoverFlowStrength > 0.0) {\n float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;\n hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);\n }\n\n vec2 hoverDisplacement =\n radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +\n motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;\n vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;\n vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;\n\n float bandRow = floor(effectCoord.y / halftoneSize);\n float waveOffset =\n waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;\n effectCoord.x += waveOffset;\n\n vec2 cellIndex = floor(effectCoord / halftoneSize);\n vec2 sampleUv = clamp(\n (cellIndex + 0.5) * halftoneSize / logicalResolution,\n vec2(0.0),\n vec2(1.0)\n );\n vec2 cellUv = fract(effectCoord / halftoneSize);\n\n vec4 sceneSample = texture2D(tScene, sampleUv);\n float mask = smoothstep(0.02, 0.08, sceneSample.a);\n float localPower = clamp(\n s_3 + hoverHalftonePowerShift * hoverHalftoneMask,\n -1.5,\n 1.5\n );\n float localWidth = clamp(\n s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,\n 0.05,\n 1.4\n );\n float lightLift =\n hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;\n float toneValue =\n (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);\n if (applyToDarkAreas > 0.5) {\n toneValue = 1.0 - toneValue;\n }\n // Preserve the pre-toneTarget light-mode response by keeping the power\n // bias inside the averaged tone calculation.\n float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0);\n float bandRadius = clamp(\n toneValue + powerBias + lightLift,\n 0.0,\n 1.0\n ) * 1.86 * 0.5;\n\n float alpha = 0.0;\n if (bandRadius > 0.0001) {\n float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);\n float edge = 0.02;\n alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;\n }\n\n vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);\n vec3 color = activeDashColor * alpha;\n gl_FragColor = vec4(color, alpha);\n\n #include \n #include \n }\n'; -const imagePassthroughFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tImage;\n uniform vec2 imageSize;\n uniform vec2 viewportSize;\n uniform float zoom;\n uniform float contrast;\n\n varying vec2 vUv;\n\n void main() {\n float imageAspect = imageSize.x / imageSize.y;\n float viewAspect = viewportSize.x / viewportSize.y;\n\n vec2 uv = vUv;\n\n if (imageAspect > viewAspect) {\n float scale = viewAspect / imageAspect;\n uv.x = (uv.x - 0.5) * scale + 0.5;\n } else {\n float scale = imageAspect / viewAspect;\n uv.y = (uv.y - 0.5) * scale + 0.5;\n }\n\n uv = (uv - 0.5) / zoom + 0.5;\n\n vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));\n vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);\n\n gl_FragColor = vec4(contrastColor, 1.0);\n }\n'; - -const REFERENCE_PREVIEW_DISTANCE = 4; -const MIN_FOOTPRINT_SCALE = 0.001; - -function clampRectToViewport(rect, viewportWidth, viewportHeight) { - const minX = Math.max(rect.x, 0); - const minY = Math.max(rect.y, 0); - const maxX = Math.min(rect.x + rect.width, viewportWidth); - const maxY = Math.min(rect.y + rect.height, viewportHeight); - - if (maxX <= minX || maxY <= minY) { - return null; - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; -} - -function getRectArea(rect) { - if (!rect) { - return 0; - } - - return Math.max(rect.width, 0) * Math.max(rect.height, 0); -} - -function getImagePreviewZoom(previewDistance) { - return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); -} - -function getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom, -}) { - if ( - imageWidth <= 0 || - imageHeight <= 0 || - viewportWidth <= 0 || - viewportHeight <= 0 - ) { - return null; - } - - const imageAspect = imageWidth / imageHeight; - const viewAspect = viewportWidth / viewportHeight; - - let fittedWidth = viewportWidth; - let fittedHeight = viewportHeight; - - if (imageAspect > viewAspect) { - fittedHeight = viewportWidth / imageAspect; - } else { - fittedWidth = viewportHeight * imageAspect; - } - - const scaledWidth = fittedWidth * zoom; - const scaledHeight = fittedHeight * zoom; - - return clampRectToViewport( - { - x: (viewportWidth - scaledWidth) * 0.5, - y: (viewportHeight - scaledHeight) * 0.5, - width: scaledWidth, - height: scaledHeight, - }, - viewportWidth, - viewportHeight, - ); -} - -function getFootprintScaleFromRects(currentRect, referenceRect) { - const currentArea = getRectArea(currentRect); - const referenceArea = getRectArea(referenceRect); - - if (currentArea <= 0 || referenceArea <= 0) { - return 1; - } - - return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE); -} - -function getImageFootprintScale({ - imageHeight, - imageWidth, - previewDistance, - viewportHeight, - viewportWidth, -}) { - const currentRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: getImagePreviewZoom(previewDistance), - }); - const referenceRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: 1, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function createRenderTarget(width, height) { - return new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); -} - -function createInteractionState() { - return { - autoElapsed: initialPose.autoElapsed, - activePointerId: null, - dragging: false, - hoverStrength: 0, - mouseX: 0.5, - mouseY: 0.5, - pointerInside: false, - pointerVelocityX: 0, - pointerVelocityY: 0, - pointerX: 0, - pointerY: 0, - rotateElapsed: initialPose.rotateElapsed, - rotationX: initialPose.rotationX, - rotationVelocityX: 0, - rotationY: initialPose.rotationY, - rotationVelocityY: 0, - rotationZ: initialPose.rotationZ, - rotationVelocityZ: 0, - smoothedMouseX: 0.5, - smoothedMouseY: 0.5, - targetRotationX: initialPose.targetRotationX, - targetRotationY: initialPose.targetRotationY, - velocityX: 0, - velocityY: 0, - }; -} - -async function mountHalftoneCanvas(options) { - const { container, imageUrl, onError } = options; - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))), - 1, - ); - - const image = await new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = () => { - const error = new Error('Failed to load image'); - onError?.(error); - reject(error); - }; - img.src = imageUrl; - }); - - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor(0x000000, 0); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.cursor = 'default'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const imageTexture = new THREE.Texture(image); - imageTexture.colorSpace = THREE.SRGBColorSpace; - imageTexture.needsUpdate = true; - - const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const imageMaterial = new THREE.ShaderMaterial({ - uniforms: { - tImage: { value: imageTexture }, - imageSize: { value: new THREE.Vector2(image.width, image.height) }, - viewportSize: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - zoom: { value: getImagePreviewZoom(previewDistance) }, - contrast: { value: settings.halftone.imageContrast }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: imagePassthroughFragmentShader, - }); - - const imageScene = new THREE.Scene(); - imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial)); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - effectResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - logicalResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - tile: { value: settings.halftone.scale }, - s_3: { value: settings.halftone.power }, - s_4: { value: settings.halftone.width }, - applyToDarkAreas: { - value: settings.halftone.toneTarget === 'dark' ? 1 : 0, - }, - dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, - hoverDashColor: { - value: new THREE.Color(settings.halftone.hoverDashColor), - }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: settings.animation.waveSpeed }, - footprintScale: { value: 1.0 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - interactionVelocity: { value: new THREE.Vector2(0, 0) }, - dragOffset: { value: new THREE.Vector2(0, 0) }, - hoverHalftoneActive: { value: 0 }, - hoverHalftonePowerShift: { value: 0 }, - hoverHalftoneRadius: { value: settings.animation.hoverHalftoneRadius }, - hoverHalftoneWidthShift: { value: 0 }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: settings.animation.hoverLightRadius }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - cropToBounds: { value: 1 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add( - new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), - ); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const updateViewportUniforms = ( - logicalWidth, - logicalHeight, - effectWidth, - effectHeight, - ) => { - blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - halftoneMaterial.uniforms.effectResolution.value.set( - effectWidth, - effectHeight, - ); - halftoneMaterial.uniforms.logicalResolution.value.set( - logicalWidth, - logicalHeight, - ); - imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight); - }; - - const getHalftoneScale = () => - getImageFootprintScale({ - imageHeight: image.height, - imageWidth: image.width, - previewDistance, - viewportHeight: getVirtualHeight(), - viewportWidth: getVirtualWidth(), - }); - - const interaction = createInteractionState(); - const imagePointerFollow = 0.38; - const imagePointerVelocityDamping = 0.82; - const imageHoverFadeIn = 18; - const imageHoverFadeOut = 7; - - const syncSize = () => { - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - updateViewportUniforms( - virtualWidth, - virtualHeight, - virtualWidth, - virtualHeight, - ); - }; - - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); - - const updatePointerPosition = (event, options = {}) => { - const rect = canvas.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - - const nextMouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, - 0, - 1, - ); - const nextMouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, - 0, - 1, - ); - - const deltaX = nextMouseX - interaction.mouseX; - const deltaY = nextMouseY - interaction.mouseY; - - interaction.mouseX = nextMouseX; - interaction.mouseY = nextMouseY; - interaction.pointerInside = - interaction.dragging || - (event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom); - - if (options.resetVelocity) { - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - interaction.smoothedMouseX = nextMouseX; - interaction.smoothedMouseY = nextMouseY; - } else { - interaction.pointerVelocityX = deltaX; - interaction.pointerVelocityY = deltaY; - } - - return { deltaX, deltaY }; - }; - - const releasePointerCapture = (pointerId) => { - if (pointerId === null) { - return; - } - - if (!canvas.hasPointerCapture(pointerId)) { - return; - } - - try { - canvas.releasePointerCapture(pointerId); - } catch (error) { - void error; - } - }; - - const handlePointerDown = (event) => { - updatePointerPosition(event, { resetVelocity: true }); - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - }; - - const handlePointerMove = (event) => { - const resetVelocity = !interaction.pointerInside && !interaction.dragging; - updatePointerPosition( - event, - resetVelocity ? { resetVelocity: true } : undefined, - ); - }; - - const handlePointerLeave = () => { - if (interaction.dragging) { - return; - } - - interaction.pointerInside = false; - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - }; - - const handlePointerUp = (event) => { - updatePointerPosition(event, { resetVelocity: true }); - releasePointerCapture(interaction.activePointerId); - interaction.activePointerId = null; - interaction.dragging = false; - const rect = canvas.getBoundingClientRect(); - interaction.pointerInside = - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom; - }; - - const handlePointerCancel = () => { - releasePointerCapture(interaction.activePointerId); - interaction.activePointerId = null; - interaction.dragging = false; - interaction.pointerInside = false; - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - }; - - const handleWindowBlur = () => { - handlePointerCancel(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointerup', handlePointerUp); - canvas.addEventListener('pointercancel', handlePointerCancel); - window.addEventListener('blur', handleWindowBlur); - canvas.addEventListener('pointerdown', handlePointerDown); - - const clock = new THREE.Timer(); - clock.connect(document); - let animationFrameId = 0; - - const renderFrame = (timestamp) => { - animationFrameId = window.requestAnimationFrame(renderFrame); - clock.update(timestamp); - - const deltaSeconds = clock.getDelta(); - const elapsedTime = clock.getElapsed(); - halftoneMaterial.uniforms.time.value = elapsedTime; - const hoverEasing = - 1 - - Math.exp( - -deltaSeconds * - (interaction.pointerInside ? imageHoverFadeIn : imageHoverFadeOut), - ); - interaction.hoverStrength += - ((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) * - hoverEasing; - - interaction.smoothedMouseX += - (interaction.mouseX - interaction.smoothedMouseX) * imagePointerFollow; - interaction.smoothedMouseY += - (interaction.mouseY - interaction.smoothedMouseY) * imagePointerFollow; - interaction.pointerVelocityX *= imagePointerVelocityDamping; - interaction.pointerVelocityY *= imagePointerVelocityDamping; - - halftoneMaterial.uniforms.interactionUv.value.set( - interaction.smoothedMouseX, - 1 - interaction.smoothedMouseY, - ); - halftoneMaterial.uniforms.interactionVelocity.value.set( - interaction.pointerVelocityX * getVirtualWidth(), - -interaction.pointerVelocityY * getVirtualHeight(), - ); - halftoneMaterial.uniforms.dragOffset.value.set(0, 0); - halftoneMaterial.uniforms.hoverHalftoneActive.value = settings.animation - .hoverHalftoneEnabled - ? interaction.hoverStrength - : 0; - halftoneMaterial.uniforms.hoverHalftonePowerShift.value = settings.animation - .hoverHalftoneEnabled - ? settings.animation.hoverHalftonePowerShift - : 0; - halftoneMaterial.uniforms.hoverHalftoneRadius.value = - settings.animation.hoverHalftoneRadius; - halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = settings.animation - .hoverHalftoneEnabled - ? settings.animation.hoverHalftoneWidthShift - : 0; - halftoneMaterial.uniforms.hoverLightStrength.value = settings.animation - .hoverLightEnabled - ? settings.animation.hoverLightIntensity * interaction.hoverStrength - : 0; - halftoneMaterial.uniforms.hoverLightRadius.value = - settings.animation.hoverLightRadius; - halftoneMaterial.uniforms.hoverFlowStrength.value = 0; - halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18; - halftoneMaterial.uniforms.dragFlowStrength.value = 0; - imageMaterial.uniforms.zoom.value = getImagePreviewZoom(previewDistance); - halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale(); - - renderer.setRenderTarget(sceneTarget); - renderer.render(imageScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderFrame(); - - return () => { - window.cancelAnimationFrame(animationFrameId); - clock.dispose(); - resizeObserver.disconnect(); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - canvas.removeEventListener('pointerup', handlePointerUp); - canvas.removeEventListener('pointercancel', handlePointerCancel); - window.removeEventListener('blur', handleWindowBlur); - canvas.removeEventListener('pointerdown', handlePointerDown); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - imageMaterial.dispose(); - imageTexture.dispose(); - fullScreenGeometry.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; -} +import { type CSSProperties } from 'react'; + +import { + PROMO_MIC_IMAGE_FIT, + PROMO_MIC_IMAGE_URL, + PROMO_MIC_INITIAL_POSE, + PROMO_MIC_PREVIEW_DISTANCE, + PROMO_MIC_SETTINGS, +} from './promo-mic-config'; const StyledVisualMount = styled.div` background: transparent; @@ -700,34 +26,21 @@ type PromoMicProps = { }; export function PromoMic({ - imageUrl = '/illustrations/generated/partner-meeting.webp', + imageUrl = PROMO_MIC_IMAGE_URL, style, }: PromoMicProps) { - const mountReference = useRef(null); - - useEffect(() => { - const container = mountReference.current; - - if (!container) { - return; - } - - const unmount = mountHalftoneCanvas({ - container, - imageUrl, - onError: (error) => { - if (process.env.NODE_ENV !== 'production') { - console.error(error); - } - }, - }); - - return () => { - void Promise.resolve(unmount).then((dispose) => dispose?.()); - }; - }, [imageUrl]); - - return ; + return ( + + + + ); } export default PromoMic; diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/customer-cases-cover-config.ts b/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/customer-cases-cover-config.ts new file mode 100644 index 0000000000..9550fd881f --- /dev/null +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/customer-cases-cover-config.ts @@ -0,0 +1,136 @@ +import { + normalizeHalftoneStudioSettings, + type HalftoneImageFit, + type HalftoneImageInteractionSettings, + type HalftoneStudioSettings, +} from '@/lib/halftone'; + +export const CUSTOMER_CASES_COVER_DEFAULT_DASH_COLOR = '#4A38F5'; +export const CUSTOMER_CASES_COVER_DEFAULT_HOVER_DASH_COLOR = '#8B7FF8'; +export const CUSTOMER_CASES_COVER_IMAGE_FIT: HalftoneImageFit = 'cover'; +export const CUSTOMER_CASES_COVER_PREVIEW_DISTANCE = 4; +export const CUSTOMER_CASES_COVER_VIRTUAL_RENDER_HEIGHT = 512; + +export const CUSTOMER_CASES_COVER_IMAGE_INTERACTION = { + hoverFadeIn: 10, + hoverFadeOut: 5, + pointerFollow: 0.3, +} satisfies Partial; + +const CSS_VAR_PATTERN = /^var\(\s*(--[^,)]+)(?:\s*,\s*([^)]+))?\s*\)$/; + +export function resolveColorValue(value: string, fallback: string) { + if (typeof value !== 'string') { + return fallback; + } + + const match = value.trim().match(CSS_VAR_PATTERN); + + if (!match) { + return value; + } + + if (typeof window === 'undefined') { + return match[2]?.trim() ?? fallback; + } + + const resolved = getComputedStyle(document.documentElement) + .getPropertyValue(match[1]) + .trim(); + + return resolved || match[2]?.trim() || fallback; +} + +export const buildCustomerCasesCoverSettings = ({ + dashColor, + hoverDashColor, +}: { + dashColor: string; + hoverDashColor: string; +}): HalftoneStudioSettings => + normalizeHalftoneStudioSettings({ + sourceMode: 'image', + shapeKey: 'torusKnot', + lighting: { + intensity: 1.5, + fillIntensity: 0.15, + ambientIntensity: 0.08, + angleDegrees: 45, + height: 2, + }, + material: { + surface: 'solid', + color: '#d4d0c8', + roughness: 0.42, + metalness: 0.16, + thickness: 150, + refraction: 2, + environmentPower: 5, + }, + halftone: { + enabled: true, + scale: 24.72, + power: -0.07, + toneTarget: 'light', + width: 0.46, + imageContrast: 1, + dashColor, + hoverDashColor, + }, + background: { + transparent: true, + color: '#000000', + }, + animation: { + autoRotateEnabled: true, + breatheEnabled: false, + cameraParallaxEnabled: false, + followHoverEnabled: false, + followDragEnabled: false, + floatEnabled: false, + hoverHalftoneEnabled: true, + hoverLightEnabled: false, + dragFlowEnabled: false, + lightSweepEnabled: false, + rotateEnabled: false, + autoSpeed: 0.2, + autoWobble: 0.3, + breatheAmount: 0.04, + breatheSpeed: 0.8, + cameraParallaxAmount: 0.3, + cameraParallaxEase: 0.08, + driftAmount: 8, + hoverRange: 25, + hoverEase: 0.08, + hoverReturn: true, + dragSens: 0.008, + dragFriction: 0.08, + dragMomentum: true, + rotateAxis: 'y', + rotatePreset: 'axis', + rotateSpeed: 0.2, + rotatePingPong: false, + floatAmplitude: 0.16, + floatSpeed: 0.8, + lightSweepHeightRange: 0.5, + lightSweepRange: 28, + lightSweepSpeed: 0.7, + springDamping: 0.72, + springReturnEnabled: false, + springStrength: 0.18, + hoverHalftonePowerShift: 0.45, + hoverHalftoneRadius: 0.6, + hoverHalftoneWidthShift: -0.1, + hoverLightIntensity: 0.8, + hoverLightRadius: 0.2, + dragFlowDecay: 0.08, + dragFlowRadius: 0.24, + dragFlowStrength: 1.8, + hoverWarpStrength: 3, + hoverWarpRadius: 0.15, + dragWarpStrength: 5, + waveEnabled: false, + waveSpeed: 1, + waveAmount: 2, + }, + }); diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/promo-mic-config.ts b/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/promo-mic-config.ts new file mode 100644 index 0000000000..2511934ee3 --- /dev/null +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/visuals/promo-mic-config.ts @@ -0,0 +1,110 @@ +import { + normalizeHalftoneStudioSettings, + type HalftoneExportPose, + type HalftoneImageFit, + type HalftoneStudioSettings, +} from '@/lib/halftone'; + +export const PROMO_MIC_IMAGE_URL = + '/illustrations/generated/partner-meeting.webp'; +export const PROMO_MIC_IMAGE_FIT: HalftoneImageFit = 'cover'; +export const PROMO_MIC_PREVIEW_DISTANCE = 4; + +export const PROMO_MIC_INITIAL_POSE: HalftoneExportPose = { + autoElapsed: 0, + rotateElapsed: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + targetRotationX: 0, + targetRotationY: 0, + timeElapsed: 86.56079999985694, +}; + +export const PROMO_MIC_SETTINGS: HalftoneStudioSettings = + normalizeHalftoneStudioSettings({ + sourceMode: 'image', + shapeKey: 'torusKnot', + lighting: { + intensity: 1.5, + fillIntensity: 0.15, + ambientIntensity: 0.08, + angleDegrees: 45, + height: 2, + }, + material: { + surface: 'solid', + color: '#d4d0c8', + roughness: 0.42, + metalness: 0.16, + thickness: 150, + refraction: 2, + environmentPower: 5, + }, + halftone: { + enabled: true, + scale: 16, + power: -0.07, + toneTarget: 'dark', + width: 0.46, + imageContrast: 1, + dashColor: '#777', + hoverDashColor: '#4A38F5', + }, + background: { + transparent: true, + color: '#f3f3f3', + }, + animation: { + autoRotateEnabled: true, + breatheEnabled: false, + cameraParallaxEnabled: false, + followHoverEnabled: false, + followDragEnabled: false, + floatEnabled: false, + hoverHalftoneEnabled: false, + hoverLightEnabled: true, + dragFlowEnabled: false, + lightSweepEnabled: false, + rotateEnabled: false, + autoSpeed: 0.2, + autoWobble: 0.3, + breatheAmount: 0.04, + breatheSpeed: 0.8, + cameraParallaxAmount: 0.3, + cameraParallaxEase: 0.08, + driftAmount: 8, + hoverRange: 25, + hoverEase: 0.08, + hoverReturn: true, + dragSens: 0.008, + dragFriction: 0.08, + dragMomentum: true, + rotateAxis: 'y', + rotatePreset: 'axis', + rotateSpeed: 0.2, + rotatePingPong: false, + floatAmplitude: 0.16, + floatSpeed: 0.8, + lightSweepHeightRange: 0.5, + lightSweepRange: 28, + lightSweepSpeed: 0.7, + springDamping: 0.72, + springReturnEnabled: false, + springStrength: 0.18, + hoverHalftonePowerShift: 0.42, + hoverHalftoneRadius: 0.2, + hoverHalftoneWidthShift: -0.18, + hoverLightIntensity: 1.2, + hoverLightRadius: 0.32, + dragFlowDecay: 0.08, + dragFlowRadius: 0.24, + dragFlowStrength: 1.8, + hoverWarpStrength: 3, + hoverWarpRadius: 0.15, + dragWarpStrength: 5, + waveEnabled: false, + waveSpeed: 1, + waveAmount: 2, + }, + }); diff --git a/packages/twenty-website-new/src/sections/Demo/components/Cta.tsx b/packages/twenty-website-new/src/sections/Demo/components/Cta.tsx deleted file mode 100644 index 09c2942cd7..0000000000 --- a/packages/twenty-website-new/src/sections/Demo/components/Cta.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import type { ReactNode } from 'react'; - -const CTAsContainer = styled.div` - display: flex; - flex-wrap: wrap; - gap: ${theme.spacing(4)}; - justify-content: center; -`; - -type CtaProps = { children: ReactNode }; - -export function Cta({ children }: CtaProps) { - return {children}; -} diff --git a/packages/twenty-website-new/src/sections/Demo/components/Heading.tsx b/packages/twenty-website-new/src/sections/Demo/components/Heading.tsx deleted file mode 100644 index 02aafdf8cf..0000000000 --- a/packages/twenty-website-new/src/sections/Demo/components/Heading.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Heading as BaseHeading } from '@/design-system/components'; -import type { HeadingType } from '@/design-system/components/Heading'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; - -const StyledHeading = styled(BaseHeading)` - max-width: 360px; - - @media (min-width: ${theme.breakpoints.md}px) { - max-width: 672px; - } -`; - -type HeadingProps = { - segments: HeadingType[]; -}; - -export function Heading({ segments }: HeadingProps) { - return ; -} diff --git a/packages/twenty-website-new/src/sections/Demo/components/Root.tsx b/packages/twenty-website-new/src/sections/Demo/components/Root.tsx deleted file mode 100644 index 61abc60c16..0000000000 --- a/packages/twenty-website-new/src/sections/Demo/components/Root.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Container } from '@/design-system/components'; -import { theme } from '@/theme'; -import { css } from '@linaria/core'; -import { styled } from '@linaria/react'; -import NextImage from 'next/image'; -import type { ReactNode } from 'react'; - -const RootWrapper = styled.div` - display: grid; - grid-template-columns: 1fr; - position: relative; - width: 100%; -`; - -const BackgroundLayer = styled.div` - align-self: stretch; - grid-column: 1; - grid-row: 1; - opacity: 0.6; - pointer-events: none; - position: relative; - width: 100%; - z-index: 0; -`; - -const PatternLayer = styled.div` - bottom: 0; - height: 60%; - left: 50%; - position: absolute; - transform: translateX(-50%); - width: 100%; -`; - -const patternImageClassName = css` - object-fit: cover; - object-position: center top; -`; - -const StyledSection = styled.section` - grid-column: 1; - grid-row: 1; - position: relative; - width: 100%; - z-index: 1; -`; - -const StyledContainer = styled(Container)` - display: grid; - grid-template-columns: 1fr; - justify-items: center; - padding-bottom: ${theme.spacing(16)}; - padding-left: ${theme.spacing(4)}; - padding-right: ${theme.spacing(4)}; - padding-top: ${theme.spacing(16)}; - row-gap: ${theme.spacing(6)}; - text-align: center; - width: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - padding-bottom: ${theme.spacing(24)}; - padding-left: ${theme.spacing(10)}; - padding-right: ${theme.spacing(10)}; - padding-top: ${theme.spacing(24)}; - } -`; - -type RootProps = { children: ReactNode }; - -export function Root({ children }: RootProps) { - return ( - - - - - - - - {children} - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx b/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx deleted file mode 100644 index 9b59d0449a..0000000000 --- a/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { ImageType } from '@/design-system/components/Image'; -import { theme } from '@/theme'; -import { css } from '@linaria/core'; -import { styled } from '@linaria/react'; -import NextImage from 'next/image'; - -const ScreenshotRoot = styled.div` - aspect-ratio: 136 / 69; - border-radius: ${theme.radius(4)}; - overflow: hidden; - position: relative; - width: 100%; - max-width: 800px; - margin-top: ${theme.spacing(12)}; - margin-bottom: ${theme.spacing(11)}; - - @media (min-width: ${theme.breakpoints.md}px) { - border-radius: ${theme.radius(6)}; - margin-top: ${theme.spacing(19)}; - margin-bottom: ${theme.spacing(11)}; - } -`; - -const screenshotImageClassName = css` - object-fit: cover; -`; - -type ScreenshotProps = { image: ImageType }; - -export function Screenshot({ image }: ScreenshotProps) { - return ( - - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Demo/components/index.ts b/packages/twenty-website-new/src/sections/Demo/components/index.ts deleted file mode 100644 index 5c5d90fef7..0000000000 --- a/packages/twenty-website-new/src/sections/Demo/components/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Cta } from './Cta'; -import { Heading } from './Heading'; -import { Root } from './Root'; -import { Screenshot } from './Screenshot'; - -export const Demo = { Cta, Heading, Root, Screenshot }; diff --git a/packages/twenty-website-new/src/sections/Demo/types/DemoData.ts b/packages/twenty-website-new/src/sections/Demo/types/DemoData.ts deleted file mode 100644 index 2fee07b9c8..0000000000 --- a/packages/twenty-website-new/src/sections/Demo/types/DemoData.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { EyebrowType } from '@/design-system/components/Eyebrow'; -import type { HeadingType } from '@/design-system/components/Heading'; -import type { ImageType } from '@/design-system/components/Image'; - -export type DemoDataType = { - eyebrow: EyebrowType; - heading: HeadingType[]; - image: ImageType; -}; diff --git a/packages/twenty-website-new/src/sections/Demo/types/index.ts b/packages/twenty-website-new/src/sections/Demo/types/index.ts deleted file mode 100644 index b2d705eeb3..0000000000 --- a/packages/twenty-website-new/src/sections/Demo/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { DemoDataType } from './DemoData'; diff --git a/packages/twenty-website-new/src/sections/Editorial/types/index.ts b/packages/twenty-website-new/src/sections/Editorial/types/index.ts deleted file mode 100644 index bcb456f9c6..0000000000 --- a/packages/twenty-website-new/src/sections/Editorial/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { EditorialDataType } from './EditorialData'; diff --git a/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx b/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx index 92c9c518a9..6d3124bb7d 100644 --- a/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx +++ b/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx @@ -7,9 +7,13 @@ import { useEffect, useLayoutEffect, useRef } from 'react'; import * as THREE from 'three'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { createAnimationFrameLoop } from '@/lib/animation'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; import { - createFrameTimer, - createSiteWebGlRenderer, + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, + type VisualRenderLoopFrame, } from '@/lib/visual-runtime'; import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; @@ -606,22 +610,25 @@ export function FaqBackground() { shell.style.bottom = 'auto'; shell.style.height = `${initialHeight}px`; - let resizeFrameId = 0; - - const handleWindowResize = () => { - window.cancelAnimationFrame(resizeFrameId); - resizeFrameId = window.requestAnimationFrame(() => { + const resizeTask = createAnimationFrameLoop({ + onFrame: () => { const currentViewportHeight = window.innerHeight || initialViewportHeight; const ratio = currentViewportHeight / initialViewportHeight; shell.style.height = `${Math.round(initialHeight * ratio)}px`; - }); + return false; + }, + }); + + const handleWindowResize = () => { + resizeTask.stop(); + resizeTask.start(); }; window.addEventListener('resize', handleWindowResize); return () => { - window.cancelAnimationFrame(resizeFrameId); + resizeTask.stop(); window.removeEventListener('resize', handleWindowResize); }; }, []); @@ -634,7 +641,7 @@ export function FaqBackground() { } let cancelled = false; - let animationFrameId = 0; + let renderLoop: VisualRenderLoop | null = null; let rotateElapsed = INITIAL_ROTATE_ELAPSED; const getWidth = () => Math.max(container.clientWidth, 1); @@ -648,7 +655,18 @@ export function FaqBackground() { 1, ); - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); + const renderer = tryCreateSiteWebGlRenderer({ + antialias: false, + alpha: true, + onContextLost: () => { + renderLoop?.stop(); + }, + }); + + if (renderer === null) { + return; + } + renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setPixelRatio(1); renderer.setClearColor(0x000000, 0); @@ -807,8 +825,7 @@ export function FaqBackground() { }; applySize(); - const resizeObserver = new ResizeObserver(applySize); - resizeObserver.observe(container); + const stopObservingSize = observeElementSize(container, applySize); loadFaqGeometry(GLB_URL) .then((geometry) => { @@ -825,17 +842,16 @@ export function FaqBackground() { console.error(error); }); - const frameTimer = createFrameTimer(); - - const renderFrame = () => { + const renderFrame = ( + _timestamp: DOMHighResTimeStamp, + { elapsedSeconds }: VisualRenderLoopFrame, + ) => { if (cancelled) { return; } - animationFrameId = window.requestAnimationFrame(renderFrame); - const delta = 1 / 60; - const elapsedTime = INITIAL_TIME_ELAPSED + frameTimer.getElapsed(); + const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds; halftoneMaterial.uniforms.time.value = elapsedTime; rotateElapsed += delta; @@ -907,12 +923,18 @@ export function FaqBackground() { renderer.render(postScene, orthographicCamera); }; - renderFrame(); + renderLoop = createVisualRenderLoop({ + renderFrame, + shouldRender: () => !cancelled, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); return () => { cancelled = true; - resizeObserver.disconnect(); - window.cancelAnimationFrame(animationFrameId); + renderLoop?.dispose(); + stopObservingSize(); blurHorizontalMaterial.dispose(); blurVerticalMaterial.dispose(); halftoneMaterial.dispose(); diff --git a/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx b/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx index 33cecf289b..b5f56f8be4 100644 --- a/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx +++ b/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx @@ -6,9 +6,12 @@ import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; import { - createFrameTimer, - createSiteWebGlRenderer, + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, + type VisualRenderLoopFrame, } from '@/lib/visual-runtime'; import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; @@ -627,7 +630,7 @@ export function FooterBackground() { } let cancelled = false; - let animationFrameId = 0; + let renderLoop: VisualRenderLoop | null = null; const getWidth = () => Math.max(container.clientWidth, 1); const getHeight = () => Math.max(container.clientHeight, 1); @@ -640,7 +643,18 @@ export function FooterBackground() { 1, ); - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); + const renderer = tryCreateSiteWebGlRenderer({ + antialias: false, + alpha: true, + onContextLost: () => { + renderLoop?.stop(); + }, + }); + + if (renderer === null) { + return; + } + renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setPixelRatio(1); renderer.setClearColor(0x000000, 0); @@ -802,8 +816,7 @@ export function FooterBackground() { ); }; - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); + const stopObservingSize = observeElementSize(container, syncSize); const updatePointerPosition = (event: PointerEvent) => { const rect = canvas.getBoundingClientRect(); @@ -894,16 +907,15 @@ export function FooterBackground() { console.error(error); }); - const frameTimer = createFrameTimer(); - - const renderFrame = () => { + const renderFrame = ( + _timestamp: DOMHighResTimeStamp, + { elapsedSeconds }: VisualRenderLoopFrame, + ) => { if (cancelled) { return; } - animationFrameId = window.requestAnimationFrame(renderFrame); - - const elapsedTime = INITIAL_TIME_ELAPSED + frameTimer.getElapsed(); + const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds; halftoneMaterial.uniforms.time.value = elapsedTime; halftoneMaterial.uniforms.interactionUv.value.set( interaction.mouseX, @@ -965,18 +977,24 @@ export function FooterBackground() { renderer.render(postScene, orthographicCamera); }; - renderFrame(); + renderLoop = createVisualRenderLoop({ + renderFrame, + shouldRender: () => !cancelled, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); return () => { cancelled = true; - resizeObserver.disconnect(); + renderLoop?.dispose(); + stopObservingSize(); canvas.removeEventListener('pointermove', handlePointerMove); canvas.removeEventListener('pointerleave', handlePointerLeave); canvas.removeEventListener('pointerdown', handlePointerDown); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointermove', handleWindowPointerMove); window.removeEventListener('blur', handleWindowBlur); - window.cancelAnimationFrame(animationFrameId); blurHorizontalMaterial.dispose(); blurVerticalMaterial.dispose(); halftoneMaterial.dispose(); diff --git a/packages/twenty-website-new/src/sections/Helped/types/index.ts b/packages/twenty-website-new/src/sections/Helped/types/index.ts deleted file mode 100644 index ce5746994c..0000000000 --- a/packages/twenty-website-new/src/sections/Helped/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { HeadingCardType, HelpedVisualId } from './HeadingCard'; -export type { HelpedDataType } from './HelpedData'; diff --git a/packages/twenty-website-new/src/sections/Helped/visuals/HelpedHalftoneModel.tsx b/packages/twenty-website-new/src/sections/Helped/visuals/HelpedHalftoneModel.tsx index 385e84fbfb..ce81db32df 100644 --- a/packages/twenty-website-new/src/sections/Helped/visuals/HelpedHalftoneModel.tsx +++ b/packages/twenty-website-new/src/sections/Helped/visuals/HelpedHalftoneModel.tsx @@ -19,9 +19,12 @@ import * as THREE from 'three'; import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; import { - createFrameTimer, - createSiteWebGlRenderer, + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, + type VisualRenderLoopFrame, } from '@/lib/visual-runtime'; import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; @@ -827,7 +830,7 @@ function HelpedHalftoneCanvas({ return; } - let animationFrameId = 0; + let renderLoop: VisualRenderLoop | null = null; const getWidth = () => Math.max(container.clientWidth, 1); const getHeight = () => Math.max(container.clientHeight, 1); @@ -840,7 +843,18 @@ function HelpedHalftoneCanvas({ 1, ); - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); + const renderer = tryCreateSiteWebGlRenderer({ + antialias: false, + alpha: true, + onContextLost: () => { + renderLoop?.stop(); + }, + }); + + if (renderer === null) { + return; + } + renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setPixelRatio(1); renderer.setClearColor(0x000000, 0); @@ -1026,8 +1040,7 @@ function HelpedHalftoneCanvas({ ); }; - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); + const stopObservingSize = observeElementSize(container, syncSize); const updatePointerPosition = (event: PointerEvent) => { const rect = canvas.getBoundingClientRect(); @@ -1128,13 +1141,12 @@ function HelpedHalftoneCanvas({ window.addEventListener('pointermove', handleWindowPointerMove); window.addEventListener('pointerup', handlePointerUp); - const frameTimer = createFrameTimer(); - - const renderFrame = () => { - animationFrameId = window.requestAnimationFrame(renderFrame); - - const delta = frameTimer.tick(); - const elapsedTime = initialPose.timeElapsed + frameTimer.getElapsed(); + const renderFrame = ( + _timestamp: DOMHighResTimeStamp, + { deltaSeconds, elapsedSeconds }: VisualRenderLoopFrame, + ) => { + const delta = deltaSeconds; + const elapsedTime = initialPose.timeElapsed + elapsedSeconds; halftoneMaterial.uniforms.time.value = elapsedTime; let baseRotationX = 0; @@ -1378,11 +1390,16 @@ function HelpedHalftoneCanvas({ renderer.render(postScene, orthographicCamera); }; - renderFrame(); + renderLoop = createVisualRenderLoop({ + renderFrame, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); return () => { - window.cancelAnimationFrame(animationFrameId); - resizeObserver.disconnect(); + renderLoop?.dispose(); + stopObservingSize(); canvas.removeEventListener('pointerdown', handlePointerDown); canvas.removeEventListener('pointerleave', handlePointerLeave); canvas.removeEventListener('pointermove', handlePointerMove); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/normalizeHeroPage.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/normalizeHeroPage.ts similarity index 98% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/normalizeHeroPage.ts rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/normalizeHeroPage.ts index 62eb804c41..1c394450a9 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/normalizeHeroPage.ts +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/normalizeHeroPage.ts @@ -4,7 +4,7 @@ import type { HeroSidebarItem, HeroTablePageDefinition, HeroWorkflowPageDefinition, -} from '../../types/HeroHomeData'; +} from '@/sections/Hero/types'; export type HeroPageDefaults = { defaultActions: string[]; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/objectPinnedActions.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/objectPinnedActions.ts similarity index 94% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/objectPinnedActions.ts rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/objectPinnedActions.ts index 49331e1ed3..857d5832a1 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/objectPinnedActions.ts +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/objectPinnedActions.ts @@ -1,4 +1,4 @@ -import type { HeroNavbarActionType } from '../../types/HeroHomeData'; +import type { HeroNavbarActionType } from '@/sections/Hero/types'; type PinnedAction = HeroNavbarActionType; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/rocketObject.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/rocketObject.ts similarity index 99% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/rocketObject.ts rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/rocketObject.ts index c96af31f5d..73771817e7 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/rocketObject.ts +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/rocketObject.ts @@ -1,7 +1,7 @@ import type { HeroSidebarItem, HeroTablePageDefinition, -} from '../../types/HeroHomeData'; +} from '@/sections/Hero/types'; export const ROCKET_ITEM_ID = 'rockets'; export const ROCKET_ITEM_LABEL = 'Rockets'; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx index 3c46999284..bf25279ce2 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx @@ -3,17 +3,20 @@ import { styled } from '@linaria/react'; import { useCallback, - useEffect, useLayoutEffect, useRef, useState, - type PointerEvent as ReactPointerEvent, type ReactNode, } from 'react'; import { theme } from '@/theme'; -import { VISUAL_TOKENS } from '../homeVisualTokens'; +import { VISUAL_TOKENS } from '../Shared/homeVisualTokens'; +import { useWindowPointerInteractions } from '../WindowInteraction/use-window-pointer-interactions'; +import type { + WindowPosition as Position, + WindowSize as Size, +} from '../WindowInteraction/window-geometry'; import { useWindowOrder } from '../WindowOrder/WindowOrderProvider'; -import { WINDOW_SHADOWS } from '../windowShadows'; +import { WINDOW_SHADOWS } from '../Shared/windowShadows'; import { MacWindowBar } from './MacWindowBar'; const WINDOW_ID = 'twenty-app-window'; @@ -24,61 +27,6 @@ const INITIAL_MAX_WIDTH = 1040; const INITIAL_ASPECT_RATIO = 1280 / 832; const MOBILE_PARENT_BREAKPOINT = 640; -type Position = { left: number; top: number }; -type Size = { width: number; height: number }; - -type DragState = { - pointerId: number; - originX: number; - originY: number; - startLeft: number; - startTop: number; -}; - -type ResizeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; - -type ResizeEdge = 'top' | 'right' | 'bottom' | 'left'; - -type ResizeHandle = ResizeCorner | ResizeEdge; - -type ResizeState = { - pointerId: number; - originX: number; - originY: number; - startWidth: number; - startHeight: number; - startLeft: number; - startTop: number; - handle: ResizeHandle; -}; - -const HORIZONTAL_HANDLES: ReadonlySet = new Set([ - 'top-left', - 'top-right', - 'bottom-left', - 'bottom-right', - 'left', - 'right', -]); -const VERTICAL_HANDLES: ReadonlySet = new Set([ - 'top-left', - 'top-right', - 'bottom-left', - 'bottom-right', - 'top', - 'bottom', -]); -const LEFT_HANDLES: ReadonlySet = new Set([ - 'top-left', - 'bottom-left', - 'left', -]); -const TOP_HANDLES: ReadonlySet = new Set([ - 'top-left', - 'top-right', - 'top', -]); - const Shell = styled.div<{ $isResizing: boolean; $isReady: boolean; @@ -182,13 +130,9 @@ type DraggableAppWindowProps = { export const DraggableAppWindow = ({ children }: DraggableAppWindowProps) => { const shellRef = useRef(null); - const dragStateRef = useRef(null); - const resizeStateRef = useRef(null); const [position, setPosition] = useState(null); const [size, setSize] = useState(null); - const [isDragging, setIsDragging] = useState(false); - const [isResizing, setIsResizing] = useState(false); const { activate, zIndex } = useWindowOrder(WINDOW_ID); @@ -229,247 +173,26 @@ export const DraggableAppWindow = ({ children }: DraggableAppWindowProps) => { return parent?.getBoundingClientRect() ?? null; }, []); - const clampPosition = useCallback( - (candidateLeft: number, candidateTop: number, currentSize: Size) => { - const parentRect = getParentRect(); - if (!parentRect) { - return { left: candidateLeft, top: candidateTop }; - } - const maxLeft = parentRect.width - currentSize.width - MIN_EDGE_GAP; - const maxTop = parentRect.height - currentSize.height - MIN_EDGE_GAP; - return { - left: Math.min(Math.max(candidateLeft, MIN_EDGE_GAP), maxLeft), - top: Math.min(Math.max(candidateTop, MIN_EDGE_GAP), maxTop), - }; - }, - [getParentRect], - ); - - const handleDragStart = useCallback( - (event: ReactPointerEvent) => { - if (event.pointerType === 'mouse' && event.button !== 0) { - return; - } - const target = event.target as HTMLElement | null; - if ( - target && - target.closest('button, a, input, textarea, select, [role="button"]') - ) { - return; - } - if (!position) { - return; - } - - event.preventDefault(); - activate(); - - const shell = shellRef.current; - shell?.setPointerCapture?.(event.pointerId); - dragStateRef.current = { - pointerId: event.pointerId, - originX: event.clientX, - originY: event.clientY, - startLeft: position.left, - startTop: position.top, - }; - setIsDragging(true); - }, - [activate, position], - ); - - const latestPositionRef = useRef(position); - const latestSizeRef = useRef(size); - useEffect(() => { - latestPositionRef.current = position; - }, [position]); - useEffect(() => { - latestSizeRef.current = size; - }, [size]); - - useEffect(() => { - if (!isDragging) { - return undefined; - } - - const handleMove = (event: PointerEvent) => { - const state = dragStateRef.current; - if (!state || state.pointerId !== event.pointerId || !size) { - return; - } - const nextLeft = state.startLeft + (event.clientX - state.originX); - const nextTop = state.startTop + (event.clientY - state.originY); - const clamped = clampPosition(nextLeft, nextTop, size); - latestPositionRef.current = clamped; - const shell = shellRef.current; - if (shell !== null) { - shell.style.transform = `translate3d(${clamped.left}px, ${clamped.top}px, 0)`; - } - }; - - const stop = (event: PointerEvent) => { - const state = dragStateRef.current; - if (!state || state.pointerId !== event.pointerId) { - return; - } - dragStateRef.current = null; - setIsDragging(false); - const committed = latestPositionRef.current; - if (committed !== null) { - setPosition(committed); - } - shellRef.current?.releasePointerCapture?.(event.pointerId); - }; - - window.addEventListener('pointermove', handleMove); - window.addEventListener('pointerup', stop); - window.addEventListener('pointercancel', stop); - - return () => { - window.removeEventListener('pointermove', handleMove); - window.removeEventListener('pointerup', stop); - window.removeEventListener('pointercancel', stop); - }; - }, [clampPosition, isDragging, size]); - - const startResize = useCallback( - (handle: ResizeHandle) => (event: ReactPointerEvent) => { - if (event.pointerType === 'mouse' && event.button !== 0) { - return; - } - if (!position || !size) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - activate(); - - const shell = shellRef.current; - shell?.setPointerCapture?.(event.pointerId); - resizeStateRef.current = { - pointerId: event.pointerId, - originX: event.clientX, - originY: event.clientY, - startWidth: size.width, - startHeight: size.height, - startLeft: position.left, - startTop: position.top, - handle, - }; - setIsResizing(true); - }, - [activate, position, size], - ); - - useEffect(() => { - if (!isResizing) { - return undefined; - } - - const handleMove = (event: PointerEvent) => { - const state = resizeStateRef.current; - if (!state || state.pointerId !== event.pointerId) { - return; - } - const parentRect = getParentRect(); - if (!parentRect) { - return; - } - - const deltaX = event.clientX - state.originX; - const deltaY = event.clientY - state.originY; - - const affectsWidth = HORIZONTAL_HANDLES.has(state.handle); - const affectsHeight = VERTICAL_HANDLES.has(state.handle); - const growsFromLeft = LEFT_HANDLES.has(state.handle); - const growsFromTop = TOP_HANDLES.has(state.handle); - - const effectiveMinWidth = Math.min( - MIN_WIDTH, - Math.max(parentRect.width - MIN_EDGE_GAP * 2, 0), - ); - const effectiveMinHeight = Math.min( - MIN_HEIGHT, - Math.max(parentRect.height - MIN_EDGE_GAP * 2, 0), - ); - - let nextWidth = state.startWidth; - let nextLeft = state.startLeft; - if (affectsWidth) { - if (growsFromLeft) { - const maxWidth = state.startWidth + state.startLeft - MIN_EDGE_GAP; - nextWidth = Math.min( - Math.max(state.startWidth - deltaX, effectiveMinWidth), - Math.max(maxWidth, effectiveMinWidth), - ); - nextLeft = state.startLeft + state.startWidth - nextWidth; - } else { - const maxWidth = parentRect.width - state.startLeft - MIN_EDGE_GAP; - nextWidth = Math.min( - Math.max(state.startWidth + deltaX, effectiveMinWidth), - Math.max(maxWidth, effectiveMinWidth), - ); - } - } - - let nextHeight = state.startHeight; - let nextTop = state.startTop; - if (affectsHeight) { - if (growsFromTop) { - const maxHeight = state.startHeight + state.startTop - MIN_EDGE_GAP; - nextHeight = Math.min( - Math.max(state.startHeight - deltaY, effectiveMinHeight), - Math.max(maxHeight, effectiveMinHeight), - ); - nextTop = state.startTop + state.startHeight - nextHeight; - } else { - const maxHeight = parentRect.height - state.startTop - MIN_EDGE_GAP; - nextHeight = Math.min( - Math.max(state.startHeight + deltaY, effectiveMinHeight), - Math.max(maxHeight, effectiveMinHeight), - ); - } - } - - latestSizeRef.current = { width: nextWidth, height: nextHeight }; - latestPositionRef.current = { left: nextLeft, top: nextTop }; - const shell = shellRef.current; - if (shell !== null) { - shell.style.width = `${nextWidth}px`; - shell.style.height = `${nextHeight}px`; - shell.style.transform = `translate3d(${nextLeft}px, ${nextTop}px, 0)`; - } - }; - - const stop = (event: PointerEvent) => { - const state = resizeStateRef.current; - if (!state || state.pointerId !== event.pointerId) { - return; - } - resizeStateRef.current = null; - setIsResizing(false); - const committedSize = latestSizeRef.current; - const committedPosition = latestPositionRef.current; - if (committedSize !== null) { - setSize(committedSize); - } - if (committedPosition !== null) { - setPosition(committedPosition); - } - shellRef.current?.releasePointerCapture?.(event.pointerId); - }; - - window.addEventListener('pointermove', handleMove); - window.addEventListener('pointerup', stop); - window.addEventListener('pointercancel', stop); - - return () => { - window.removeEventListener('pointermove', handleMove); - window.removeEventListener('pointerup', stop); - window.removeEventListener('pointercancel', stop); - }; - }, [getParentRect, isResizing]); + const { + handleDragStart, + isDragging, + isResizing, + latestPositionRef, + latestSizeRef, + startResize, + } = useWindowPointerInteractions({ + activate, + blockedDragTargetSelector: + 'button, a, input, textarea, select, [role="button"]', + edgeGap: MIN_EDGE_GAP, + getBounds: getParentRect, + minSize: { width: MIN_WIDTH, height: MIN_HEIGHT }, + position, + setPosition, + setSize, + shellRef, + size, + }); const handleShellPointerDown = useCallback(() => { activate(); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/MacWindowBar.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/MacWindowBar.tsx index 54ca003b8b..e1fe24df6f 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/MacWindowBar.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/MacWindowBar.tsx @@ -2,7 +2,7 @@ import { styled } from '@linaria/react'; import type { PointerEvent as ReactPointerEvent } from 'react'; -import { TerminalTrafficLights } from '../DraggableTerminal/TerminalTrafficLights'; +import { TerminalTrafficLights } from '../DraggableTerminal/TerminalTrafficLights/TerminalTrafficLights'; type MacWindowBarProps = { title?: string; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponse.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponse.tsx new file mode 100644 index 0000000000..fc4837c1b6 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponse.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { useMemo } from 'react'; + +import { + ASSISTANT_RESPONSE_STREAMING_STAGES, + buildAssistantResponseSegments, +} from './assistant-response-segments'; +import { AssistantResponseParagraph } from './AssistantResponseParagraph'; +import { ChangesSummaryCard } from './ChangesSummaryCard'; +import { ThinkingIndicator } from './ThinkingIndicator'; +import { useAssistantResponseStage } from './use-assistant-response-stage'; + +const ResponseRoot = styled.div` + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; +`; + +const CardWrap = styled.div<{ $instant: boolean }>` + animation: ${({ $instant }) => + $instant + ? 'none' + : 'chatCardRise 420ms cubic-bezier(0.22, 1, 0.36, 1) both'}; + + @keyframes chatCardRise { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +type AssistantResponseProps = { + instantComplete?: boolean; + onUndo?: () => void; + onObjectCreated?: (id: string) => void; + onChatFinished?: () => void; +}; + +export const AssistantResponse = ({ + instantComplete = false, + onUndo, + onObjectCreated, + onChatFinished, +}: AssistantResponseProps) => { + const { createStageCompletionHandler, hasReachedStage, stage } = + useAssistantResponseStage({ + instantComplete, + onChatFinished, + }); + const objectCreationHandler = instantComplete ? undefined : onObjectCreated; + + const segmentsByStage = useMemo( + () => buildAssistantResponseSegments(objectCreationHandler), + [objectCreationHandler], + ); + + return ( + + {stage === 'thinking' && } + + {ASSISTANT_RESPONSE_STREAMING_STAGES.map((responseStage) => + hasReachedStage(responseStage) ? ( + + ) : null, + )} + + {hasReachedStage('card') && ( + + + + )} + + ); +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponseParagraph.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponseParagraph.tsx new file mode 100644 index 0000000000..2b70c3e645 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponseParagraph.tsx @@ -0,0 +1,42 @@ +import { styled } from '@linaria/react'; + +import { TERMINAL_TOKENS } from '../terminalTokens'; +import { + type AssistantResponseStage, + type AssistantResponseStreamingStage, +} from './assistant-response-stage'; +import { CHAT_TIMINGS } from './animationTiming'; +import { StreamingText, type StreamingSegment } from './StreamingText'; + +const Paragraph = styled.p` + color: ${TERMINAL_TOKENS.text.prompt}; + font-family: ${TERMINAL_TOKENS.font.ui}; + font-size: 13px; + line-height: 20px; + margin: 0; +`; + +type AssistantResponseParagraphProps = { + activeStage: AssistantResponseStage; + instant: boolean; + onStageComplete: (stage: AssistantResponseStreamingStage) => () => void; + segments: StreamingSegment[]; + stage: AssistantResponseStreamingStage; +}; + +export const AssistantResponseParagraph = ({ + activeStage, + instant, + onStageComplete, + segments, + stage, +}: AssistantResponseParagraphProps) => ( + + + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ChangesSummaryCard.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryCard.tsx similarity index 57% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ChangesSummaryCard.tsx rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryCard.tsx index 54949b2c51..8989274229 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ChangesSummaryCard.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryCard.tsx @@ -4,20 +4,19 @@ import { styled } from '@linaria/react'; import { IconArrowBackUp, IconChevronDown, - IconChevronRight, IconChevronUp, } from '@tabler/icons-react'; import { useState } from 'react'; + import { CHAT_TIMINGS } from './animationTiming'; import { - CHANGESET_TOTALS, - ROCKET_CHANGESET, - type FileChange, -} from './rocketChangeset'; - -const ROW_STAGGER_MS = 24; -const ROW_BASE_DELAY_MS = 40; -const COLLAPSED_FILE_COUNT = 3; + getChangesSummaryRowDelay, + getHiddenChangesCount, + getVisibleChanges, +} from './changes-summary-card-state'; +import { DiffAdded, DiffRemoved } from './ChangesSummaryDiffCounts'; +import { ChangesSummaryFileRow } from './ChangesSummaryFileRow'; +import { CHANGESET_TOTALS, ROCKET_CHANGESET } from './rocketChangeset'; const CardRoot = styled.div` animation: chatCardRise ${CHAT_TIMINGS.fileCardEnterMs}ms @@ -60,18 +59,6 @@ const HeaderTitle = styled.div` gap: 6px; `; -const DiffAdded = styled.span` - color: #2f7d52; - font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; - font-weight: 600; -`; - -const DiffRemoved = styled.span` - color: #a94a4f; - font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; - font-weight: 600; -`; - const UndoButton = styled.button` align-items: center; background: transparent; @@ -101,82 +88,6 @@ const FileList = styled.div` flex-direction: column; `; -const FileRow = styled.div<{ $delay: string }>` - animation: chatFileRowFade 240ms ease-out both; - animation-delay: ${({ $delay }) => $delay}; - align-items: center; - display: flex; - gap: 10px; - padding: 9px 14px; - transition: background-color 0.14s ease; - - & + & { - border-top: 1px solid rgba(0, 0, 0, 0.04); - } - - &:hover { - background: rgba(0, 0, 0, 0.02); - } - - @keyframes chatFileRowFade { - from { - opacity: 0; - transform: translateY(3px); - } - to { - opacity: 1; - transform: translateY(0); - } - } -`; - -const FilePath = styled.span` - color: rgba(0, 0, 0, 0.78); - flex: 1 1 auto; - font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; - font-size: 11.5px; - font-weight: 500; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const DiffCounts = styled.span` - align-items: center; - display: inline-flex; - flex: 0 0 auto; - font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; - font-size: 11px; - gap: 6px; -`; - -const Chevron = styled.span` - align-items: center; - color: rgba(0, 0, 0, 0.3); - display: inline-flex; - flex: 0 0 auto; -`; - -const ZeroCount = styled.span` - color: rgba(0, 0, 0, 0.35); -`; - -const renderDiffCounts = (change: FileChange) => ( - - {change.added > 0 ? ( - +{change.added} - ) : ( - +0 - )} - {change.removed > 0 ? ( - -{change.removed} - ) : ( - -0 - )} - -); - const SeeMoreButton = styled.button` align-items: center; background: transparent; @@ -213,14 +124,11 @@ type ChangesSummaryCardProps = { export const ChangesSummaryCard = ({ onUndo }: ChangesSummaryCardProps) => { const [isExpanded, setIsExpanded] = useState(false); - const hiddenCount = Math.max( - ROCKET_CHANGESET.length - COLLAPSED_FILE_COUNT, - 0, - ); - const visibleChanges = - isExpanded || hiddenCount === 0 - ? ROCKET_CHANGESET - : ROCKET_CHANGESET.slice(0, COLLAPSED_FILE_COUNT); + const hiddenCount = getHiddenChangesCount({ changes: ROCKET_CHANGESET }); + const visibleChanges = getVisibleChanges({ + changes: ROCKET_CHANGESET, + isExpanded, + }); return ( @@ -237,16 +145,11 @@ export const ChangesSummaryCard = ({ onUndo }: ChangesSummaryCardProps) => { {visibleChanges.map((change, index) => ( - - {change.path} - {renderDiffCounts(change)} - - - - + /> ))} {hiddenCount > 0 && ( diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryDiffCounts.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryDiffCounts.tsx new file mode 100644 index 0000000000..1eb8e807ba --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryDiffCounts.tsx @@ -0,0 +1,45 @@ +import { styled } from '@linaria/react'; + +export const DiffAdded = styled.span` + color: #2f7d52; + font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; + font-weight: 600; +`; + +export const DiffRemoved = styled.span` + color: #a94a4f; + font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; + font-weight: 600; +`; + +const DiffCounts = styled.span` + align-items: center; + display: inline-flex; + flex: 0 0 auto; + font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 11px; + gap: 6px; +`; + +const ZeroCount = styled.span` + color: rgba(0, 0, 0, 0.35); +`; + +type ChangesSummaryDiffCountsProps = { + added: number; + removed: number; +}; + +export const ChangesSummaryDiffCounts = ({ + added, + removed, +}: ChangesSummaryDiffCountsProps) => ( + + {added > 0 ? +{added} : +0} + {removed > 0 ? ( + -{removed} + ) : ( + -0 + )} + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryFileRow.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryFileRow.tsx new file mode 100644 index 0000000000..9c081762c0 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryFileRow.tsx @@ -0,0 +1,71 @@ +import { styled } from '@linaria/react'; +import { IconChevronRight } from '@tabler/icons-react'; + +import type { FileChange } from './rocketChangeset'; +import { ChangesSummaryDiffCounts } from './ChangesSummaryDiffCounts'; + +const FileRow = styled.div<{ $delay: string }>` + animation: chatFileRowFade 240ms ease-out both; + animation-delay: ${({ $delay }) => $delay}; + align-items: center; + display: flex; + gap: 10px; + padding: 9px 14px; + transition: background-color 0.14s ease; + + & + & { + border-top: 1px solid rgba(0, 0, 0, 0.04); + } + + &:hover { + background: rgba(0, 0, 0, 0.02); + } + + @keyframes chatFileRowFade { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const FilePath = styled.span` + color: rgba(0, 0, 0, 0.78); + flex: 1 1 auto; + font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 11.5px; + font-weight: 500; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Chevron = styled.span` + align-items: center; + color: rgba(0, 0, 0, 0.3); + display: inline-flex; + flex: 0 0 auto; +`; + +type ChangesSummaryFileRowProps = { + animationDelay: string; + change: FileChange; +}; + +export const ChangesSummaryFileRow = ({ + animationDelay, + change, +}: ChangesSummaryFileRowProps) => ( + + {change.path} + + + + + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ConversationPanel.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ConversationPanel.tsx similarity index 64% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ConversationPanel.tsx rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ConversationPanel.tsx index e0c3b2ec03..80fbcf6ca4 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ConversationPanel.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ConversationPanel.tsx @@ -1,9 +1,8 @@ 'use client'; import { styled } from '@linaria/react'; -import { useEffect, useRef } from 'react'; -import { getPrefersReducedMotionSnapshot } from '@/lib/motion'; import { AssistantResponse } from './AssistantResponse'; +import { useConversationAutoScroll } from './use-conversation-auto-scroll'; import { UserMessage } from './UserMessage'; export type ConversationMessage = @@ -53,42 +52,7 @@ export const ConversationPanel = ({ onObjectCreated, onChatFinished, }: ConversationPanelProps) => { - const scrollRef = useRef(null); - - useEffect(() => { - const element = scrollRef.current; - if (!element) { - return undefined; - } - - let frameId: number | null = null; - const scheduleScroll = () => { - if (frameId !== null) { - return; - } - frameId = window.requestAnimationFrame(() => { - frameId = null; - const behavior = getPrefersReducedMotionSnapshot() ? 'auto' : 'smooth'; - element.scrollTo({ top: element.scrollHeight, behavior }); - }); - }; - - scheduleScroll(); - - const observer = new MutationObserver(scheduleScroll); - observer.observe(element, { - characterData: true, - childList: true, - subtree: true, - }); - - return () => { - observer.disconnect(); - if (frameId !== null) { - window.cancelAnimationFrame(frameId); - } - }; - }, []); + const scrollRef = useConversationAutoScroll(); return ( diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/StreamingText.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/StreamingText.tsx new file mode 100644 index 0000000000..6ebd320c5a --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/StreamingText.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { styled } from '@linaria/react'; + +import { renderStreamingSegments } from './render-streaming-segments'; +import type { StreamingSegment } from './streaming-text-types'; +import { useStreamingTextProgress } from './use-streaming-text-progress'; + +type StreamingTextProps = { + segments: ReadonlyArray; + charDurationMs?: number; + instant?: boolean; + onComplete?: () => void; +}; + +const StreamWrap = styled.span` + display: inline; +`; + +const Caret = styled.span` + animation: chatCaretBlink 1s steps(1, end) infinite; + background: currentColor; + display: inline-block; + height: 1em; + margin-left: 2px; + opacity: 0.55; + vertical-align: text-bottom; + width: 1.5px; + + @keyframes chatCaretBlink { + 0%, + 50% { + opacity: 0.55; + } + 51%, + 100% { + opacity: 0; + } + } +`; + +export const StreamingText = ({ + segments, + charDurationMs = 14, + instant = false, + onComplete, +}: StreamingTextProps) => { + const { isComplete, revealed } = useStreamingTextProgress({ + charDurationMs, + instant, + onComplete, + segments, + }); + + return ( + + {renderStreamingSegments(segments, revealed)} + {!isComplete && } + + ); +}; + +export type { StreamingSegment }; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ThinkingIndicator.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ThinkingIndicator.tsx similarity index 100% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ThinkingIndicator.tsx rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ThinkingIndicator.tsx diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/UserMessage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/UserMessage.tsx similarity index 100% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/UserMessage.tsx rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/UserMessage.tsx diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/animationTiming.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/animationTiming.ts similarity index 100% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/animationTiming.ts rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/animationTiming.ts diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-segments.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-segments.tsx new file mode 100644 index 0000000000..8d0a4f0d14 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-segments.tsx @@ -0,0 +1,237 @@ +import { styled } from '@linaria/react'; +import type { ReactNode } from 'react'; + +import { TERMINAL_TOKENS } from '../terminalTokens'; +import type { AssistantResponseStreamingStage } from './assistant-response-stage'; +import type { StreamingSegment } from './StreamingText'; + +const InlineCode = styled.span` + background: rgba(0, 0, 0, 0.045); + border-radius: 3px; + color: rgba(0, 0, 0, 0.78); + font-family: ${TERMINAL_TOKENS.font.mono}; + font-size: 12px; + padding: 1px 5px; +`; + +const FileLink = styled.span` + color: #2a66de; + cursor: pointer; + font-family: ${TERMINAL_TOKENS.font.mono}; + font-size: 12px; + + &:hover { + text-decoration: underline; + } +`; + +const ReferenceLink = styled.a` + color: #2a66de; + text-decoration: underline; + text-underline-offset: 2px; + + &:hover { + color: #1e4ea8; + } +`; + +const text = (value: string, onReveal?: () => void): StreamingSegment => ({ + kind: 'text', + value, + onReveal, +}); + +const node = ( + key: string, + value: ReactNode, + onReveal?: () => void, +): StreamingSegment => ({ + kind: 'node', + value: {value}, + onReveal, +}); + +const ROCKET_ID = 'rockets'; +const LAUNCH_ID = 'launches'; +const PAYLOAD_ID = 'payloads'; +const COMPANIES_ID = 'companies'; +const LAUNCH_SITE_ID = 'launch-sites'; + +export const ASSISTANT_RESPONSE_STREAMING_STAGES = [ + 'rocket', + 'launch', + 'payload', + 'customer', + 'launchSite', + 'actions', + 'wrapup', +] as const satisfies readonly AssistantResponseStreamingStage[]; + +const buildIntroAndRocketParagraph = ( + onObjectCreated?: (id: string) => void, +): StreamingSegment[] => [ + text( + "I'll scaffold a launch-ops CRM in your workspace: four new objects plus the standard ", + ), + node('rocket-companies', Companies), + text(' object for customers, with shared UUIDs in '), + node('rocket-ids', schema-identifiers.ts), + text('. First up: '), + node( + 'rocket-chip', + Rocket, + onObjectCreated ? () => onObjectCreated(ROCKET_ID) : undefined, + ), + text( + '. Each vehicle gets a serial number, manufacturer, lifecycle status, reusability, launch date, dimensions, and target orbit in ', + ), + node('rocket-file', rocket.object.ts), + text('.'), +]; + +const buildLaunchParagraph = ( + onObjectCreated?: (id: string) => void, +): StreamingSegment[] => [ + text('Next up: '), + node( + 'launch-chip', + Launch, + onObjectCreated ? () => onObjectCreated(LAUNCH_ID) : undefined, + ), + text( + '. Every mission gets a unique mission code, status, mission type, planned and actual launch times, and a summary. Defined in ', + ), + node('launch-file', launch.object.ts), + text('.'), +]; + +const buildPayloadParagraph = ( + onObjectCreated?: (id: string) => void, +): StreamingSegment[] => [ + text('Now '), + node( + 'payload-chip', + Payload, + onObjectCreated ? () => onObjectCreated(PAYLOAD_ID) : undefined, + ), + text( + '. This covers what actually flies: satellites, crew capsules, cargo, probes, and landers, with type, status, target orbit, mass, and a customer reference. Scoped in ', + ), + node('payload-file', payload.object.ts), + text('.'), +]; + +const buildCustomerParagraph = ( + onObjectCreated?: (id: string) => void, +): StreamingSegment[] => [ + text('For customers, there is no new object. I reuse the standard '), + node( + 'customer-chip', + Companies, + onObjectCreated ? () => onObjectCreated(COMPANIES_ID) : undefined, + ), + text( + ' object that ships with Twenty, so accounts, domain favicons, and the People relation work for free. ', + ), + node('customer-file', payload.object.ts), + text(' points its '), + node('customer-field', customer), + text(' relation straight at it.'), +]; + +const buildLaunchSiteParagraph = ( + onObjectCreated?: (id: string) => void, +): StreamingSegment[] => [ + text('Last object: '), + node( + 'launch-site-chip', + Launch site, + onObjectCreated ? () => onObjectCreated(LAUNCH_SITE_ID) : undefined, + ), + text( + '. This covers pads and ranges with a site code, country, region, pad name, and operational status. Lives in ', + ), + node('launch-site-file', launch-site.object.ts), + text('.'), +]; + +const PINNED_ACTIONS_PARAGRAPH: StreamingSegment[] = [ + text( + 'Each object also gets 2-3 relevant quick commands pinned to its header. Next to ', + ), + node('pa-new', New), + text(', '), + node('pa-rocket', Rocket), + text(' has reuse / retire shortcuts, '), + node('pa-launch', Launch), + text(' has '), + node('pa-l-resched', Reschedule), + text(' and '), + node('pa-l-payload', Add payload), + text(', '), + node('pa-payload', Payload), + text(' has '), + node('pa-p-book', Book slot), + text(', '), + node('pa-companies', Companies), + text(' has a quick '), + node('pa-c-status', Set status), + text(', and '), + node('pa-site', Launch site), + text(' has '), + node('pa-s-window', Book window), + text('. Defined under '), + node('pa-folder', src/command-menu-items/), + text('.'), +]; + +const WRAPUP_PARAGRAPH: StreamingSegment[] = [ + text('Relations wire '), + node('w-rl', Rocket → Launches), + text(', '), + node('w-sl', LaunchSite → Launches), + text(', '), + node('w-cp', Company → Payloads), + text(', and '), + node('w-lp', Launch → Payloads), + text('. Each object gets an index view and sidebar entry; '), + node('w-launches', Launches), + text(' also has '), + node('w-upcoming', upcoming-launches.view.ts), + text(' and '), + node('w-past', past-launches.view.ts), + text('. Verified with '), + node('w-lint', yarn lint), + text(', '), + node('w-tsc', tsc --noEmit), + text(', '), + node( + 'w-vitest', + vitest run schema.integration-test.ts, + ), + text(', and '), + node('w-dev', yarn twenty dev --once), + text('. Reference: '), + node( + 'w-docs', + event.preventDefault()} + > + Twenty app-building docs + , + ), + text('.'), +]; + +export const buildAssistantResponseSegments = ( + onObjectCreated?: (id: string) => void, +): Record => ({ + actions: PINNED_ACTIONS_PARAGRAPH, + customer: buildCustomerParagraph(onObjectCreated), + launch: buildLaunchParagraph(onObjectCreated), + launchSite: buildLaunchSiteParagraph(onObjectCreated), + payload: buildPayloadParagraph(onObjectCreated), + rocket: buildIntroAndRocketParagraph(onObjectCreated), + wrapup: WRAPUP_PARAGRAPH, +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.test.ts new file mode 100644 index 0000000000..7980555006 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.test.ts @@ -0,0 +1,46 @@ +import { + ASSISTANT_RESPONSE_STAGE_DELAYS, + getAssistantResponseStageTransition, + hasAssistantResponseStageReached, +} from './assistant-response-stage'; + +describe('assistant-response-stage', () => { + it('reports whether a stage has already been reached', () => { + expect( + hasAssistantResponseStageReached({ + currentStage: 'payload', + targetStage: 'launch', + }), + ).toBe(true); + + expect( + hasAssistantResponseStageReached({ + currentStage: 'launch', + targetStage: 'payload', + }), + ).toBe(false); + }); + + it('returns deterministic transitions for streamed response stages', () => { + expect(getAssistantResponseStageTransition('rocket')).toEqual({ + delayMs: ASSISTANT_RESPONSE_STAGE_DELAYS.afterObjectBeatMs, + nextStage: 'launch', + }); + + expect(getAssistantResponseStageTransition('launchSite')).toEqual({ + delayMs: ASSISTANT_RESPONSE_STAGE_DELAYS.betweenParagraphsMs, + nextStage: 'actions', + }); + + expect(getAssistantResponseStageTransition('wrapup')).toEqual({ + delayMs: ASSISTANT_RESPONSE_STAGE_DELAYS.beforeCardMs, + nextStage: 'card', + }); + }); + + it('does not create transitions for terminal states', () => { + expect(getAssistantResponseStageTransition('thinking')).toBeNull(); + expect(getAssistantResponseStageTransition('card')).toBeNull(); + expect(getAssistantResponseStageTransition('done')).toBeNull(); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.ts new file mode 100644 index 0000000000..4e1b5304e5 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.ts @@ -0,0 +1,95 @@ +export type AssistantResponseStage = + | 'thinking' + | 'rocket' + | 'launch' + | 'payload' + | 'customer' + | 'launchSite' + | 'actions' + | 'wrapup' + | 'card' + | 'done'; + +export type AssistantResponseStreamingStage = Exclude< + AssistantResponseStage, + 'thinking' | 'card' | 'done' +>; + +type AssistantResponseStageTransition = { + nextStage: AssistantResponseStage; + delayMs: number; +}; + +export const ASSISTANT_RESPONSE_STAGE_DELAYS = { + afterCardRevealMs: 180, + afterObjectBeatMs: 520, + beforeCardMs: 420, + betweenParagraphsMs: 320, +} as const; + +export const ASSISTANT_RESPONSE_STAGE_ORDER: readonly AssistantResponseStage[] = + [ + 'thinking', + 'rocket', + 'launch', + 'payload', + 'customer', + 'launchSite', + 'actions', + 'wrapup', + 'card', + 'done', + ]; + +const ASSISTANT_RESPONSE_STAGE_TRANSITIONS: Record< + AssistantResponseStreamingStage, + AssistantResponseStageTransition +> = { + actions: { + delayMs: ASSISTANT_RESPONSE_STAGE_DELAYS.betweenParagraphsMs, + nextStage: 'wrapup', + }, + customer: { + delayMs: ASSISTANT_RESPONSE_STAGE_DELAYS.afterObjectBeatMs, + nextStage: 'launchSite', + }, + launch: { + delayMs: ASSISTANT_RESPONSE_STAGE_DELAYS.afterObjectBeatMs, + nextStage: 'payload', + }, + launchSite: { + delayMs: ASSISTANT_RESPONSE_STAGE_DELAYS.betweenParagraphsMs, + nextStage: 'actions', + }, + payload: { + delayMs: ASSISTANT_RESPONSE_STAGE_DELAYS.afterObjectBeatMs, + nextStage: 'customer', + }, + rocket: { + delayMs: ASSISTANT_RESPONSE_STAGE_DELAYS.afterObjectBeatMs, + nextStage: 'launch', + }, + wrapup: { + delayMs: ASSISTANT_RESPONSE_STAGE_DELAYS.beforeCardMs, + nextStage: 'card', + }, +}; + +export const hasAssistantResponseStageReached = ({ + currentStage, + targetStage, +}: { + currentStage: AssistantResponseStage; + targetStage: AssistantResponseStage; +}) => + ASSISTANT_RESPONSE_STAGE_ORDER.indexOf(currentStage) >= + ASSISTANT_RESPONSE_STAGE_ORDER.indexOf(targetStage); + +export const getAssistantResponseStageTransition = ( + completedStage: AssistantResponseStage, +): AssistantResponseStageTransition | null => + completedStage in ASSISTANT_RESPONSE_STAGE_TRANSITIONS + ? ASSISTANT_RESPONSE_STAGE_TRANSITIONS[ + completedStage as AssistantResponseStreamingStage + ] + : null; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.test.ts new file mode 100644 index 0000000000..d20610468a --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.test.ts @@ -0,0 +1,51 @@ +import { + getChangesSummaryRowDelay, + getHiddenChangesCount, + getVisibleChanges, +} from './changes-summary-card-state'; +import type { FileChange } from './rocketChangeset'; + +const changes: FileChange[] = [ + { added: 10, path: 'one.ts', removed: 0 }, + { added: 20, path: 'two.ts', removed: 1 }, + { added: 30, path: 'three.ts', removed: 2 }, + { added: 40, path: 'four.ts', removed: 3 }, +]; + +describe('changes-summary-card-state', () => { + it('computes the number of hidden collapsed changes', () => { + expect(getHiddenChangesCount({ changes, collapsedFileCount: 3 })).toEqual( + 1, + ); + + expect( + getHiddenChangesCount({ + changes: changes.slice(0, 2), + collapsedFileCount: 3, + }), + ).toEqual(0); + }); + + it('returns collapsed or expanded visible changes deterministically', () => { + expect( + getVisibleChanges({ + changes, + collapsedFileCount: 2, + isExpanded: false, + }), + ).toEqual(changes.slice(0, 2)); + + expect( + getVisibleChanges({ + changes, + collapsedFileCount: 2, + isExpanded: true, + }), + ).toBe(changes); + }); + + it('computes stable row animation delays', () => { + expect(getChangesSummaryRowDelay(0)).toBe('40ms'); + expect(getChangesSummaryRowDelay(3)).toBe('112ms'); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.ts new file mode 100644 index 0000000000..5929e7097a --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.ts @@ -0,0 +1,29 @@ +import type { FileChange } from './rocketChangeset'; + +export const CHANGES_SUMMARY_COLLAPSED_FILE_COUNT = 3; +export const CHANGES_SUMMARY_ROW_BASE_DELAY_MS = 40; +export const CHANGES_SUMMARY_ROW_STAGGER_MS = 24; + +export const getHiddenChangesCount = ({ + changes, + collapsedFileCount = CHANGES_SUMMARY_COLLAPSED_FILE_COUNT, +}: { + changes: ReadonlyArray; + collapsedFileCount?: number; +}): number => Math.max(changes.length - collapsedFileCount, 0); + +export const getVisibleChanges = ({ + changes, + collapsedFileCount = CHANGES_SUMMARY_COLLAPSED_FILE_COUNT, + isExpanded, +}: { + changes: ReadonlyArray; + collapsedFileCount?: number; + isExpanded: boolean; +}): ReadonlyArray => + isExpanded || getHiddenChangesCount({ changes, collapsedFileCount }) === 0 + ? changes + : changes.slice(0, collapsedFileCount); + +export const getChangesSummaryRowDelay = (index: number): string => + `${CHANGES_SUMMARY_ROW_BASE_DELAY_MS + index * CHANGES_SUMMARY_ROW_STAGGER_MS}ms`; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.test.ts new file mode 100644 index 0000000000..d1eb87902b --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.test.ts @@ -0,0 +1,56 @@ +import { + getConversationScrollBehavior, + scrollConversationElementToBottom, +} from './conversation-scroll'; + +describe('conversation-scroll', () => { + it('uses auto scrolling when reduced motion is preferred', () => { + expect(getConversationScrollBehavior(true)).toBe('auto'); + expect(getConversationScrollBehavior(false)).toBe('smooth'); + }); + + it('scrolls to the bottom with native scrollTo when available', () => { + const element = { + scrollHeight: 240, + scrollTo: jest.fn(), + scrollTop: 0, + }; + + scrollConversationElementToBottom({ + element, + prefersReducedMotion: false, + }); + + expect(element.scrollTo).toHaveBeenCalledWith({ + behavior: 'smooth', + top: 240, + }); + expect(element.scrollTop).toBe(0); + }); + + it('falls back to scrollTop when scrollTo is missing or unsupported', () => { + const withoutScrollTo = { + scrollHeight: 320, + scrollTop: 0, + }; + const throwingScrollTo = { + scrollHeight: 480, + scrollTo: jest.fn(() => { + throw new Error('unsupported scroll options'); + }), + scrollTop: 0, + }; + + scrollConversationElementToBottom({ + element: withoutScrollTo, + prefersReducedMotion: false, + }); + scrollConversationElementToBottom({ + element: throwingScrollTo, + prefersReducedMotion: false, + }); + + expect(withoutScrollTo.scrollTop).toBe(320); + expect(throwingScrollTo.scrollTop).toBe(480); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.ts new file mode 100644 index 0000000000..d5fd206be2 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.ts @@ -0,0 +1,33 @@ +type ScrollableConversationElement = { + scrollHeight: number; + scrollTop: number; + scrollTo?: (options: ScrollToOptions) => void; +}; + +export const getConversationScrollBehavior = ( + prefersReducedMotion: boolean, +): ScrollBehavior => (prefersReducedMotion ? 'auto' : 'smooth'); + +export const scrollConversationElementToBottom = ({ + element, + prefersReducedMotion, +}: { + element: ScrollableConversationElement; + prefersReducedMotion: boolean; +}) => { + const top = element.scrollHeight; + + if (typeof element.scrollTo !== 'function') { + element.scrollTop = top; + return; + } + + try { + element.scrollTo({ + behavior: getConversationScrollBehavior(prefersReducedMotion), + top, + }); + } catch { + element.scrollTop = top; + } +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/get-streaming-segment-length.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/get-streaming-segment-length.ts new file mode 100644 index 0000000000..27a18833cc --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/get-streaming-segment-length.ts @@ -0,0 +1,4 @@ +import type { StreamingSegment } from './streaming-text-types'; + +export const getStreamingSegmentLength = (segment: StreamingSegment): number => + segment.kind === 'text' ? segment.value.length : (segment.length ?? 1); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.test.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.test.tsx new file mode 100644 index 0000000000..62ef1b2456 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.test.tsx @@ -0,0 +1,39 @@ +import { isValidElement } from 'react'; + +import { renderStreamingSegments } from './render-streaming-segments'; +import type { StreamingSegment } from './streaming-text-types'; + +const getRenderedText = (segment: unknown): string => { + if (!isValidElement<{ children: string }>(segment)) { + throw new Error('Expected a rendered React element.'); + } + + return segment.props.children; +}; + +describe('renderStreamingSegments', () => { + it('partially reveals text segments by character count', () => { + const rendered = renderStreamingSegments( + [{ kind: 'text', value: 'Launch' }], + 3, + ); + + expect(rendered.map(getRenderedText)).toEqual(['Lau']); + }); + + it('waits until a node segment reaches its configured reveal length', () => { + const segments: StreamingSegment[] = [ + { kind: 'text', value: 'A' }, + { kind: 'node', value: 'rocket', length: 3 }, + { kind: 'text', value: 'Z' }, + ]; + + expect(renderStreamingSegments(segments, 3).map(getRenderedText)).toEqual([ + 'A', + ]); + expect(renderStreamingSegments(segments, 4).map(getRenderedText)).toEqual([ + 'A', + 'rocket', + ]); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.tsx new file mode 100644 index 0000000000..094ac357f4 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react'; + +import { getStreamingSegmentLength } from './get-streaming-segment-length'; +import type { StreamingSegment } from './streaming-text-types'; + +export const renderStreamingSegments = ( + segments: ReadonlyArray, + revealed: number, +): ReactNode[] => { + const rendered: ReactNode[] = []; + let remaining = revealed; + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + + if (remaining <= 0) { + break; + } + + if (segment.kind === 'text') { + const take = Math.min(segment.value.length, remaining); + rendered.push( + {segment.value.slice(0, take)}, + ); + remaining -= take; + continue; + } + + const cost = getStreamingSegmentLength(segment); + + if (remaining < cost) { + break; + } + + rendered.push({segment.value}); + remaining -= cost; + } + + return rendered; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/rocketChangeset.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/rocketChangeset.ts similarity index 100% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/rocketChangeset.ts rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/rocketChangeset.ts diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/streaming-text-types.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/streaming-text-types.ts new file mode 100644 index 0000000000..90a75e8eb0 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/streaming-text-types.ts @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; + +export type StreamingSegment = + | { kind: 'text'; value: string; onReveal?: () => void } + | { + kind: 'node'; + value: ReactNode; + length?: number; + onReveal?: () => void; + }; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-assistant-response-stage.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-assistant-response-stage.ts new file mode 100644 index 0000000000..41b811d3c7 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-assistant-response-stage.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useTimeoutRegistry } from '@/lib/react'; + +import { CHAT_TIMINGS } from './animationTiming'; +import { + ASSISTANT_RESPONSE_STAGE_DELAYS, + getAssistantResponseStageTransition, + hasAssistantResponseStageReached, + type AssistantResponseStage, +} from './assistant-response-stage'; + +type UseAssistantResponseStageOptions = { + instantComplete: boolean; + onChatFinished?: () => void; +}; + +export const useAssistantResponseStage = ({ + instantComplete, + onChatFinished, +}: UseAssistantResponseStageOptions) => { + const timeoutRegistry = useTimeoutRegistry(); + const [stage, setStage] = useState( + instantComplete ? 'done' : 'thinking', + ); + const hasNotifiedChatFinishedRef = useRef(false); + + useEffect(() => { + if (instantComplete) { + return undefined; + } + + return timeoutRegistry.schedule( + () => setStage('rocket'), + CHAT_TIMINGS.thinkingMs, + ); + }, [instantComplete, timeoutRegistry]); + + useEffect(() => { + if (!instantComplete) { + return; + } + + setStage('done'); + }, [instantComplete]); + + useEffect(() => { + if ( + (stage !== 'card' && stage !== 'done') || + hasNotifiedChatFinishedRef.current + ) { + return undefined; + } + + hasNotifiedChatFinishedRef.current = true; + + return timeoutRegistry.schedule( + () => { + onChatFinished?.(); + }, + stage === 'done' ? 0 : ASSISTANT_RESPONSE_STAGE_DELAYS.afterCardRevealMs, + ); + }, [onChatFinished, stage, timeoutRegistry]); + + const createStageCompletionHandler = useCallback( + (completedStage: AssistantResponseStage) => () => { + const transition = getAssistantResponseStageTransition(completedStage); + + if (transition === null) { + return; + } + + timeoutRegistry.schedule(() => { + setStage(transition.nextStage); + }, transition.delayMs); + }, + [timeoutRegistry], + ); + + const hasReachedStage = useCallback( + (targetStage: AssistantResponseStage) => + hasAssistantResponseStageReached({ + currentStage: stage, + targetStage, + }), + [stage], + ); + + return { + createStageCompletionHandler, + hasReachedStage, + stage, + }; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-conversation-auto-scroll.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-conversation-auto-scroll.ts new file mode 100644 index 0000000000..5ecc9c928f --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-conversation-auto-scroll.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; + +import { createAnimationFrameLoop } from '@/lib/animation'; +import { getPrefersReducedMotionSnapshot } from '@/lib/motion'; + +import { scrollConversationElementToBottom } from './conversation-scroll'; + +const getSafePrefersReducedMotionSnapshot = (): boolean => + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? getPrefersReducedMotionSnapshot() + : true; + +export const useConversationAutoScroll = () => { + const scrollRef = useRef(null); + + useEffect(() => { + const element = scrollRef.current; + + if (!element) { + return undefined; + } + + const scrollTask = createAnimationFrameLoop({ + onFrame: () => { + scrollConversationElementToBottom({ + element, + prefersReducedMotion: getSafePrefersReducedMotionSnapshot(), + }); + + return false; + }, + }); + const scheduleScroll = scrollTask.start; + + scheduleScroll(); + + if (typeof MutationObserver === 'undefined') { + return () => { + scrollTask.stop(); + }; + } + + const observer = new MutationObserver(scheduleScroll); + + observer.observe(element, { + characterData: true, + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + scrollTask.stop(); + }; + }, []); + + return scrollRef; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-streaming-text-progress.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-streaming-text-progress.ts new file mode 100644 index 0000000000..b29c60e36d --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-streaming-text-progress.ts @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react'; + +import { useLatestRef, useTimeoutRegistry } from '@/lib/react'; + +import { getStreamingSegmentLength } from './get-streaming-segment-length'; +import type { StreamingSegment } from './streaming-text-types'; + +type UseStreamingTextProgressOptions = { + charDurationMs: number; + instant: boolean; + onComplete?: () => void; + segments: ReadonlyArray; +}; + +type UseStreamingTextProgressResult = { + isComplete: boolean; + revealed: number; + totalLength: number; +}; + +const getTotalLength = (segments: ReadonlyArray): number => + segments.reduce( + (total, segment) => total + getStreamingSegmentLength(segment), + 0, + ); + +export const useStreamingTextProgress = ({ + charDurationMs, + instant, + onComplete, + segments, +}: UseStreamingTextProgressOptions): UseStreamingTextProgressResult => { + const totalLength = getTotalLength(segments); + const [revealed, setRevealed] = useState(0); + const timeoutRegistry = useTimeoutRegistry(); + const onCompleteRef = useLatestRef(onComplete); + const completedRef = useRef(false); + const firedSegmentCountRef = useRef(0); + + useEffect(() => { + setRevealed(0); + completedRef.current = false; + firedSegmentCountRef.current = 0; + }, [segments]); + + useEffect(() => { + if (!instant) { + return; + } + setRevealed(totalLength); + }, [instant, totalLength]); + + useEffect(() => { + if (revealed >= totalLength) { + if (!completedRef.current) { + completedRef.current = true; + onCompleteRef.current?.(); + } + return undefined; + } + + if (instant) { + return undefined; + } + + return timeoutRegistry.schedule(() => { + setRevealed((previous) => Math.min(previous + 1, totalLength)); + }, charDurationMs); + }, [ + charDurationMs, + instant, + onCompleteRef, + revealed, + timeoutRegistry, + totalLength, + ]); + + useEffect(() => { + let offset = 0; + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + + offset += getStreamingSegmentLength(segment); + + if (revealed < offset) { + break; + } + + if (index < firedSegmentCountRef.current) { + continue; + } + + segment.onReveal?.(); + firedSegmentCountRef.current = index + 1; + } + }, [revealed, segments]); + + return { + isComplete: revealed >= totalLength, + revealed, + totalLength, + }; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx index 25287fd2aa..931e063c15 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx @@ -1,101 +1,17 @@ 'use client'; import { styled } from '@linaria/react'; -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, - type PointerEvent as ReactPointerEvent, -} from 'react'; +import { useCallback } from 'react'; import { theme } from '@/theme'; -import { useWindowOrder } from '../WindowOrder/WindowOrderProvider'; -import { WINDOW_SHADOWS } from '../windowShadows'; -import { - ConversationPanel, - type ConversationMessage, -} from './conversation/ConversationPanel'; -import { TerminalDiff } from './TerminalDiff/TerminalDiff'; +import { WINDOW_SHADOWS } from '../Shared/windowShadows'; import { EDITOR_TOKENS } from './TerminalEditor/editorTokens'; -import { TerminalEditor } from './TerminalEditor/TerminalEditor'; -import { TerminalPromptBox } from './TerminalPromptBox'; +import { TerminalContent } from './TerminalContent'; +import { TerminalResizeHandles } from './TerminalResizeHandles'; import { TerminalTopBar } from './TerminalTopBar'; -import { type TerminalToggleValue } from './TerminalToggle'; +import type { TerminalToggleValue } from './TerminalToggle'; import { TERMINAL_TOKENS } from './terminalTokens'; - -const WINDOW_ID = 'terminal-window'; -const INITIAL_PROMPT_TEXT = - 'Scaffold a launch-ops CRM in my workspace with rockets, launches, payloads, customers, and launch sites, with relevant actions for each.'; -const CLEARED_PROMPT_TEXT = 'Ask anything…'; - -const TERMINAL_INITIAL_WIDTH = 380; -const TERMINAL_INITIAL_HEIGHT = 220; -const TERMINAL_CHAT_EXPANDED_HEIGHT = 480; -const TERMINAL_EDITOR_WIDTH = 720; -const TERMINAL_EDITOR_HEIGHT = 480; -const TERMINAL_MIN_WIDTH = 300; -const TERMINAL_MIN_HEIGHT = 200; -const TERMINAL_INITIAL_BOTTOM_OFFSET = 96; -const MIN_EDGE_GAP = 0; -const MOBILE_PARENT_BREAKPOINT = 640; -const MOBILE_OFFSET_X = 16; -const MOBILE_OFFSET_Y = 48; - -type ResizeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; - -type ResizeEdge = 'top' | 'right' | 'bottom' | 'left'; - -type ResizeHandle = ResizeCorner | ResizeEdge; - -type DragState = { - pointerId: number; - originX: number; - originY: number; - startLeft: number; - startTop: number; -}; - -type ResizeState = { - pointerId: number; - originX: number; - originY: number; - startWidth: number; - startHeight: number; - startLeft: number; - startTop: number; - handle: ResizeHandle; -}; - -type TerminalPosition = { left: number; top: number }; -type TerminalSize = { width: number; height: number }; - -const HORIZONTAL_HANDLES: ReadonlySet = new Set([ - 'top-left', - 'top-right', - 'bottom-left', - 'bottom-right', - 'left', - 'right', -]); -const VERTICAL_HANDLES: ReadonlySet = new Set([ - 'top-left', - 'top-right', - 'bottom-left', - 'bottom-right', - 'top', - 'bottom', -]); -const LEFT_HANDLES: ReadonlySet = new Set([ - 'top-left', - 'bottom-left', - 'left', -]); -const TOP_HANDLES: ReadonlySet = new Set([ - 'top-left', - 'top-right', - 'top', -]); +import { useTerminalConversationWorkflow } from './use-terminal-conversation-workflow'; +import { useTerminalWindowLayout } from './use-terminal-window-layout'; const Shell = styled.div<{ $isDragging: boolean; @@ -152,156 +68,6 @@ const Shell = styled.div<{ } `; -const Body = styled.div` - display: flex; - flex: 1 1 auto; - flex-direction: column; - justify-content: flex-end; - min-height: 0; - position: relative; - width: 100%; -`; - -const ViewLayer = styled.div<{ $visible: boolean; $row?: boolean }>` - display: flex; - flex-direction: ${({ $row }) => ($row ? 'row' : 'column')}; - inset: 0; - justify-content: ${({ $row }) => ($row ? 'flex-start' : 'flex-end')}; - opacity: ${({ $visible }) => ($visible ? 1 : 0)}; - pointer-events: ${({ $visible }) => ($visible ? 'auto' : 'none')}; - position: absolute; - transition: opacity 220ms ease; -`; - -const ChatColumn = styled.div` - display: flex; - flex: 1 1 auto; - flex-direction: column; - justify-content: flex-end; - min-height: 0; - min-width: 0; -`; - -const DiffSlide = styled.div<{ $open: boolean }>` - display: flex; - flex: 0 0 ${({ $open }) => ($open ? '55%' : '0')}; - flex-direction: column; - min-height: 0; - overflow: hidden; - transition: flex-basis 320ms cubic-bezier(0.22, 1, 0.36, 1); - width: ${({ $open }) => ($open ? '55%' : '0')}; -`; - -const ResizeCornerBase = styled.div` - height: 16px; - position: absolute; - width: 16px; - z-index: 5; - - &::after { - border-radius: 1px; - content: ''; - height: 8px; - opacity: 0; - position: absolute; - transition: opacity 0.18s ease; - width: 8px; - } - - &:hover::after { - opacity: 1; - } -`; - -const ResizeCornerTopLeft = styled(ResizeCornerBase)` - cursor: nwse-resize; - left: -4px; - top: -4px; - - &::after { - border-left: 2px solid rgba(0, 0, 0, 0.18); - border-top: 2px solid rgba(0, 0, 0, 0.18); - left: 6px; - top: 6px; - } -`; - -const ResizeCornerTopRight = styled(ResizeCornerBase)` - cursor: nesw-resize; - right: -4px; - top: -4px; - - &::after { - border-right: 2px solid rgba(0, 0, 0, 0.18); - border-top: 2px solid rgba(0, 0, 0, 0.18); - right: 6px; - top: 6px; - } -`; - -const ResizeCornerBottomLeft = styled(ResizeCornerBase)` - bottom: -4px; - cursor: nesw-resize; - left: -4px; - - &::after { - border-bottom: 2px solid rgba(0, 0, 0, 0.18); - border-left: 2px solid rgba(0, 0, 0, 0.18); - bottom: 6px; - left: 6px; - } -`; - -const ResizeCornerBottomRight = styled(ResizeCornerBase)` - bottom: -4px; - cursor: nwse-resize; - right: -4px; - - &::after { - border-bottom: 2px solid rgba(0, 0, 0, 0.18); - border-right: 2px solid rgba(0, 0, 0, 0.18); - bottom: 6px; - right: 6px; - } -`; - -const ResizeEdgeBase = styled.div` - position: absolute; - z-index: 4; -`; - -const ResizeEdgeTop = styled(ResizeEdgeBase)` - cursor: ns-resize; - height: 6px; - left: 12px; - right: 12px; - top: -3px; -`; - -const ResizeEdgeBottom = styled(ResizeEdgeBase)` - bottom: -3px; - cursor: ns-resize; - height: 6px; - left: 12px; - right: 12px; -`; - -const ResizeEdgeLeft = styled(ResizeEdgeBase)` - bottom: 12px; - cursor: ew-resize; - left: -3px; - top: 12px; - width: 6px; -`; - -const ResizeEdgeRight = styled(ResizeEdgeBase)` - bottom: 12px; - cursor: ew-resize; - right: -3px; - top: 12px; - width: 6px; -`; - type DraggableTerminalProps = { onObjectCreated?: (id: string) => void; onChatFinished?: () => void; @@ -315,471 +81,76 @@ export const DraggableTerminal = ({ onChatReset, onJumpToConversationEnd, }: DraggableTerminalProps) => { - const shellRef = useRef(null); - const dragStateRef = useRef(null); - const resizeStateRef = useRef(null); - const hasAnnouncedChatFinishedRef = useRef(false); - - const [position, setPosition] = useState(null); - const [size, setSize] = useState({ - width: TERMINAL_INITIAL_WIDTH, - height: TERMINAL_INITIAL_HEIGHT, + const { + changeView, + finishChat, + hasStartedConversation, + instantComplete, + isChatFinished, + isDiffOpen, + jumpToConversationEnd, + messages, + resetConversation, + sendPrompt, + toggleDiff, + view, + } = useTerminalConversationWorkflow({ + onChatFinished, + onChatReset, + onJumpToConversationEnd, }); - const [isDragging, setIsDragging] = useState(false); - const [isResizing, setIsResizing] = useState(false); - const [messages, setMessages] = useState([]); - const [view, setView] = useState('ai-chat'); - const [isChatFinished, setIsChatFinished] = useState(false); - const [isDiffOpen, setIsDiffOpen] = useState(false); - const [animationsEnabled, setAnimationsEnabled] = useState(false); - const [instantComplete, setInstantComplete] = useState(false); - - useEffect(() => { - const timeoutId = window.setTimeout(() => { - setAnimationsEnabled(true); - }, 150); - return () => window.clearTimeout(timeoutId); - }, []); - - const { activate, zIndex } = useWindowOrder(WINDOW_ID); - - const hasStartedConversation = messages.length > 0; - - const resizeAnchored = useCallback( - (targetWidth: number, targetHeight: number) => { - if (size.height === targetHeight && size.width === targetWidth) { - return; - } - const deltaX = size.width - targetWidth; - const deltaY = size.height - targetHeight; - const parentRect = - shellRef.current?.parentElement?.getBoundingClientRect() ?? null; - setSize({ width: targetWidth, height: targetHeight }); - setPosition((pos) => { - if (!pos) { - return pos; - } - const parentWidth = parentRect?.width ?? size.width; - const parentHeight = parentRect?.height ?? size.height; - const centerX = pos.left + size.width / 2; - const centerY = pos.top + size.height / 2; - const anchorRight = centerX > parentWidth / 2; - const anchorBottom = centerY > parentHeight / 2; - return { - left: anchorRight ? pos.left + deltaX : pos.left, - top: anchorBottom ? pos.top + deltaY : pos.top, - }; - }); - }, - [size], - ); - - const getTargetDimensions = useCallback( - (nextView: TerminalToggleValue, chatStarted: boolean) => { - const parentRect = - shellRef.current?.parentElement?.getBoundingClientRect() ?? null; - const isMobileParent = - parentRect !== null && parentRect.width < MOBILE_PARENT_BREAKPOINT; - const maxWidth = parentRect - ? parentRect.width - (isMobileParent ? MOBILE_OFFSET_X : 0) - : Infinity; - const maxHeight = parentRect - ? parentRect.height - (isMobileParent ? MOBILE_OFFSET_Y : 0) - : Infinity; - if (nextView === 'editor') { - return { - width: Math.min(TERMINAL_EDITOR_WIDTH, maxWidth), - height: Math.min(TERMINAL_EDITOR_HEIGHT, maxHeight), - }; - } - return { - width: Math.min(TERMINAL_INITIAL_WIDTH, maxWidth), - height: Math.min( - chatStarted ? TERMINAL_CHAT_EXPANDED_HEIGHT : TERMINAL_INITIAL_HEIGHT, - maxHeight, - ), - }; - }, - [], - ); + const { + activate, + animationsEnabled, + handleDragStart, + isDragging, + isReady, + isResizing, + resizeToTerminalTarget, + shellRef, + startResize, + windowStyle, + } = useTerminalWindowLayout(); const handleSendPrompt = useCallback(() => { - if (hasStartedConversation) { + if (!sendPrompt()) { return; } - setInstantComplete(false); - const sendAt = Date.now(); - setMessages([ - { id: `u-${sendAt}`, role: 'user', text: INITIAL_PROMPT_TEXT }, - { id: `a-${sendAt}`, role: 'assistant' }, - ]); + if (view === 'ai-chat') { - const { width, height } = getTargetDimensions('ai-chat', true); - resizeAnchored(width, height); + resizeToTerminalTarget({ + chatStarted: true, + view: 'ai-chat', + }); } - }, [getTargetDimensions, hasStartedConversation, resizeAnchored, view]); + }, [resizeToTerminalTarget, sendPrompt, view]); const handleViewChange = useCallback( (next: TerminalToggleValue) => { - setView(next); - const { width, height } = getTargetDimensions( - next, - hasStartedConversation, - ); - resizeAnchored(width, height); + changeView(next); + resizeToTerminalTarget({ + chatStarted: hasStartedConversation, + view: next, + }); }, - [getTargetDimensions, hasStartedConversation, resizeAnchored], + [changeView, hasStartedConversation, resizeToTerminalTarget], ); const handleResetConversation = useCallback(() => { - hasAnnouncedChatFinishedRef.current = false; - setMessages([]); - setIsChatFinished(false); - setIsDiffOpen(false); - setInstantComplete(false); - setView('ai-chat'); - const { width, height } = getTargetDimensions('ai-chat', false); - resizeAnchored(width, height); - onChatReset?.(); - }, [getTargetDimensions, onChatReset, resizeAnchored]); - - const handleToggleDiff = useCallback(() => { - setIsDiffOpen((current) => !current); - }, []); - - const handleChatFinishedInternal = useCallback(() => { - setIsChatFinished(true); - if (hasAnnouncedChatFinishedRef.current) { - return; - } - hasAnnouncedChatFinishedRef.current = true; - onChatFinished?.(); - }, [onChatFinished]); + resetConversation(); + resizeToTerminalTarget({ + chatStarted: false, + view: 'ai-chat', + }); + }, [resetConversation, resizeToTerminalTarget]); const handleJumpToConversationEnd = useCallback(() => { - if (!hasStartedConversation) { - const sendAt = Date.now(); - setMessages([ - { id: `u-${sendAt}`, role: 'user', text: INITIAL_PROMPT_TEXT }, - { id: `a-${sendAt}`, role: 'assistant' }, - ]); - } - setInstantComplete(true); - setIsDiffOpen(false); - setView('ai-chat'); - const { width, height } = getTargetDimensions('ai-chat', true); - resizeAnchored(width, height); - onJumpToConversationEnd?.(); - handleChatFinishedInternal(); - }, [ - getTargetDimensions, - handleChatFinishedInternal, - hasStartedConversation, - onJumpToConversationEnd, - resizeAnchored, - ]); - - useLayoutEffect(() => { - const shell = shellRef.current; - const parent = shell?.parentElement as HTMLElement | null; - if (!shell || !parent) { - return; - } - - const parentRect = parent.getBoundingClientRect(); - - if (parentRect.width < MOBILE_PARENT_BREAKPOINT) { - const mobileWidth = Math.min( - TERMINAL_INITIAL_WIDTH, - parentRect.width - MOBILE_OFFSET_X, - ); - const mobileHeight = Math.min( - TERMINAL_INITIAL_HEIGHT, - parentRect.height - MOBILE_OFFSET_Y, - ); - setSize({ width: mobileWidth, height: mobileHeight }); - setPosition({ - left: MOBILE_OFFSET_X, - top: MOBILE_OFFSET_Y, - }); - return; - } - - const initialWidth = Math.min(TERMINAL_INITIAL_WIDTH, parentRect.width); - const initialHeight = Math.min(TERMINAL_INITIAL_HEIGHT, parentRect.height); - - setSize({ width: initialWidth, height: initialHeight }); - setPosition({ - left: Math.max(0, parentRect.width - initialWidth), - top: Math.max( - 0, - parentRect.height - initialHeight - TERMINAL_INITIAL_BOTTOM_OFFSET, - ), + jumpToConversationEnd(); + resizeToTerminalTarget({ + chatStarted: true, + view: 'ai-chat', }); - }, []); - - const getParentRect = useCallback(() => { - const shell = shellRef.current; - const parent = shell?.parentElement as HTMLElement | null; - return parent?.getBoundingClientRect() ?? null; - }, []); - - const clampPosition = useCallback( - ( - candidateLeft: number, - candidateTop: number, - currentSize: TerminalSize, - ) => { - const parentRect = getParentRect(); - if (!parentRect) { - return { left: candidateLeft, top: candidateTop }; - } - - const maxLeft = parentRect.width - currentSize.width - MIN_EDGE_GAP; - const maxTop = parentRect.height - currentSize.height - MIN_EDGE_GAP; - - return { - left: Math.min(Math.max(candidateLeft, MIN_EDGE_GAP), maxLeft), - top: Math.min(Math.max(candidateTop, MIN_EDGE_GAP), maxTop), - }; - }, - [getParentRect], - ); - - const handleDragStart = useCallback( - (event: ReactPointerEvent) => { - if (event.pointerType === 'mouse' && event.button !== 0) { - return; - } - - const target = event.target as HTMLElement | null; - if (target && target.closest('button')) { - return; - } - - event.preventDefault(); - activate(); - - const shell = shellRef.current; - if (!shell || !position) { - return; - } - - shell.setPointerCapture?.(event.pointerId); - dragStateRef.current = { - pointerId: event.pointerId, - originX: event.clientX, - originY: event.clientY, - startLeft: position.left, - startTop: position.top, - }; - setIsDragging(true); - }, - [activate, position], - ); - - const latestPositionRef = useRef(position); - const latestSizeRef = useRef(size); - useEffect(() => { - latestPositionRef.current = position; - }, [position]); - useEffect(() => { - latestSizeRef.current = size; - }, [size]); - - useEffect(() => { - if (!isDragging) { - return undefined; - } - - const handleMove = (event: PointerEvent) => { - const state = dragStateRef.current; - if (!state || state.pointerId !== event.pointerId) { - return; - } - - const nextLeft = state.startLeft + (event.clientX - state.originX); - const nextTop = state.startTop + (event.clientY - state.originY); - const clamped = clampPosition(nextLeft, nextTop, size); - latestPositionRef.current = clamped; - const shell = shellRef.current; - if (shell !== null) { - shell.style.transform = `translate3d(${clamped.left}px, ${clamped.top}px, 0)`; - } - }; - - const stopDragging = (event: PointerEvent) => { - const state = dragStateRef.current; - if (!state || state.pointerId !== event.pointerId) { - return; - } - dragStateRef.current = null; - setIsDragging(false); - const committed = latestPositionRef.current; - if (committed !== null) { - setPosition(committed); - } - shellRef.current?.releasePointerCapture?.(event.pointerId); - }; - - window.addEventListener('pointermove', handleMove); - window.addEventListener('pointerup', stopDragging); - window.addEventListener('pointercancel', stopDragging); - - return () => { - window.removeEventListener('pointermove', handleMove); - window.removeEventListener('pointerup', stopDragging); - window.removeEventListener('pointercancel', stopDragging); - }; - }, [clampPosition, isDragging, size]); - - const startResize = useCallback( - (handle: ResizeHandle) => (event: ReactPointerEvent) => { - if (event.pointerType === 'mouse' && event.button !== 0) { - return; - } - if (!position) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - activate(); - - const shell = shellRef.current; - if (!shell) { - return; - } - - shell.setPointerCapture?.(event.pointerId); - resizeStateRef.current = { - pointerId: event.pointerId, - originX: event.clientX, - originY: event.clientY, - startWidth: size.width, - startHeight: size.height, - startLeft: position.left, - startTop: position.top, - handle, - }; - setIsResizing(true); - }, - [activate, position, size], - ); - - useEffect(() => { - if (!isResizing) { - return undefined; - } - - const handleMove = (event: PointerEvent) => { - const state = resizeStateRef.current; - if (!state || state.pointerId !== event.pointerId) { - return; - } - - const parentRect = getParentRect(); - if (!parentRect) { - return; - } - - const deltaX = event.clientX - state.originX; - const deltaY = event.clientY - state.originY; - - const affectsWidth = HORIZONTAL_HANDLES.has(state.handle); - const affectsHeight = VERTICAL_HANDLES.has(state.handle); - const growsFromLeft = LEFT_HANDLES.has(state.handle); - const growsFromTop = TOP_HANDLES.has(state.handle); - - const effectiveMinWidth = Math.min( - TERMINAL_MIN_WIDTH, - Math.max(parentRect.width - MIN_EDGE_GAP * 2, 0), - ); - const effectiveMinHeight = Math.min( - TERMINAL_MIN_HEIGHT, - Math.max(parentRect.height - MIN_EDGE_GAP * 2, 0), - ); - - let nextWidth = state.startWidth; - let nextLeft = state.startLeft; - if (affectsWidth) { - if (growsFromLeft) { - const maxWidth = state.startWidth + state.startLeft - MIN_EDGE_GAP; - nextWidth = Math.min( - Math.max(state.startWidth - deltaX, effectiveMinWidth), - Math.max(maxWidth, effectiveMinWidth), - ); - nextLeft = state.startLeft + state.startWidth - nextWidth; - } else { - const maxWidth = parentRect.width - state.startLeft - MIN_EDGE_GAP; - nextWidth = Math.min( - Math.max(state.startWidth + deltaX, effectiveMinWidth), - Math.max(maxWidth, effectiveMinWidth), - ); - } - } - - let nextHeight = state.startHeight; - let nextTop = state.startTop; - if (affectsHeight) { - if (growsFromTop) { - const maxHeight = state.startHeight + state.startTop - MIN_EDGE_GAP; - nextHeight = Math.min( - Math.max(state.startHeight - deltaY, effectiveMinHeight), - Math.max(maxHeight, effectiveMinHeight), - ); - nextTop = state.startTop + state.startHeight - nextHeight; - } else { - const maxHeight = parentRect.height - state.startTop - MIN_EDGE_GAP; - nextHeight = Math.min( - Math.max(state.startHeight + deltaY, effectiveMinHeight), - Math.max(maxHeight, effectiveMinHeight), - ); - } - } - - latestSizeRef.current = { width: nextWidth, height: nextHeight }; - latestPositionRef.current = { left: nextLeft, top: nextTop }; - const shell = shellRef.current; - if (shell !== null) { - shell.style.width = `${nextWidth}px`; - shell.style.height = `${nextHeight}px`; - shell.style.transform = `translate3d(${nextLeft}px, ${nextTop}px, 0)`; - } - }; - - const stopResizing = (event: PointerEvent) => { - const state = resizeStateRef.current; - if (!state || state.pointerId !== event.pointerId) { - return; - } - resizeStateRef.current = null; - setIsResizing(false); - const committedSize = latestSizeRef.current; - const committedPosition = latestPositionRef.current; - setSize(committedSize); - if (committedPosition !== null) { - setPosition(committedPosition); - } - shellRef.current?.releasePointerCapture?.(event.pointerId); - }; - - window.addEventListener('pointermove', handleMove); - window.addEventListener('pointerup', stopResizing); - window.addEventListener('pointercancel', stopResizing); - - return () => { - window.removeEventListener('pointermove', handleMove); - window.removeEventListener('pointerup', stopResizing); - window.removeEventListener('pointercancel', stopResizing); - }; - }, [getParentRect, isResizing]); - - const isInteracting = isDragging || isResizing; - const renderPosition = isInteracting - ? (latestPositionRef.current ?? position) - : position; - const renderSize = isInteracting ? latestSizeRef.current : size; + }, [jumpToConversationEnd, resizeToTerminalTarget]); return ( - - - - - - - - + - - - - {hasStartedConversation ? ( - - ) : null} - - - - - - - - - - + ); }; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalContent.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalContent.tsx new file mode 100644 index 0000000000..0b059e0949 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalContent.tsx @@ -0,0 +1,111 @@ +import { styled } from '@linaria/react'; +import { + ConversationPanel, + type ConversationMessage, +} from './Conversation/ConversationPanel'; +import { TerminalDiff } from './TerminalDiff/TerminalDiff'; +import { TerminalEditor } from './TerminalEditor/TerminalEditor'; +import { TerminalPromptBox } from './TerminalPrompt/TerminalPromptBox'; +import type { TerminalToggleValue } from './TerminalToggle'; +import { + CLEARED_PROMPT_TEXT, + INITIAL_PROMPT_TEXT, +} from './terminal-conversation-copy'; + +const Body = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + justify-content: flex-end; + min-height: 0; + position: relative; + width: 100%; +`; + +const ViewLayer = styled.div<{ $visible: boolean; $row?: boolean }>` + display: flex; + flex-direction: ${({ $row }) => ($row ? 'row' : 'column')}; + inset: 0; + justify-content: ${({ $row }) => ($row ? 'flex-start' : 'flex-end')}; + opacity: ${({ $visible }) => ($visible ? 1 : 0)}; + pointer-events: ${({ $visible }) => ($visible ? 'auto' : 'none')}; + position: absolute; + transition: opacity 220ms ease; +`; + +const ChatColumn = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + justify-content: flex-end; + min-height: 0; + min-width: 0; +`; + +const DiffSlide = styled.div<{ $open: boolean }>` + display: flex; + flex: 0 0 ${({ $open }) => ($open ? '55%' : '0')}; + flex-direction: column; + min-height: 0; + overflow: hidden; + transition: flex-basis 320ms cubic-bezier(0.22, 1, 0.36, 1); + width: ${({ $open }) => ($open ? '55%' : '0')}; +`; + +type TerminalContentProps = { + hasStartedConversation: boolean; + instantComplete: boolean; + isChatFinished: boolean; + isDiffOpen: boolean; + messages: ConversationMessage[]; + onChatFinished: () => void; + onObjectCreated?: (id: string) => void; + onResetConversation: () => void; + onSendPrompt: () => void; + view: TerminalToggleValue; +}; + +export const TerminalContent = ({ + hasStartedConversation, + instantComplete, + isChatFinished, + isDiffOpen, + messages, + onChatFinished, + onObjectCreated, + onResetConversation, + onSendPrompt, + view, +}: TerminalContentProps) => ( + + + + {hasStartedConversation ? ( + + ) : null} + + + + + + + + + + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diffData.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diffData.ts index fce4c8dd11..d59bfa88e5 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diffData.ts +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diffData.ts @@ -1,4 +1,4 @@ -import { CHANGESET_TOTALS } from '../conversation/rocketChangeset'; +import { CHANGESET_TOTALS } from '../Conversation/rocketChangeset'; export type DiffTokenKind = | 'text' diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx index 02e19e0e0f..2b0d48ae7d 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx @@ -1,24 +1,26 @@ 'use client'; -import { IconX } from '@tabler/icons-react'; import { styled } from '@linaria/react'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { TERMINAL_TOKENS } from '../terminalTokens'; import { - DEFAULT_EDITOR_FILE_ID, EDITOR_FILES, EXPLORER_NODES, findFileById, - GENERATED_FILE_IDS, - STARTER_EDITOR_FILE_ID, tokenizeSource, - type CodeToken, type EditorFile, - type ExplorerNode, - type FileIconKind, - type TokenKind, } from './editorData'; import { EDITOR_TOKENS } from './editorTokens'; +import { TerminalEditorCodeView } from './TerminalEditorCodeView'; +import { TerminalEditorExplorer } from './TerminalEditorExplorer'; +import { TerminalEditorTabs } from './TerminalEditorTabs'; +import { + closeTerminalEditorFile, + getInitialTerminalEditorState, + getTerminalEditorFallbackFileId, + getVisibleTerminalEditorExplorerNodes, + selectTerminalEditorFile, + syncTerminalEditorStateWithGeneratedFiles, +} from './terminal-editor-state'; const Root = styled.div` background: ${EDITOR_TOKENS.surface.body}; @@ -28,127 +30,6 @@ const Root = styled.div` width: 100%; `; -const Sidebar = styled.div` - background: ${EDITOR_TOKENS.surface.sidebar}; - border-right: 1px solid ${EDITOR_TOKENS.surface.sidebarBorder}; - display: flex; - flex: 0 0 206px; - flex-direction: column; - min-height: 0; - overflow: hidden; - padding-bottom: 12px; - width: 206px; -`; - -const ExplorerHeader = styled.div` - align-items: center; - border-bottom: 1px solid ${EDITOR_TOKENS.surface.explorerHeaderBorder}; - color: ${EDITOR_TOKENS.text.explorerLabel}; - display: flex; - flex: 0 0 36px; - font-family: ${TERMINAL_TOKENS.font.ui}; - font-size: 11px; - font-weight: 500; - height: 36px; - letter-spacing: 0.4px; - padding: 0 12px 0 14px; -`; - -const FileTree = styled.div` - display: flex; - flex: 1 1 auto; - flex-direction: column; - min-height: 0; - overflow-y: auto; - padding-top: 4px; - - &::-webkit-scrollbar { - width: 6px; - } - &::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.06); - border-radius: 999px; - } -`; - -const FileRowStatic = styled.div<{ $active?: boolean; $depth: number }>` - align-items: center; - background: ${({ $active }) => - $active ? EDITOR_TOKENS.surface.activeRow : 'transparent'}; - display: flex; - flex: 0 0 24px; - gap: 6px; - height: 24px; - overflow: hidden; - padding-left: ${({ $depth }) => `${12 + $depth * 14}px`}; - padding-right: 12px; - white-space: nowrap; -`; - -const FileRowButton = styled.button<{ $active?: boolean; $depth: number }>` - align-items: center; - background: ${({ $active }) => - $active ? EDITOR_TOKENS.surface.activeRow : 'transparent'}; - border: none; - color: inherit; - cursor: pointer; - display: flex; - flex: 0 0 24px; - font-family: ${TERMINAL_TOKENS.font.ui}; - gap: 6px; - height: 24px; - overflow: hidden; - padding-left: ${({ $depth }) => `${12 + $depth * 14}px`}; - padding-right: 12px; - text-align: left; - transition: background-color 0.14s ease; - white-space: nowrap; - width: 100%; - - &:hover { - background: ${({ $active }) => - $active ? EDITOR_TOKENS.surface.activeRow : 'rgba(255, 255, 255, 0.04)'}; - } -`; - -const Caret = styled.span` - color: ${EDITOR_TOKENS.text.caret}; - font-family: ${TERMINAL_TOKENS.font.ui}; - font-size: 10px; -`; - -const FolderName = styled.span` - color: ${EDITOR_TOKENS.text.secondary}; - font-family: ${TERMINAL_TOKENS.font.ui}; - font-size: 12px; -`; - -const FileName = styled.span<{ $active?: boolean }>` - color: ${({ $active }) => - $active ? EDITOR_TOKENS.text.primary : EDITOR_TOKENS.text.muted}; - font-family: ${TERMINAL_TOKENS.font.ui}; - font-size: 12px; - font-weight: ${({ $active }) => ($active ? 500 : 400)}; -`; - -const FILE_ICON_COLOR: Record = { - ts: EDITOR_TOKENS.fileIcon.ts, - md: EDITOR_TOKENS.fileIcon.md, - js: EDITOR_TOKENS.fileIcon.js, - git: EDITOR_TOKENS.fileIcon.git, - yaml: EDITOR_TOKENS.fileIcon.yaml, - cf: EDITOR_TOKENS.fileIcon.cf, - lock: EDITOR_TOKENS.fileIcon.lock, -}; - -const FileIcon = styled.span<{ $color: string }>` - color: ${({ $color }) => $color}; - font-family: ${TERMINAL_TOKENS.font.ui}; - font-size: 9px; - font-weight: 500; - letter-spacing: 0.2px; -`; - const EditorShell = styled.div` background: ${EDITOR_TOKENS.surface.body}; display: flex; @@ -158,205 +39,6 @@ const EditorShell = styled.div` min-width: 0; `; -const TabBar = styled.div` - background: ${EDITOR_TOKENS.surface.tabBar}; - border-bottom: 1px solid ${EDITOR_TOKENS.surface.tabBarBorder}; - display: flex; - flex: 0 0 36px; - height: 36px; - overflow-x: auto; - overflow-y: hidden; - - &::-webkit-scrollbar { - display: none; - } -`; - -const Tab = styled.div<{ $active?: boolean }>` - align-items: center; - background: ${({ $active }) => - $active ? EDITOR_TOKENS.surface.activeTab : 'transparent'}; - border-right: 1px solid ${EDITOR_TOKENS.surface.tabBarBorder}; - cursor: pointer; - display: flex; - flex: 0 0 auto; - gap: 8px; - height: 36px; - padding: 0 12px 0 14px; - position: relative; - transition: background-color 0.14s ease; - - &::before { - background: ${({ $active }) => - $active ? EDITOR_TOKENS.surface.activeTabAccent : 'transparent'}; - content: ''; - height: 1px; - left: 0; - position: absolute; - right: 0; - top: 0; - } - - &:hover { - background: ${({ $active }) => - $active ? EDITOR_TOKENS.surface.activeTab : 'rgba(255, 255, 255, 0.03)'}; - } -`; - -const TabFileIcon = styled.span` - color: ${EDITOR_TOKENS.text.tabAccent}; - font-family: ${TERMINAL_TOKENS.font.ui}; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.4px; -`; - -const TabTitle = styled.span<{ $active?: boolean }>` - color: ${({ $active }) => - $active ? EDITOR_TOKENS.text.active : EDITOR_TOKENS.text.muted}; - flex: 1 1 auto; - font-family: ${TERMINAL_TOKENS.font.ui}; - font-size: 12px; - font-weight: ${({ $active }) => ($active ? 500 : 400)}; - white-space: nowrap; -`; - -const TabClose = styled.span` - align-items: center; - color: ${EDITOR_TOKENS.text.dim}; - display: flex; - flex: 0 0 14px; - height: 14px; - justify-content: center; - width: 14px; -`; - -const CodeRegion = styled.div` - background: ${EDITOR_TOKENS.surface.body}; - display: flex; - flex: 1 1 auto; - flex-direction: column; - min-height: 0; - overflow: auto; - padding: 12px 0; - scrollbar-color: rgba(255, 255, 255, 0.12) transparent; - - &::-webkit-scrollbar { - background: ${EDITOR_TOKENS.surface.body}; - height: 8px; - width: 8px; - } - &::-webkit-scrollbar-track { - background: ${EDITOR_TOKENS.surface.body}; - } - &::-webkit-scrollbar-corner { - background: ${EDITOR_TOKENS.surface.body}; - } - &::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.12); - border-radius: 999px; - } -`; - -const CodeStack = styled.div` - display: flex; - flex-direction: column; - min-width: min-content; -`; - -const CodeLineRow = styled.div` - align-items: center; - display: flex; - height: 20px; - min-width: max-content; -`; - -const Gutter = styled.div` - color: ${EDITOR_TOKENS.text.gutter}; - display: flex; - flex: 0 0 52px; - font-family: ${TERMINAL_TOKENS.font.mono}; - font-size: 12px; - height: 20px; - justify-content: flex-end; - padding-right: 12px; - user-select: none; - width: 52px; -`; - -const CodeText = styled.pre` - color: ${EDITOR_TOKENS.text.code}; - font-family: ${TERMINAL_TOKENS.font.mono}; - font-size: 13px; - line-height: 20px; - margin: 0; - padding: 0; - white-space: pre; -`; - -const TOKEN_COLOR: Record = { - text: EDITOR_TOKENS.text.code, - keyword: EDITOR_TOKENS.syntax.keyword, - function: EDITOR_TOKENS.syntax.function, - string: EDITOR_TOKENS.syntax.string, - property: EDITOR_TOKENS.syntax.property, - identifier: EDITOR_TOKENS.syntax.identifier, - comment: EDITOR_TOKENS.syntax.comment, -}; - -const renderCodeToken = (token: CodeToken, index: number) => { - if (token.kind === 'text') { - return {token.value}; - } - return ( - - {token.value} - - ); -}; - -const renderExplorerNode = ( - node: ExplorerNode, - activeFileId: string, - onSelect: (fileId: string) => void, -) => { - if (node.kind === 'folder') { - return ( - - {node.expanded ? '▾' : '▸'} - {node.name} - - ); - } - - const isSelectable = Boolean(node.fileId); - const isActive = node.fileId !== undefined && node.fileId === activeFileId; - - if (!isSelectable) { - return ( - - - {node.iconLabel} - - {node.name} - - ); - } - - return ( - onSelect(node.fileId as string)} - type="button" - > - {node.iconLabel} - {node.name} - - ); -}; - type TerminalEditorProps = { showGeneratedFiles?: boolean; }; @@ -364,67 +46,46 @@ type TerminalEditorProps = { export const TerminalEditor = ({ showGeneratedFiles = true, }: TerminalEditorProps) => { - const fallbackFileId = showGeneratedFiles - ? DEFAULT_EDITOR_FILE_ID - : STARTER_EDITOR_FILE_ID; - - const [activeFileId, setActiveFileId] = useState(fallbackFileId); - const [openFileIds, setOpenFileIds] = useState([fallbackFileId]); + const fallbackFileId = getTerminalEditorFallbackFileId(showGeneratedFiles); + const [editorState, setEditorState] = useState(() => + getInitialTerminalEditorState(showGeneratedFiles), + ); + const { activeFileId, openFileIds } = editorState; useEffect(() => { - if (!showGeneratedFiles) { - setOpenFileIds((current) => { - const next = current.filter((id) => !GENERATED_FILE_IDS.has(id)); - return next.length > 0 ? next : [STARTER_EDITOR_FILE_ID]; - }); - setActiveFileId((current) => - GENERATED_FILE_IDS.has(current) ? STARTER_EDITOR_FILE_ID : current, - ); - return; - } - setActiveFileId(DEFAULT_EDITOR_FILE_ID); - setOpenFileIds((current) => - current.includes(DEFAULT_EDITOR_FILE_ID) - ? current - : [...current, DEFAULT_EDITOR_FILE_ID], + setEditorState((state) => + syncTerminalEditorStateWithGeneratedFiles({ + state, + showGeneratedFiles, + }), ); }, [showGeneratedFiles]); const handleSelectFile = useCallback((fileId: string) => { - setActiveFileId(fileId); - setOpenFileIds((current) => - current.includes(fileId) ? current : [...current, fileId], + setEditorState((state) => + selectTerminalEditorFile({ + state, + fileId, + }), ); }, []); - const handleCloseTab = useCallback( - (event: React.MouseEvent, fileId: string) => { - event.stopPropagation(); - setOpenFileIds((current) => { - const next = current.filter((id) => id !== fileId); - if (next.length === 0) { - return [fallbackFileId]; - } - return next; - }); - setActiveFileId((current) => { - if (current !== fileId) { - return current; - } - const remaining = openFileIds.filter((id) => id !== fileId); - return remaining[remaining.length - 1] ?? fallbackFileId; - }); + const handleCloseFile = useCallback( + (fileId: string) => { + setEditorState((state) => + closeTerminalEditorFile({ + state, + fileId, + fallbackFileId, + }), + ); }, - [fallbackFileId, openFileIds], + [fallbackFileId], ); const visibleExplorerNodes = useMemo( () => - showGeneratedFiles - ? EXPLORER_NODES - : EXPLORER_NODES.filter( - (node) => !('generated' in node && node.generated), - ), + getVisibleTerminalEditorExplorerNodes(EXPLORER_NODES, showGeneratedFiles), [showGeneratedFiles], ); @@ -448,48 +109,19 @@ export const TerminalEditor = ({ return ( - - Explorer - - {visibleExplorerNodes.map((node) => - renderExplorerNode(node, activeFileId, handleSelectFile), - )} - - + - - {openFiles.map((file) => { - const isActive = file.id === activeFileId; - return ( - setActiveFileId(file.id)} - > - TS - {file.name} - handleCloseTab(event, file.id)} - > - - - - ); - })} - - - - {codeLines.map((line, index) => ( - - {index + 1} - - {line.length === 0 ? ' ' : line.map(renderCodeToken)} - - - ))} - - + + ); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorCodeView.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorCodeView.tsx new file mode 100644 index 0000000000..abe6223832 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorCodeView.tsx @@ -0,0 +1,117 @@ +import { styled } from '@linaria/react'; +import { TERMINAL_TOKENS } from '../terminalTokens'; +import { + type CodeLine, + type CodeToken, + type EditorFile, + type TokenKind, +} from './editorData'; +import { EDITOR_TOKENS } from './editorTokens'; + +const CodeRegion = styled.div` + background: ${EDITOR_TOKENS.surface.body}; + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: auto; + padding: 12px 0; + scrollbar-color: rgba(255, 255, 255, 0.12) transparent; + + &::-webkit-scrollbar { + background: ${EDITOR_TOKENS.surface.body}; + height: 8px; + width: 8px; + } + &::-webkit-scrollbar-track { + background: ${EDITOR_TOKENS.surface.body}; + } + &::-webkit-scrollbar-corner { + background: ${EDITOR_TOKENS.surface.body}; + } + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 999px; + } +`; + +const CodeStack = styled.div` + display: flex; + flex-direction: column; + min-width: min-content; +`; + +const CodeLineRow = styled.div` + align-items: center; + display: flex; + height: 20px; + min-width: max-content; +`; + +const Gutter = styled.div` + color: ${EDITOR_TOKENS.text.gutter}; + display: flex; + flex: 0 0 52px; + font-family: ${TERMINAL_TOKENS.font.mono}; + font-size: 12px; + height: 20px; + justify-content: flex-end; + padding-right: 12px; + user-select: none; + width: 52px; +`; + +const CodeText = styled.pre` + color: ${EDITOR_TOKENS.text.code}; + font-family: ${TERMINAL_TOKENS.font.mono}; + font-size: 13px; + line-height: 20px; + margin: 0; + padding: 0; + white-space: pre; +`; + +const TOKEN_COLOR: Record = { + text: EDITOR_TOKENS.text.code, + keyword: EDITOR_TOKENS.syntax.keyword, + function: EDITOR_TOKENS.syntax.function, + string: EDITOR_TOKENS.syntax.string, + property: EDITOR_TOKENS.syntax.property, + identifier: EDITOR_TOKENS.syntax.identifier, + comment: EDITOR_TOKENS.syntax.comment, +}; + +const renderCodeToken = (token: CodeToken, index: number) => { + if (token.kind === 'text') { + return {token.value}; + } + + return ( + + {token.value} + + ); +}; + +type TerminalEditorCodeViewProps = { + activeFile: EditorFile; + codeLines: CodeLine[]; +}; + +export const TerminalEditorCodeView = ({ + activeFile, + codeLines, +}: TerminalEditorCodeViewProps) => ( + + + {codeLines.map((line, index) => ( + + {index + 1} + + {line.length === 0 ? ' ' : line.map(renderCodeToken)} + + + ))} + + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorExplorer.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorExplorer.tsx new file mode 100644 index 0000000000..faa1648a72 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorExplorer.tsx @@ -0,0 +1,198 @@ +import { styled } from '@linaria/react'; +import { TERMINAL_TOKENS } from '../terminalTokens'; +import { type ExplorerNode, type FileIconKind } from './editorData'; +import { EDITOR_TOKENS } from './editorTokens'; + +const Sidebar = styled.div` + background: ${EDITOR_TOKENS.surface.sidebar}; + border-right: 1px solid ${EDITOR_TOKENS.surface.sidebarBorder}; + display: flex; + flex: 0 0 206px; + flex-direction: column; + min-height: 0; + overflow: hidden; + padding-bottom: 12px; + width: 206px; +`; + +const ExplorerHeader = styled.div` + align-items: center; + border-bottom: 1px solid ${EDITOR_TOKENS.surface.explorerHeaderBorder}; + color: ${EDITOR_TOKENS.text.explorerLabel}; + display: flex; + flex: 0 0 36px; + font-family: ${TERMINAL_TOKENS.font.ui}; + font-size: 11px; + font-weight: 500; + height: 36px; + letter-spacing: 0.4px; + padding: 0 12px 0 14px; +`; + +const FileTree = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow-y: auto; + padding-top: 4px; + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.06); + border-radius: 999px; + } +`; + +const FileRowStatic = styled.div<{ $active?: boolean; $depth: number }>` + align-items: center; + background: ${({ $active }) => + $active ? EDITOR_TOKENS.surface.activeRow : 'transparent'}; + display: flex; + flex: 0 0 24px; + gap: 6px; + height: 24px; + overflow: hidden; + padding-left: ${({ $depth }) => `${12 + $depth * 14}px`}; + padding-right: 12px; + white-space: nowrap; +`; + +const FileRowButton = styled.button<{ $active?: boolean; $depth: number }>` + align-items: center; + background: ${({ $active }) => + $active ? EDITOR_TOKENS.surface.activeRow : 'transparent'}; + border: none; + color: inherit; + cursor: pointer; + display: flex; + flex: 0 0 24px; + font-family: ${TERMINAL_TOKENS.font.ui}; + gap: 6px; + height: 24px; + overflow: hidden; + padding-left: ${({ $depth }) => `${12 + $depth * 14}px`}; + padding-right: 12px; + text-align: left; + transition: background-color 0.14s ease; + white-space: nowrap; + width: 100%; + + &:hover { + background: ${({ $active }) => + $active ? EDITOR_TOKENS.surface.activeRow : 'rgba(255, 255, 255, 0.04)'}; + } +`; + +const Caret = styled.span` + color: ${EDITOR_TOKENS.text.caret}; + font-family: ${TERMINAL_TOKENS.font.ui}; + font-size: 10px; +`; + +const FolderName = styled.span` + color: ${EDITOR_TOKENS.text.secondary}; + font-family: ${TERMINAL_TOKENS.font.ui}; + font-size: 12px; +`; + +const FileName = styled.span<{ $active?: boolean }>` + color: ${({ $active }) => + $active ? EDITOR_TOKENS.text.primary : EDITOR_TOKENS.text.muted}; + font-family: ${TERMINAL_TOKENS.font.ui}; + font-size: 12px; + font-weight: ${({ $active }) => ($active ? 500 : 400)}; +`; + +const FILE_ICON_COLOR: Record = { + ts: EDITOR_TOKENS.fileIcon.ts, + md: EDITOR_TOKENS.fileIcon.md, + js: EDITOR_TOKENS.fileIcon.js, + git: EDITOR_TOKENS.fileIcon.git, + yaml: EDITOR_TOKENS.fileIcon.yaml, + cf: EDITOR_TOKENS.fileIcon.cf, + lock: EDITOR_TOKENS.fileIcon.lock, +}; + +const FileIcon = styled.span<{ $color: string }>` + color: ${({ $color }) => $color}; + font-family: ${TERMINAL_TOKENS.font.ui}; + font-size: 9px; + font-weight: 500; + letter-spacing: 0.2px; +`; + +type TerminalEditorExplorerProps = { + activeFileId: string; + nodes: ReadonlyArray; + onSelectFile: (fileId: string) => void; +}; + +const TerminalEditorExplorerNode = ({ + activeFileId, + node, + onSelectFile, +}: { + activeFileId: string; + node: ExplorerNode; + onSelectFile: (fileId: string) => void; +}) => { + if (node.kind === 'folder') { + return ( + + {node.expanded ? '▾' : '▸'} + {node.name} + + ); + } + + const isActive = node.fileId !== undefined && node.fileId === activeFileId; + + if (node.fileId === undefined) { + return ( + + + {node.iconLabel} + + {node.name} + + ); + } + + const fileId = node.fileId; + + return ( + onSelectFile(fileId)} + type="button" + > + {node.iconLabel} + {node.name} + + ); +}; + +export const TerminalEditorExplorer = ({ + activeFileId, + nodes, + onSelectFile, +}: TerminalEditorExplorerProps) => ( + + Explorer + + {nodes.map((node) => ( + + ))} + + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorTabs.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorTabs.tsx new file mode 100644 index 0000000000..d5165f47c6 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorTabs.tsx @@ -0,0 +1,118 @@ +import { IconX } from '@tabler/icons-react'; +import { styled } from '@linaria/react'; +import { TERMINAL_TOKENS } from '../terminalTokens'; +import { type EditorFile } from './editorData'; +import { EDITOR_TOKENS } from './editorTokens'; + +const TabBar = styled.div` + background: ${EDITOR_TOKENS.surface.tabBar}; + border-bottom: 1px solid ${EDITOR_TOKENS.surface.tabBarBorder}; + display: flex; + flex: 0 0 36px; + height: 36px; + overflow-x: auto; + overflow-y: hidden; + + &::-webkit-scrollbar { + display: none; + } +`; + +const Tab = styled.div<{ $active?: boolean }>` + align-items: center; + background: ${({ $active }) => + $active ? EDITOR_TOKENS.surface.activeTab : 'transparent'}; + border-right: 1px solid ${EDITOR_TOKENS.surface.tabBarBorder}; + cursor: pointer; + display: flex; + flex: 0 0 auto; + gap: 8px; + height: 36px; + padding: 0 12px 0 14px; + position: relative; + transition: background-color 0.14s ease; + + &::before { + background: ${({ $active }) => + $active ? EDITOR_TOKENS.surface.activeTabAccent : 'transparent'}; + content: ''; + height: 1px; + left: 0; + position: absolute; + right: 0; + top: 0; + } + + &:hover { + background: ${({ $active }) => + $active ? EDITOR_TOKENS.surface.activeTab : 'rgba(255, 255, 255, 0.03)'}; + } +`; + +const TabFileIcon = styled.span` + color: ${EDITOR_TOKENS.text.tabAccent}; + font-family: ${TERMINAL_TOKENS.font.ui}; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.4px; +`; + +const TabTitle = styled.span<{ $active?: boolean }>` + color: ${({ $active }) => + $active ? EDITOR_TOKENS.text.active : EDITOR_TOKENS.text.muted}; + flex: 1 1 auto; + font-family: ${TERMINAL_TOKENS.font.ui}; + font-size: 12px; + font-weight: ${({ $active }) => ($active ? 500 : 400)}; + white-space: nowrap; +`; + +const TabClose = styled.span` + align-items: center; + color: ${EDITOR_TOKENS.text.dim}; + display: flex; + flex: 0 0 14px; + height: 14px; + justify-content: center; + width: 14px; +`; + +type TerminalEditorTabsProps = { + activeFileId: string; + files: ReadonlyArray; + onCloseFile: (fileId: string) => void; + onSelectFile: (fileId: string) => void; +}; + +export const TerminalEditorTabs = ({ + activeFileId, + files, + onCloseFile, + onSelectFile, +}: TerminalEditorTabsProps) => ( + + {files.map((file) => { + const isActive = file.id === activeFileId; + + return ( + onSelectFile(file.id)} + > + TS + {file.name} + { + event.stopPropagation(); + onCloseFile(file.id); + }} + > + + + + ); + })} + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.test.ts new file mode 100644 index 0000000000..5f96380bdd --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.test.ts @@ -0,0 +1,190 @@ +import { + DEFAULT_EDITOR_FILE_ID, + STARTER_EDITOR_FILE_ID, + type ExplorerNode, +} from './editorData'; +import { + closeTerminalEditorFile, + getInitialTerminalEditorState, + getTerminalEditorFallbackFileId, + getVisibleTerminalEditorExplorerNodes, + selectTerminalEditorFile, + syncTerminalEditorStateWithGeneratedFiles, +} from './terminal-editor-state'; + +describe('terminal-editor-state', () => { + it('chooses the correct fallback file for generated and starter modes', () => { + expect(getTerminalEditorFallbackFileId(true)).toBe(DEFAULT_EDITOR_FILE_ID); + expect(getTerminalEditorFallbackFileId(false)).toBe(STARTER_EDITOR_FILE_ID); + }); + + it('creates an initial editor state from visibility mode', () => { + expect(getInitialTerminalEditorState(true)).toEqual({ + activeFileId: DEFAULT_EDITOR_FILE_ID, + openFileIds: [DEFAULT_EDITOR_FILE_ID], + }); + + expect(getInitialTerminalEditorState(false)).toEqual({ + activeFileId: STARTER_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID], + }); + }); + + it('activates and opens the generated default file when generated files become visible', () => { + expect( + syncTerminalEditorStateWithGeneratedFiles({ + state: { + activeFileId: STARTER_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID], + }, + showGeneratedFiles: true, + }), + ).toEqual({ + activeFileId: DEFAULT_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID, DEFAULT_EDITOR_FILE_ID], + }); + }); + + it('removes generated files and falls back to the starter file when generated files are hidden', () => { + expect( + syncTerminalEditorStateWithGeneratedFiles({ + state: { + activeFileId: DEFAULT_EDITOR_FILE_ID, + openFileIds: [DEFAULT_EDITOR_FILE_ID, 'rocket-object'], + }, + showGeneratedFiles: false, + }), + ).toEqual({ + activeFileId: STARTER_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID], + }); + }); + + it('keeps a non-generated active file only when it remains open after generated files are hidden', () => { + expect( + syncTerminalEditorStateWithGeneratedFiles({ + state: { + activeFileId: STARTER_EDITOR_FILE_ID, + openFileIds: [DEFAULT_EDITOR_FILE_ID, STARTER_EDITOR_FILE_ID], + }, + showGeneratedFiles: false, + }), + ).toEqual({ + activeFileId: STARTER_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID], + }); + }); + + it('selects a file without duplicating an already open tab', () => { + const state = { + activeFileId: STARTER_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID], + }; + + expect( + selectTerminalEditorFile({ + state, + fileId: STARTER_EDITOR_FILE_ID, + }), + ).toEqual(state); + + expect( + selectTerminalEditorFile({ + state, + fileId: DEFAULT_EDITOR_FILE_ID, + }), + ).toEqual({ + activeFileId: DEFAULT_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID, DEFAULT_EDITOR_FILE_ID], + }); + }); + + it('closes inactive tabs without changing the active file', () => { + expect( + closeTerminalEditorFile({ + state: { + activeFileId: STARTER_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID, DEFAULT_EDITOR_FILE_ID], + }, + fileId: DEFAULT_EDITOR_FILE_ID, + fallbackFileId: STARTER_EDITOR_FILE_ID, + }), + ).toEqual({ + activeFileId: STARTER_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID], + }); + }); + + it('closes active tabs and activates the previous remaining tab', () => { + expect( + closeTerminalEditorFile({ + state: { + activeFileId: DEFAULT_EDITOR_FILE_ID, + openFileIds: [ + STARTER_EDITOR_FILE_ID, + 'schema-identifiers', + DEFAULT_EDITOR_FILE_ID, + ], + }, + fileId: DEFAULT_EDITOR_FILE_ID, + fallbackFileId: STARTER_EDITOR_FILE_ID, + }), + ).toEqual({ + activeFileId: 'schema-identifiers', + openFileIds: [STARTER_EDITOR_FILE_ID, 'schema-identifiers'], + }); + }); + + it('keeps one fallback tab open when the last tab closes', () => { + expect( + closeTerminalEditorFile({ + state: { + activeFileId: STARTER_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID], + }, + fileId: STARTER_EDITOR_FILE_ID, + fallbackFileId: STARTER_EDITOR_FILE_ID, + }), + ).toEqual({ + activeFileId: STARTER_EDITOR_FILE_ID, + openFileIds: [STARTER_EDITOR_FILE_ID], + }); + }); + + it('filters generated explorer nodes only when generated files are hidden', () => { + const nodes: ExplorerNode[] = [ + { + depth: 0, + expanded: true, + id: 'root', + kind: 'folder', + name: 'root', + }, + { + depth: 1, + fileId: DEFAULT_EDITOR_FILE_ID, + generated: true, + icon: 'ts', + iconLabel: 'TS', + id: 'generated', + kind: 'file', + name: 'generated.ts', + }, + { + depth: 1, + fileId: STARTER_EDITOR_FILE_ID, + icon: 'ts', + iconLabel: 'TS', + id: 'starter', + kind: 'file', + name: 'starter.ts', + }, + ]; + + expect(getVisibleTerminalEditorExplorerNodes(nodes, true)).toBe(nodes); + expect(getVisibleTerminalEditorExplorerNodes(nodes, false)).toEqual([ + nodes[0], + nodes[2], + ]); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.ts new file mode 100644 index 0000000000..e3d16f6cd4 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.ts @@ -0,0 +1,107 @@ +import { + DEFAULT_EDITOR_FILE_ID, + GENERATED_FILE_IDS, + STARTER_EDITOR_FILE_ID, + type ExplorerNode, +} from './editorData'; + +export type TerminalEditorState = { + activeFileId: string; + openFileIds: string[]; +}; + +export const getTerminalEditorFallbackFileId = ( + showGeneratedFiles: boolean, +): string => + showGeneratedFiles ? DEFAULT_EDITOR_FILE_ID : STARTER_EDITOR_FILE_ID; + +export const getInitialTerminalEditorState = ( + showGeneratedFiles: boolean, +): TerminalEditorState => { + const fallbackFileId = getTerminalEditorFallbackFileId(showGeneratedFiles); + + return { + activeFileId: fallbackFileId, + openFileIds: [fallbackFileId], + }; +}; + +export const syncTerminalEditorStateWithGeneratedFiles = ({ + state, + showGeneratedFiles, +}: { + state: TerminalEditorState; + showGeneratedFiles: boolean; +}): TerminalEditorState => { + if (showGeneratedFiles) { + return { + activeFileId: DEFAULT_EDITOR_FILE_ID, + openFileIds: state.openFileIds.includes(DEFAULT_EDITOR_FILE_ID) + ? state.openFileIds + : [...state.openFileIds, DEFAULT_EDITOR_FILE_ID], + }; + } + + const openFileIds = state.openFileIds.filter( + (fileId) => !GENERATED_FILE_IDS.has(fileId), + ); + const safeOpenFileIds = + openFileIds.length > 0 ? openFileIds : [STARTER_EDITOR_FILE_ID]; + const activeCandidate = GENERATED_FILE_IDS.has(state.activeFileId) + ? STARTER_EDITOR_FILE_ID + : state.activeFileId; + + return { + activeFileId: safeOpenFileIds.includes(activeCandidate) + ? activeCandidate + : safeOpenFileIds[0], + openFileIds: safeOpenFileIds, + }; +}; + +export const selectTerminalEditorFile = ({ + state, + fileId, +}: { + state: TerminalEditorState; + fileId: string; +}): TerminalEditorState => ({ + activeFileId: fileId, + openFileIds: state.openFileIds.includes(fileId) + ? state.openFileIds + : [...state.openFileIds, fileId], +}); + +export const closeTerminalEditorFile = ({ + state, + fileId, + fallbackFileId, +}: { + state: TerminalEditorState; + fileId: string; + fallbackFileId: string; +}): TerminalEditorState => { + const openFileIds = state.openFileIds.filter( + (openFileId) => openFileId !== fileId, + ); + const safeOpenFileIds = + openFileIds.length > 0 ? openFileIds : [fallbackFileId]; + + if (state.activeFileId !== fileId) { + return { + activeFileId: state.activeFileId, + openFileIds: safeOpenFileIds, + }; + } + + return { + activeFileId: openFileIds[openFileIds.length - 1] ?? fallbackFileId, + openFileIds: safeOpenFileIds, + }; +}; + +export const getVisibleTerminalEditorExplorerNodes = ( + nodes: ReadonlyArray, + showGeneratedFiles: boolean, +): ReadonlyArray => + showGeneratedFiles ? nodes : nodes.filter((node) => node.generated !== true); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptBox.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptBox.tsx new file mode 100644 index 0000000000..9c9a5f11ed --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptBox.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { TerminalPromptFooter } from './TerminalPromptFooter'; +import { TerminalPromptMessage } from './TerminalPromptMessage'; +import { TERMINAL_TOKENS } from '../terminalTokens'; +import { useTerminalPromptEasterEgg } from './use-terminal-prompt-easter-egg'; + +const PromptArea = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + padding: 12px; + width: 100%; +`; + +const PromptBox = styled.div` + background: ${TERMINAL_TOKENS.surface.promptBoxBackground}; + border: 1px solid ${TERMINAL_TOKENS.surface.promptBoxBorder}; + border-radius: 16px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 12px; + justify-content: space-between; + min-height: 120px; + overflow: hidden; + padding: 12px; + transition: + background-color 0.16s ease, + border-color 0.16s ease; + width: 100%; + + &:hover { + background: ${TERMINAL_TOKENS.surface.promptBoxBackgroundHover}; + border-color: ${TERMINAL_TOKENS.surface.promptBoxBorderFocus}; + } + + &:focus-within { + background: ${TERMINAL_TOKENS.surface.promptBoxBackgroundHover}; + border-color: ${TERMINAL_TOKENS.surface.promptBoxBorderFocus}; + } + + &[data-wiggle='true'] { + animation: promptWiggle 0.5s ease; + } + + @keyframes promptWiggle { + 0%, + 100% { + transform: translateX(0); + } + 20% { + transform: translateX(-3px) rotate(-0.4deg); + } + 40% { + transform: translateX(3px) rotate(0.4deg); + } + 60% { + transform: translateX(-2px) rotate(-0.2deg); + } + 80% { + transform: translateX(2px) rotate(0.2deg); + } + } +`; + +type TerminalPromptBoxProps = { + promptText: string; + promptIsPlaceholder?: boolean; + onSend?: () => void; + sendDisabled?: boolean; + isChatFinished?: boolean; + onReset?: () => void; +}; + +export const TerminalPromptBox = ({ + promptText, + promptIsPlaceholder, + onSend, + sendDisabled, + isChatFinished, + onReset, +}: TerminalPromptBoxProps) => { + const { + easterEggMessage, + handleAnimationEnd, + handleClick: handleEasterEggClick, + isWiggling, + } = useTerminalPromptEasterEgg({ + enabled: isChatFinished === true, + }); + const showEasterEgg = easterEggMessage !== null; + const displayText = easterEggMessage ?? promptText; + + return ( + + + + + + + ); +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptChip.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptChip.tsx similarity index 96% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptChip.tsx rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptChip.tsx index 096b30c3e7..34ffe743a6 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptChip.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptChip.tsx @@ -2,7 +2,7 @@ import { styled } from '@linaria/react'; import type { ReactNode } from 'react'; -import { TERMINAL_TOKENS } from './terminalTokens'; +import { TERMINAL_TOKENS } from '../terminalTokens'; type TerminalPromptChipProps = { icon: ReactNode; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptFooter.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptFooter.tsx new file mode 100644 index 0000000000..fab46b8408 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptFooter.tsx @@ -0,0 +1,90 @@ +import { styled } from '@linaria/react'; +import { IconFolder, IconGitBranch } from '@tabler/icons-react'; + +import { TerminalPromptChip } from './TerminalPromptChip'; +import { TerminalSendButton } from './TerminalSendButton'; +import { TERMINAL_TOKENS } from '../terminalTokens'; + +const PromptFooter = styled.div` + align-items: center; + display: flex; + flex-wrap: nowrap; + gap: 8px; + width: 100%; +`; + +const ChipRow = styled.div` + align-items: center; + display: flex; + flex: 1 1 auto; + gap: 8px; + min-width: 0; +`; + +const ActionRow = styled.div` + align-items: center; + display: flex; + gap: 4px; + margin-left: auto; +`; + +const MythosButton = styled.button` + align-items: center; + background: transparent; + border: none; + border-radius: 4px; + color: ${TERMINAL_TOKENS.text.muted}; + cursor: pointer; + display: flex; + font-family: ${TERMINAL_TOKENS.font.mono}; + font-size: 13px; + font-weight: 400; + gap: 4px; + height: 24px; + line-height: 1.4; + padding: 0 8px; + transition: + background-color 0.14s ease, + color 0.14s ease; + white-space: nowrap; + + &:hover { + background: ${TERMINAL_TOKENS.surface.mythosHoverBackground}; + color: ${TERMINAL_TOKENS.text.mutedHover}; + } +`; + +type TerminalPromptFooterProps = { + isChatFinished?: boolean; + onReset?: () => void; + onSend?: () => void; + sendDisabled?: boolean; +}; + +export const TerminalPromptFooter = ({ + isChatFinished, + onReset, + onSend, + sendDisabled, +}: TerminalPromptFooterProps) => ( + + + } + label="~/code/my-twenty-app" + /> + } + label="main" + /> + + + Mythos + + + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptMessage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptMessage.tsx new file mode 100644 index 0000000000..0457e7a17a --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptMessage.tsx @@ -0,0 +1,68 @@ +import { styled } from '@linaria/react'; + +import { TERMINAL_TOKENS } from '../terminalTokens'; + +const PromptTextRow = styled.div<{ $clickable?: boolean }>` + align-items: flex-start; + cursor: ${({ $clickable }) => ($clickable ? 'pointer' : 'default')}; + display: flex; + flex: 1 1 auto; + min-width: 0; + padding-left: 6px; + user-select: none; + -webkit-user-select: none; +`; + +const PromptText = styled.p<{ $isPlaceholder?: boolean }>` + color: ${({ $isPlaceholder }) => + $isPlaceholder ? TERMINAL_TOKENS.text.muted : TERMINAL_TOKENS.text.prompt}; + font-family: ${TERMINAL_TOKENS.font.ui}; + font-size: 13px; + font-weight: 400; + line-height: 18px; + margin: 0; + overflow-wrap: anywhere; + transition: color 0.18s ease; + white-space: normal; + word-break: break-word; + + animation: promptTextSwap 0.28s ease; + + @keyframes promptTextSwap { + 0% { + opacity: 0; + transform: translateY(4px); + filter: blur(2px); + } + 100% { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } + } +`; + +type TerminalPromptMessageProps = { + isEasterEggVisible: boolean; + isPlaceholder?: boolean; + isClickable?: boolean; + onClick: () => void; + text: string; +}; + +export const TerminalPromptMessage = ({ + isClickable, + isEasterEggVisible, + isPlaceholder, + onClick, + text, +}: TerminalPromptMessageProps) => ( + + + {text} + + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButton.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButton.tsx new file mode 100644 index 0000000000..54386a65b8 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButton.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { IconArrowBackUp, IconArrowUp } from '@tabler/icons-react'; +import { styled } from '@linaria/react'; +import { TerminalSendButtonFingerHint } from './TerminalSendButtonFingerHint'; +import { TERMINAL_TOKENS } from '../terminalTokens'; +import { useTerminalSendButtonHint } from './use-terminal-send-button-hint'; + +type TerminalSendButtonProps = { + onClick?: () => void; + disabled?: boolean; + mode?: 'send' | 'reset'; +}; + +const SendButtonWrapper = styled.span` + display: inline-flex; + position: relative; +`; + +const SendButtonRoot = styled.button<{ $isReset: boolean }>` + align-items: center; + background: ${({ $isReset }) => + $isReset ? '#5a5a5a' : TERMINAL_TOKENS.accent.brand}; + border: none; + border-radius: 999px; + box-shadow: ${({ $isReset }) => + $isReset + ? 'none' + : '0 0 0 1px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.12)'}; + color: #ffffff; + cursor: pointer; + display: flex; + flex: 0 0 auto; + height: 32px; + justify-content: center; + padding: 0 4px; + transition: + background-color 0.14s ease, + transform 0.12s ease; + width: 32px; + + &:hover:not(:disabled) { + background: ${({ $isReset }) => + $isReset ? '#4c4c4c' : TERMINAL_TOKENS.accent.brandHover}; + } + + &:active:not(:disabled) { + transform: scale(0.94); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } +`; + +export const TerminalSendButton = ({ + onClick, + disabled, + mode = 'send', +}: TerminalSendButtonProps) => { + const isReset = mode === 'reset'; + const { buttonRef, dismissHint, hintPosition, hintReady, showHint } = + useTerminalSendButtonHint({ + disabled, + isReset, + }); + + return ( + + { + dismissHint(); + onClick?.(); + }} + onMouseEnter={dismissHint} + ref={buttonRef} + type="button" + > + {isReset ? ( + + ) : ( + + )} + + + + ); +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButtonFingerHint.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButtonFingerHint.tsx new file mode 100644 index 0000000000..9d6870315e --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButtonFingerHint.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { createPortal } from 'react-dom'; +import type { TerminalSendButtonHintPosition } from './terminal-send-button-hint-position'; + +const FINGER_ROTATION = -21; +const FINGER_SIZE = 51; + +const FingerHint = styled.span` + pointer-events: none; + position: fixed; + z-index: 20; +`; + +const FingerTapAnim = styled.span` + animation: fingerTap 1.4s ease-in-out infinite; + display: block; + + @keyframes fingerTap { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } + } +`; + +const FingerIcon = ({ size }: { size: number }) => ( + + + + + + + + + + + + + + + + + + + +); + +type TerminalSendButtonFingerHintProps = { + position: TerminalSendButtonHintPosition | null; + ready: boolean; + visible: boolean; +}; + +export const TerminalSendButtonFingerHint = ({ + position, + ready, + visible, +}: TerminalSendButtonFingerHintProps) => { + if (!visible || position === null || typeof document === 'undefined') { + return null; + } + + return createPortal( + + + + + , + document.body, + ); +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.test.ts new file mode 100644 index 0000000000..f42737257d --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.test.ts @@ -0,0 +1,88 @@ +import { + getTerminalPromptEasterEggMessage, + INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE, + TERMINAL_PROMPT_EASTER_EGG_MESSAGES, + terminalPromptEasterEggReducer, +} from './terminal-prompt-easter-egg-state'; + +describe('terminalPromptEasterEggReducer', () => { + it('advances from no easter egg to the first message', () => { + const state = terminalPromptEasterEggReducer( + INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE, + { type: 'advance' }, + ); + + expect(state).toMatchObject({ + escapeClickCount: 1, + escapeEventCount: 0, + isWiggling: true, + messageIndex: 0, + }); + expect(getTerminalPromptEasterEggMessage(state)).toBe( + TERMINAL_PROMPT_EASTER_EGG_MESSAGES[0], + ); + }); + + it('cycles through easter egg messages', () => { + const lastMessageState = { + ...INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE, + messageIndex: TERMINAL_PROMPT_EASTER_EGG_MESSAGES.length - 1, + }; + + expect( + terminalPromptEasterEggReducer(lastMessageState, { + type: 'advance', + }).messageIndex, + ).toBe(0); + }); + + it('triggers the traffic-light escape event every fifth click', () => { + const almostReadyState = { + ...INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE, + escapeClickCount: 4, + escapeEventCount: 2, + }; + + expect( + terminalPromptEasterEggReducer(almostReadyState, { + type: 'advance', + }), + ).toMatchObject({ + escapeClickCount: 0, + escapeEventCount: 3, + isWiggling: true, + messageIndex: 0, + }); + }); + + it('stops the prompt wiggle without changing the selected message', () => { + expect( + terminalPromptEasterEggReducer( + { + ...INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE, + isWiggling: true, + messageIndex: 2, + }, + { type: 'stop-wiggle' }, + ), + ).toEqual({ + ...INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE, + isWiggling: false, + messageIndex: 2, + }); + }); + + it('resets to the initial state', () => { + expect( + terminalPromptEasterEggReducer( + { + escapeClickCount: 3, + escapeEventCount: 1, + isWiggling: true, + messageIndex: 4, + }, + { type: 'reset' }, + ), + ).toBe(INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.ts new file mode 100644 index 0000000000..e9b08b2751 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.ts @@ -0,0 +1,78 @@ +const TRAFFIC_LIGHTS_ESCAPE_THRESHOLD = 5; + +export const TERMINAL_PROMPT_EASTER_EGG_MESSAGES = [ + 'Ask me to do something your CRM should have done years ago', + 'Build the thing your admin said was impossible', + 'Turn this CRM into something actually useful', + 'Ask for a workflow. Not a miracle.', + 'Describe the app you wish you already had', + 'Create a spaceship. Or a sales workflow.', + 'Make Salesforce nervous', + 'Still here? Type the impossible', + 'Describe the tool you were not supposed to have.', + 'Build the thing hidden behind a paywall elsewhere.', +]; + +export type TerminalPromptEasterEggState = { + escapeClickCount: number; + escapeEventCount: number; + isWiggling: boolean; + messageIndex: number | null; +}; + +type TerminalPromptEasterEggAction = + | { type: 'advance' } + | { type: 'reset' } + | { type: 'stop-wiggle' }; + +export const INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE: TerminalPromptEasterEggState = + { + escapeClickCount: 0, + escapeEventCount: 0, + isWiggling: false, + messageIndex: null, + }; + +export const terminalPromptEasterEggReducer = ( + state: TerminalPromptEasterEggState, + action: TerminalPromptEasterEggAction, +): TerminalPromptEasterEggState => { + switch (action.type) { + case 'advance': { + const nextEscapeClickCount = state.escapeClickCount + 1; + const shouldTriggerEscape = + nextEscapeClickCount >= TRAFFIC_LIGHTS_ESCAPE_THRESHOLD; + + return { + escapeClickCount: shouldTriggerEscape ? 0 : nextEscapeClickCount, + escapeEventCount: shouldTriggerEscape + ? state.escapeEventCount + 1 + : state.escapeEventCount, + isWiggling: true, + messageIndex: + state.messageIndex === null + ? 0 + : (state.messageIndex + 1) % + TERMINAL_PROMPT_EASTER_EGG_MESSAGES.length, + }; + } + + case 'reset': { + return INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE; + } + + case 'stop-wiggle': { + return { + ...state, + isWiggling: false, + }; + } + } +}; + +export const getTerminalPromptEasterEggMessage = ( + state: TerminalPromptEasterEggState, +): string | null => + state.messageIndex === null + ? null + : TERMINAL_PROMPT_EASTER_EGG_MESSAGES[state.messageIndex]; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.test.ts new file mode 100644 index 0000000000..d253da67ee --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.test.ts @@ -0,0 +1,15 @@ +import { getTerminalSendButtonHintPosition } from './terminal-send-button-hint-position'; + +describe('getTerminalSendButtonHintPosition', () => { + it('anchors the finger hint near the send button bottom-right corner', () => { + expect( + getTerminalSendButtonHintPosition({ + bottom: 120, + right: 240, + }), + ).toEqual({ + left: 218, + top: 102, + }); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.ts new file mode 100644 index 0000000000..0f679852a2 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.ts @@ -0,0 +1,18 @@ +export type TerminalSendButtonHintPosition = { + left: number; + top: number; +}; + +const FINGER_OFFSET_RIGHT = -22; +const FINGER_OFFSET_BOTTOM = -18; + +export const getTerminalSendButtonHintPosition = ({ + bottom, + right, +}: { + bottom: number; + right: number; +}): TerminalSendButtonHintPosition => ({ + left: right + FINGER_OFFSET_RIGHT, + top: bottom + FINGER_OFFSET_BOTTOM, +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-prompt-easter-egg.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-prompt-easter-egg.ts new file mode 100644 index 0000000000..79321c92f6 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-prompt-easter-egg.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useReducer } from 'react'; +import { + getTerminalPromptEasterEggMessage, + INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE, + terminalPromptEasterEggReducer, +} from './terminal-prompt-easter-egg-state'; +import { TRAFFIC_LIGHTS_ESCAPE_EVENT } from '../TerminalTrafficLights/terminal-traffic-light-constants'; + +type UseTerminalPromptEasterEggOptions = { + enabled: boolean; +}; + +export const useTerminalPromptEasterEgg = ({ + enabled, +}: UseTerminalPromptEasterEggOptions) => { + const [state, dispatch] = useReducer( + terminalPromptEasterEggReducer, + INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE, + ); + + useEffect(() => { + if (!enabled) { + dispatch({ type: 'reset' }); + } + }, [enabled]); + + useEffect(() => { + if (!enabled || state.escapeEventCount === 0) { + return; + } + + window.dispatchEvent(new CustomEvent(TRAFFIC_LIGHTS_ESCAPE_EVENT)); + }, [enabled, state.escapeEventCount]); + + const handleClick = useCallback(() => { + if (!enabled) { + return; + } + + dispatch({ type: 'advance' }); + }, [enabled]); + + const handleAnimationEnd = useCallback(() => { + dispatch({ type: 'stop-wiggle' }); + }, []); + + return { + easterEggMessage: enabled ? getTerminalPromptEasterEggMessage(state) : null, + handleAnimationEnd, + handleClick, + isWiggling: state.isWiggling, + }; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-send-button-hint.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-send-button-hint.ts new file mode 100644 index 0000000000..b851c40f3f --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-send-button-hint.ts @@ -0,0 +1,133 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { createAnimationFrameLoop } from '@/lib/animation'; +import { useTimeoutRegistry } from '@/lib/react'; +import { + getTerminalSendButtonHintPosition, + type TerminalSendButtonHintPosition, +} from './terminal-send-button-hint-position'; + +const HINT_READY_DELAY = 400; +const TERMINAL_SHELL_SELECTOR = '[data-terminal-shell="true"]'; + +type UseTerminalSendButtonHintOptions = { + disabled?: boolean; + isReset: boolean; +}; + +export const useTerminalSendButtonHint = ({ + disabled, + isReset, +}: UseTerminalSendButtonHintOptions) => { + const [hintDismissed, setHintDismissed] = useState(false); + const [hintPosition, setHintPosition] = + useState(null); + const [hintReady, setHintReady] = useState(false); + const buttonRef = useRef(null); + const timeoutRegistry = useTimeoutRegistry(); + const dismissHint = useCallback(() => setHintDismissed(true), []); + const showHint = !hintDismissed && !isReset && disabled !== true; + + useLayoutEffect(() => { + if (!showHint) { + setHintPosition(null); + setHintReady(false); + + return; + } + + let lastLeft = Number.NaN; + let lastTop = Number.NaN; + const cancelReadyTimer = timeoutRegistry.schedule( + () => setHintReady(true), + HINT_READY_DELAY, + ); + + const positionLoop = createAnimationFrameLoop({ + onFrame: () => { + if (document.hidden) { + return false; + } + + const button = buttonRef.current; + + if (button !== null) { + const rect = button.getBoundingClientRect(); + + if (rect.width > 0) { + const nextPosition = getTerminalSendButtonHintPosition({ + bottom: rect.bottom, + right: rect.right, + }); + + if ( + nextPosition.left !== lastLeft || + nextPosition.top !== lastTop + ) { + lastLeft = nextPosition.left; + lastTop = nextPosition.top; + setHintPosition(nextPosition); + } + } + } + + return true; + }, + }); + + const startPositionLoop = () => { + if (!document.hidden) { + positionLoop.start(); + } + }; + + const handleVisibility = () => { + if (document.hidden) { + positionLoop.stop(); + + return; + } + + startPositionLoop(); + }; + + const handleTerminalInteraction = (event: PointerEvent) => { + const button = buttonRef.current; + const terminalShell = button?.closest(TERMINAL_SHELL_SELECTOR); + + if (terminalShell === null || terminalShell === undefined) { + return; + } + + if (!(event.target instanceof Node)) { + return; + } + + if (terminalShell.contains(event.target)) { + setHintDismissed(true); + } + }; + + startPositionLoop(); + document.addEventListener('visibilitychange', handleVisibility); + window.addEventListener('pointerdown', handleTerminalInteraction, true); + + return () => { + positionLoop.stop(); + cancelReadyTimer(); + document.removeEventListener('visibilitychange', handleVisibility); + window.removeEventListener( + 'pointerdown', + handleTerminalInteraction, + true, + ); + }; + }, [showHint, timeoutRegistry]); + + return { + buttonRef, + dismissHint, + hintPosition, + hintReady, + showHint, + }; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptBox.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptBox.tsx deleted file mode 100644 index d2af7c4dec..0000000000 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptBox.tsx +++ /dev/null @@ -1,265 +0,0 @@ -'use client'; - -import { IconFolder, IconGitBranch } from '@tabler/icons-react'; -import { styled } from '@linaria/react'; -import { useEffect, useState } from 'react'; -import { TERMINAL_TOKENS } from './terminalTokens'; -import { TerminalPromptChip } from './TerminalPromptChip'; -import { TerminalSendButton } from './TerminalSendButton'; -import { TRAFFIC_LIGHTS_ESCAPE_EVENT } from './TerminalTrafficLights'; - -const TRAFFIC_LIGHTS_ESCAPE_THRESHOLD = 5; - -const EASTER_EGG_MESSAGES = [ - 'Ask me to do something your CRM should have done years ago', - 'Build the thing your admin said was impossible', - 'Turn this CRM into something actually useful', - 'Ask for a workflow. Not a miracle.', - 'Describe the app you wish you already had', - 'Create a spaceship. Or a sales workflow.', - 'Make Salesforce nervous', - 'Still here? Type the impossible', - 'Describe the tool you were not supposed to have.', - 'Build the thing hidden behind a paywall elsewhere.', -]; - -const PromptArea = styled.div` - box-sizing: border-box; - display: flex; - flex-direction: column; - padding: 12px; - width: 100%; -`; - -const PromptBox = styled.div` - background: ${TERMINAL_TOKENS.surface.promptBoxBackground}; - border: 1px solid ${TERMINAL_TOKENS.surface.promptBoxBorder}; - border-radius: 16px; - box-sizing: border-box; - display: flex; - flex-direction: column; - gap: 12px; - justify-content: space-between; - min-height: 120px; - overflow: hidden; - padding: 12px; - transition: - background-color 0.16s ease, - border-color 0.16s ease; - width: 100%; - - &:hover { - background: ${TERMINAL_TOKENS.surface.promptBoxBackgroundHover}; - border-color: ${TERMINAL_TOKENS.surface.promptBoxBorderFocus}; - } - - &:focus-within { - background: ${TERMINAL_TOKENS.surface.promptBoxBackgroundHover}; - border-color: ${TERMINAL_TOKENS.surface.promptBoxBorderFocus}; - } - - &[data-wiggle='true'] { - animation: promptWiggle 0.5s ease; - } - - @keyframes promptWiggle { - 0%, - 100% { - transform: translateX(0); - } - 20% { - transform: translateX(-3px) rotate(-0.4deg); - } - 40% { - transform: translateX(3px) rotate(0.4deg); - } - 60% { - transform: translateX(-2px) rotate(-0.2deg); - } - 80% { - transform: translateX(2px) rotate(0.2deg); - } - } -`; - -const PromptTextRow = styled.div<{ $clickable?: boolean }>` - align-items: flex-start; - cursor: ${({ $clickable }) => ($clickable ? 'pointer' : 'default')}; - display: flex; - flex: 1 1 auto; - min-width: 0; - padding-left: 6px; - user-select: none; - -webkit-user-select: none; -`; - -const PromptText = styled.p<{ $isPlaceholder?: boolean }>` - color: ${({ $isPlaceholder }) => - $isPlaceholder ? TERMINAL_TOKENS.text.muted : TERMINAL_TOKENS.text.prompt}; - font-family: ${TERMINAL_TOKENS.font.ui}; - font-size: 13px; - font-weight: 400; - line-height: 18px; - margin: 0; - overflow-wrap: anywhere; - transition: color 0.18s ease; - white-space: normal; - word-break: break-word; - - animation: promptTextSwap 0.28s ease; - - @keyframes promptTextSwap { - 0% { - opacity: 0; - transform: translateY(4px); - filter: blur(2px); - } - 100% { - opacity: 1; - transform: translateY(0); - filter: blur(0); - } - } -`; - -const PromptFooter = styled.div` - align-items: center; - display: flex; - flex-wrap: nowrap; - gap: 8px; - width: 100%; -`; - -const ChipRow = styled.div` - align-items: center; - display: flex; - flex: 1 1 auto; - gap: 8px; - min-width: 0; -`; - -const ActionRow = styled.div` - align-items: center; - display: flex; - gap: 4px; - margin-left: auto; -`; - -const MythosButton = styled.button` - align-items: center; - background: transparent; - border: none; - border-radius: 4px; - color: ${TERMINAL_TOKENS.text.muted}; - cursor: pointer; - display: flex; - font-family: ${TERMINAL_TOKENS.font.mono}; - font-size: 13px; - font-weight: 400; - gap: 4px; - height: 24px; - line-height: 1.4; - padding: 0 8px; - transition: - background-color 0.14s ease, - color 0.14s ease; - white-space: nowrap; - - &:hover { - background: ${TERMINAL_TOKENS.surface.mythosHoverBackground}; - color: ${TERMINAL_TOKENS.text.mutedHover}; - } -`; - -type TerminalPromptBoxProps = { - promptText: string; - promptIsPlaceholder?: boolean; - onSend?: () => void; - sendDisabled?: boolean; - isChatFinished?: boolean; - onReset?: () => void; -}; - -export const TerminalPromptBox = ({ - promptText, - promptIsPlaceholder, - onSend, - sendDisabled, - isChatFinished, - onReset, -}: TerminalPromptBoxProps) => { - const [easterEggIndex, setEasterEggIndex] = useState(null); - const [isWiggling, setIsWiggling] = useState(false); - const [, setClickCount] = useState(0); - - useEffect(() => { - if (!isChatFinished) { - setEasterEggIndex(null); - setIsWiggling(false); - setClickCount(0); - } - }, [isChatFinished]); - - const handleEasterEggClick = () => { - if (!isChatFinished) { - return; - } - setEasterEggIndex((prev) => - prev === null ? 0 : (prev + 1) % EASTER_EGG_MESSAGES.length, - ); - setIsWiggling(true); - window.setTimeout(() => setIsWiggling(false), 500); - - setClickCount((prev) => { - const next = prev + 1; - if (next >= TRAFFIC_LIGHTS_ESCAPE_THRESHOLD) { - window.dispatchEvent(new CustomEvent(TRAFFIC_LIGHTS_ESCAPE_EVENT)); - return 0; - } - return next; - }); - }; - - const showEasterEgg = isChatFinished && easterEggIndex !== null; - const displayText = showEasterEgg - ? EASTER_EGG_MESSAGES[easterEggIndex] - : promptText; - - return ( - - - - - {displayText} - - - - - } - label="~/code/my-twenty-app" - /> - } - label="main" - /> - - - Mythos - - - - - - ); -}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalResizeHandles.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalResizeHandles.tsx new file mode 100644 index 0000000000..cea7801b87 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalResizeHandles.tsx @@ -0,0 +1,148 @@ +import { styled } from '@linaria/react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; +import type { WindowResizeHandle } from '../WindowInteraction/window-geometry'; + +type StartTerminalResize = ( + handle: WindowResizeHandle, +) => (event: ReactPointerEvent) => void; + +const ResizeCornerBase = styled.div` + height: 16px; + position: absolute; + width: 16px; + z-index: 5; + + &::after { + border-radius: 1px; + content: ''; + height: 8px; + opacity: 0; + position: absolute; + transition: opacity 0.18s ease; + width: 8px; + } + + &:hover::after { + opacity: 1; + } +`; + +const ResizeCornerTopLeft = styled(ResizeCornerBase)` + cursor: nwse-resize; + left: -4px; + top: -4px; + + &::after { + border-left: 2px solid rgba(0, 0, 0, 0.18); + border-top: 2px solid rgba(0, 0, 0, 0.18); + left: 6px; + top: 6px; + } +`; + +const ResizeCornerTopRight = styled(ResizeCornerBase)` + cursor: nesw-resize; + right: -4px; + top: -4px; + + &::after { + border-right: 2px solid rgba(0, 0, 0, 0.18); + border-top: 2px solid rgba(0, 0, 0, 0.18); + right: 6px; + top: 6px; + } +`; + +const ResizeCornerBottomLeft = styled(ResizeCornerBase)` + bottom: -4px; + cursor: nesw-resize; + left: -4px; + + &::after { + border-bottom: 2px solid rgba(0, 0, 0, 0.18); + border-left: 2px solid rgba(0, 0, 0, 0.18); + bottom: 6px; + left: 6px; + } +`; + +const ResizeCornerBottomRight = styled(ResizeCornerBase)` + bottom: -4px; + cursor: nwse-resize; + right: -4px; + + &::after { + border-bottom: 2px solid rgba(0, 0, 0, 0.18); + border-right: 2px solid rgba(0, 0, 0, 0.18); + bottom: 6px; + right: 6px; + } +`; + +const ResizeEdgeBase = styled.div` + position: absolute; + z-index: 4; +`; + +const ResizeEdgeTop = styled(ResizeEdgeBase)` + cursor: ns-resize; + height: 6px; + left: 12px; + right: 12px; + top: -3px; +`; + +const ResizeEdgeBottom = styled(ResizeEdgeBase)` + bottom: -3px; + cursor: ns-resize; + height: 6px; + left: 12px; + right: 12px; +`; + +const ResizeEdgeLeft = styled(ResizeEdgeBase)` + bottom: 12px; + cursor: ew-resize; + left: -3px; + top: 12px; + width: 6px; +`; + +const ResizeEdgeRight = styled(ResizeEdgeBase)` + bottom: 12px; + cursor: ew-resize; + right: -3px; + top: 12px; + width: 6px; +`; + +type TerminalResizeHandlesProps = { + onStartResize: StartTerminalResize; +}; + +export const TerminalResizeHandles = ({ + onStartResize, +}: TerminalResizeHandlesProps) => ( + <> + + + + + + + + + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalSendButton.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalSendButton.tsx deleted file mode 100644 index f661fc8e4c..0000000000 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalSendButton.tsx +++ /dev/null @@ -1,284 +0,0 @@ -'use client'; - -import { IconArrowBackUp, IconArrowUp } from '@tabler/icons-react'; -import { styled } from '@linaria/react'; -import { useLayoutEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { TERMINAL_TOKENS } from './terminalTokens'; - -type TerminalSendButtonProps = { - onClick?: () => void; - disabled?: boolean; - mode?: 'send' | 'reset'; -}; - -const FINGER_OFFSET_RIGHT = -22; -const FINGER_OFFSET_BOTTOM = -18; -const FINGER_ROTATION = -21; -const FINGER_SIZE = 51; - -const SendButtonWrapper = styled.span` - display: inline-flex; - position: relative; -`; - -const SendButtonRoot = styled.button<{ $isReset: boolean }>` - align-items: center; - background: ${({ $isReset }) => - $isReset ? '#5a5a5a' : TERMINAL_TOKENS.accent.brand}; - border: none; - border-radius: 999px; - box-shadow: ${({ $isReset }) => - $isReset - ? 'none' - : '0 0 0 1px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.12)'}; - color: #ffffff; - cursor: pointer; - display: flex; - flex: 0 0 auto; - height: 32px; - justify-content: center; - padding: 0 4px; - transition: - background-color 0.14s ease, - transform 0.12s ease; - width: 32px; - - &:hover:not(:disabled) { - background: ${({ $isReset }) => - $isReset ? '#4c4c4c' : TERMINAL_TOKENS.accent.brandHover}; - } - - &:active:not(:disabled) { - transform: scale(0.94); - } - - &:disabled { - cursor: not-allowed; - opacity: 0.45; - } -`; - -const FingerHint = styled.span` - pointer-events: none; - position: fixed; - z-index: 20; -`; - -const FingerTapAnim = styled.span` - animation: fingerTap 1.4s ease-in-out infinite; - display: block; - - @keyframes fingerTap { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-6px); - } - } -`; - -const FingerIcon = ({ size }: { size: number }) => ( - - - - - - - - - - - - - - - - - - - -); - -export const TerminalSendButton = ({ - onClick, - disabled, - mode = 'send', -}: TerminalSendButtonProps) => { - const isReset = mode === 'reset'; - const [hintDismissed, setHintDismissed] = useState(false); - const [hintPos, setHintPos] = useState<{ left: number; top: number } | null>( - null, - ); - const [hintReady, setHintReady] = useState(false); - const buttonRef = useRef(null); - const dismissHint = () => setHintDismissed(true); - const showHint = !hintDismissed && !isReset && !disabled; - - useLayoutEffect(() => { - if (!showHint) { - setHintPos(null); - setHintReady(false); - return; - } - let rafId = 0; - let lastLeft = Number.NaN; - let lastTop = Number.NaN; - const readyTimer = window.setTimeout(() => setHintReady(true), 400); - - const tick = () => { - rafId = 0; - const el = buttonRef.current; - if (el) { - const rect = el.getBoundingClientRect(); - if (rect.width > 0) { - const nextLeft = rect.right + FINGER_OFFSET_RIGHT; - const nextTop = rect.bottom + FINGER_OFFSET_BOTTOM; - if (nextLeft !== lastLeft || nextTop !== lastTop) { - lastLeft = nextLeft; - lastTop = nextTop; - setHintPos({ left: nextLeft, top: nextTop }); - } - } - } - if (!document.hidden) { - rafId = window.requestAnimationFrame(tick); - } - }; - - const start = () => { - if (rafId === 0 && !document.hidden) { - rafId = window.requestAnimationFrame(tick); - } - }; - const handleVisibility = () => { - if (document.hidden) { - if (rafId !== 0) { - window.cancelAnimationFrame(rafId); - rafId = 0; - } - } else { - start(); - } - }; - start(); - document.addEventListener('visibilitychange', handleVisibility); - - const handleTerminalInteraction = (event: PointerEvent) => { - const btnEl = buttonRef.current; - if (!btnEl) return; - const shell = btnEl.closest('[class*="Shell"]'); - if ( - shell && - event.target instanceof Node && - shell.contains(event.target) - ) { - setHintDismissed(true); - } - }; - window.addEventListener('pointerdown', handleTerminalInteraction, true); - - return () => { - if (rafId !== 0) window.cancelAnimationFrame(rafId); - window.clearTimeout(readyTimer); - document.removeEventListener('visibilitychange', handleVisibility); - window.removeEventListener( - 'pointerdown', - handleTerminalInteraction, - true, - ); - }; - }, [showHint]); - - return ( - - { - dismissHint(); - onClick?.(); - }} - onMouseEnter={dismissHint} - ref={buttonRef} - type="button" - > - {isReset ? ( - - ) : ( - - )} - - {showHint && hintPos && typeof document !== 'undefined' - ? createPortal( - - - - - , - document.body, - ) - : null} - - ); -}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTopBar.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTopBar.tsx index 9edda71b8d..57b099781d 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTopBar.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTopBar.tsx @@ -7,7 +7,7 @@ import { DIFF_TOTALS } from './TerminalDiff/diffData'; import { EDITOR_TOKENS } from './TerminalEditor/editorTokens'; import { TERMINAL_TOKENS } from './terminalTokens'; import { TerminalToggle, type TerminalToggleValue } from './TerminalToggle'; -import { TerminalTrafficLights } from './TerminalTrafficLights'; +import { TerminalTrafficLights } from './TerminalTrafficLights/TerminalTrafficLights'; type TerminalTopBarProps = { onDragStart: (event: ReactPointerEvent) => void; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights.tsx deleted file mode 100644 index bfee4c524e..0000000000 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights.tsx +++ /dev/null @@ -1,483 +0,0 @@ -'use client'; - -import { styled } from '@linaria/react'; -import { - useEffect, - useRef, - useState, - type MouseEvent as ReactMouseEvent, -} from 'react'; -import { createPortal } from 'react-dom'; -import { theme } from '@/theme'; -import { TERMINAL_TOKENS } from './terminalTokens'; - -const TRAFFIC_LIGHT_DOT_SIZE = 12; -const TRAFFIC_LIGHT_GAP = 8; -const DEFAULT_HORIZONTAL_INSET = 6; - -export const TRAFFIC_LIGHTS_ESCAPE_EVENT = 'twenty-traffic-lights-escape'; - -const GRAVITY = 0.55; -const BOUNCE_DAMPING = 0.4; -const AIR_FRICTION = 0.915; -const GROUND_FRICTION = 0.32; -const REST_VELOCITY = 0.9; -const FLOOR_PADDING = 0; -const INITIAL_POP_MIN = -5; -const INITIAL_POP_MAX = -8; -const INITIAL_HORIZONTAL_RANGE = 3; -const INITIAL_SPIN = 4; -const SCROLL_IMPULSE_MIN = 7; -const SCROLL_IMPULSE_MAX = 12; -const SCROLL_HORIZONTAL_RANGE = 1.5; - -type PhysicsState = { - x: number; - y: number; - vx: number; - vy: number; - rotation: number; - angularVelocity: number; - isResting: boolean; -}; - -const TrafficLightsContainer = styled.div<{ $horizontalInset: number }>` - align-items: center; - display: flex; - gap: ${TRAFFIC_LIGHT_GAP}px; - padding: 0 ${({ $horizontalInset }) => `${$horizontalInset}px`}; -`; - -const TrafficLightDot = styled.button<{ - $background: string; - $backgroundActive: string; -}>` - align-items: center; - background: ${({ $background }) => $background}; - border: none; - border-radius: 999px; - cursor: pointer; - display: flex; - flex: 0 0 auto; - height: ${TRAFFIC_LIGHT_DOT_SIZE}px; - justify-content: center; - padding: 0; - position: relative; - transition: - background-color 0.12s ease, - transform 0.12s ease; - width: ${TRAFFIC_LIGHT_DOT_SIZE}px; - - &::after { - border-radius: 999px; - box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.12); - content: ''; - inset: 0; - pointer-events: none; - position: absolute; - } - - svg { - opacity: 0; - transition: opacity 0.12s ease; - } - - &:hover { - background: ${({ $backgroundActive }) => $backgroundActive}; - - svg { - opacity: 1; - } - } - - &:active { - transform: scale(0.92); - } - - /* While escaping, the originals stay in layout but are invisible — the - visible bouncing dots are portaled copies on document.body so they're - positioned relative to the viewport (not any transformed ancestor). */ - &[data-escaping='true'] { - pointer-events: none; - visibility: hidden; - } -`; - -const FlyingDotContainer = styled.button` - background: transparent; - border: none; - cursor: default; - display: block; - height: ${TRAFFIC_LIGHT_DOT_SIZE}px; - left: 0; - padding: 0; - pointer-events: none; - position: fixed; - top: 0; - width: ${TRAFFIC_LIGHT_DOT_SIZE}px; - z-index: ${theme.zIndex.portalTop}; - - /* Catchable once the physics loop flags the dot as resting. */ - &[data-resting='true'] { - cursor: pointer; - pointer-events: auto; - } -`; - -const FlyingDotBall = styled.span<{ - $background: string; - $backgroundActive: string; -}>` - align-items: center; - background: ${({ $background }) => $background}; - border-radius: 999px; - box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.12); - display: flex; - height: 100%; - justify-content: center; - transform-origin: center; - transition: background-color 0.12s ease; - width: 100%; - - svg { - opacity: 0; - transition: opacity 0.12s ease; - } - - /* Mirror the resting dot's hover treatment from the top bar so it feels - like the real Mac traffic lights even after they've fallen. */ - [data-resting='true']:hover > & { - background: ${({ $backgroundActive }) => $backgroundActive}; - } - - [data-resting='true']:hover > & svg { - opacity: 1; - } - - &[data-returning='true'] { - animation: dotPop 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; - } - - @keyframes dotPop { - 0% { - opacity: 1; - transform: scale(1); - } - 40% { - opacity: 1; - transform: scale(1.55); - } - 100% { - opacity: 0; - transform: scale(0); - } - } -`; - -const CloseGlyph = () => ( - - - -); - -const MinimizeGlyph = () => ( - - - -); - -const ZoomGlyph = () => ( - - - -); - -type TerminalTrafficLightsProps = { - horizontalInset?: number; - onZoomTripleClick?: () => void; -}; - -const DOT_DEFINITIONS = [ - { - background: TERMINAL_TOKENS.trafficLight.close, - backgroundActive: TERMINAL_TOKENS.trafficLight.closeActive, - Glyph: CloseGlyph, - }, - { - background: TERMINAL_TOKENS.trafficLight.minimize, - backgroundActive: TERMINAL_TOKENS.trafficLight.minimizeActive, - Glyph: MinimizeGlyph, - }, - { - background: TERMINAL_TOKENS.trafficLight.zoom, - backgroundActive: TERMINAL_TOKENS.trafficLight.zoomActive, - Glyph: ZoomGlyph, - }, -]; - -export const TerminalTrafficLights = ({ - horizontalInset = DEFAULT_HORIZONTAL_INSET, - onZoomTripleClick, -}: TerminalTrafficLightsProps) => { - const [isEscaping, setIsEscaping] = useState(false); - const [returningDots, setReturningDots] = useState([ - false, - false, - false, - ]); - const [returnedDots, setReturnedDots] = useState([ - false, - false, - false, - ]); - const [portalReady, setPortalReady] = useState(false); - const originalRefs = useRef>([ - null, - null, - null, - ]); - const flyingRefs = useRef>([ - null, - null, - null, - ]); - const physicsRef = useRef([]); - - useEffect(() => { - setPortalReady(true); - }, []); - - const handleZoomClick = (event: ReactMouseEvent) => { - if (event.detail === 3) { - onZoomTripleClick?.(); - } - }; - - useEffect(() => { - const handleEscape = () => { - physicsRef.current = originalRefs.current.map((el) => { - const rect = el?.getBoundingClientRect(); - return { - x: rect?.left ?? 0, - y: rect?.top ?? 0, - vx: (Math.random() - 0.5) * 2 * INITIAL_HORIZONTAL_RANGE, - vy: - INITIAL_POP_MIN + - Math.random() * (INITIAL_POP_MAX - INITIAL_POP_MIN), - rotation: 0, - angularVelocity: (Math.random() - 0.5) * 2 * INITIAL_SPIN, - isResting: false, - }; - }); - flyingRefs.current.forEach((el) => { - if (el) el.removeAttribute('data-resting'); - }); - setReturningDots([false, false, false]); - setReturnedDots([false, false, false]); - setIsEscaping(true); - }; - window.addEventListener(TRAFFIC_LIGHTS_ESCAPE_EVENT, handleEscape); - return () => { - window.removeEventListener(TRAFFIC_LIGHTS_ESCAPE_EVENT, handleEscape); - }; - }, [isEscaping]); - - const handleCatchDot = (index: number) => { - setReturningDots((prev) => { - if (prev[index]) return prev; - const next = [...prev]; - next[index] = true; - return next; - }); - }; - - const handlePopAnimationEnd = (index: number) => { - setReturnedDots((prev) => { - if (prev[index]) return prev; - const next = [...prev]; - next[index] = true; - return next; - }); - }; - - useEffect(() => { - if (!isEscaping) return; - let rafId = 0; - - const tick = () => { - const floor = window.innerHeight - TRAFFIC_LIGHT_DOT_SIZE - FLOOR_PADDING; - const rightWall = window.innerWidth - TRAFFIC_LIGHT_DOT_SIZE; - physicsRef.current.forEach((p, i) => { - const el = flyingRefs.current[i]; - if (!el) return; - if (p.isResting) { - return; - } - - p.vy += GRAVITY; - p.vx *= AIR_FRICTION; - p.x += p.vx; - p.y += p.vy; - p.rotation += p.angularVelocity; - - if (p.y >= floor) { - p.y = floor; - p.vx *= GROUND_FRICTION; - if (Math.abs(p.vy) < REST_VELOCITY) { - p.vy = 0; - p.angularVelocity = 0; - if (Math.abs(p.vx) < 0.3) { - p.vx = 0; - if (!p.isResting) { - p.isResting = true; - el.setAttribute('data-resting', 'true'); - } - } - } else { - p.vy = -p.vy * BOUNCE_DAMPING; - p.angularVelocity *= 0.7; - } - } - - if (p.x < 0) { - p.x = 0; - p.vx = -p.vx * BOUNCE_DAMPING; - } else if (p.x > rightWall) { - p.x = rightWall; - p.vx = -p.vx * BOUNCE_DAMPING; - } - - el.style.transform = `translate(${p.x}px, ${p.y}px) rotate(${p.rotation}deg)`; - }); - const allSettled = physicsRef.current.every( - (p, i) => p.isResting || !flyingRefs.current[i], - ); - if (allSettled) { - rafId = 0; - return; - } - rafId = requestAnimationFrame(tick); - }; - rafId = requestAnimationFrame(tick); - - const handleScroll = () => { - let anyDisturbed = false; - physicsRef.current.forEach((p, i) => { - if (!flyingRefs.current[i]) return; - p.isResting = false; - flyingRefs.current[i]?.removeAttribute('data-resting'); - p.vy = -( - SCROLL_IMPULSE_MIN + - Math.random() * (SCROLL_IMPULSE_MAX - SCROLL_IMPULSE_MIN) - ); - p.vx += (Math.random() - 0.5) * 2 * SCROLL_HORIZONTAL_RANGE; - p.angularVelocity += (Math.random() - 0.5) * 8; - anyDisturbed = true; - }); - if (anyDisturbed && rafId === 0) { - rafId = requestAnimationFrame(tick); - } - }; - window.addEventListener('scroll', handleScroll, { passive: true }); - - return () => { - cancelAnimationFrame(rafId); - window.removeEventListener('scroll', handleScroll); - }; - }, [isEscaping]); - - const setOriginalRef = (index: number) => (el: HTMLButtonElement | null) => { - originalRefs.current[index] = el; - }; - - const setFlyingRef = (index: number) => (el: HTMLButtonElement | null) => { - flyingRefs.current[index] = el; - if (el) { - const p = physicsRef.current[index]; - if (p) { - el.style.transform = `translate(${p.x}px, ${p.y}px)`; - } - } - }; - - return ( - - - - - - - - - - - - {isEscaping && portalReady - ? createPortal( - <> - {DOT_DEFINITIONS.map( - ({ background, backgroundActive, Glyph }, index) => - returnedDots[index] ? null : ( - handleCatchDot(index)} - ref={setFlyingRef(index)} - type="button" - > - handlePopAnimationEnd(index)} - > - - - - ), - )} - , - document.body, - ) - : null} - - ); -}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalFlyingTrafficLights.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalFlyingTrafficLights.tsx new file mode 100644 index 0000000000..745c566641 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalFlyingTrafficLights.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { styled } from '@linaria/react'; +import type { ComponentType, RefCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { theme } from '@/theme'; +import { TRAFFIC_LIGHT_DOT_SIZE } from './terminal-traffic-light-constants'; +import { TRAFFIC_LIGHT_DOT_DEFINITIONS } from './terminal-traffic-light-definitions'; + +const FlyingDotContainer = styled.button` + background: transparent; + border: none; + cursor: default; + display: block; + height: ${TRAFFIC_LIGHT_DOT_SIZE}px; + left: 0; + padding: 0; + pointer-events: none; + position: fixed; + top: 0; + width: ${TRAFFIC_LIGHT_DOT_SIZE}px; + z-index: ${theme.zIndex.portalTop}; + + &[data-resting='true'] { + cursor: pointer; + pointer-events: auto; + } +`; + +const FlyingDotBall = styled.span<{ + $background: string; + $backgroundActive: string; +}>` + align-items: center; + background: ${({ $background }) => $background}; + border-radius: 999px; + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.12); + display: flex; + height: 100%; + justify-content: center; + transform-origin: center; + transition: background-color 0.12s ease; + width: 100%; + + svg { + opacity: 0; + transition: opacity 0.12s ease; + } + + [data-resting='true']:hover > & { + background: ${({ $backgroundActive }) => $backgroundActive}; + } + + [data-resting='true']:hover > & svg { + opacity: 1; + } + + &[data-returning='true'] { + animation: dotPop 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + } + + @keyframes dotPop { + 0% { + opacity: 1; + transform: scale(1); + } + 40% { + opacity: 1; + transform: scale(1.55); + } + 100% { + opacity: 0; + transform: scale(0); + } + } +`; + +type TerminalFlyingTrafficLightsProps = { + onCatchDot: (index: number) => void; + onPopAnimationEnd: (index: number) => void; + portalReady: boolean; + returningDots: boolean[]; + returnedDots: boolean[]; + setFlyingRef: (index: number) => RefCallback; + visible: boolean; +}; + +export const TerminalFlyingTrafficLights = ({ + onCatchDot, + onPopAnimationEnd, + portalReady, + returningDots, + returnedDots, + setFlyingRef, + visible, +}: TerminalFlyingTrafficLightsProps) => { + if (!visible || !portalReady || typeof document === 'undefined') { + return null; + } + + return createPortal( + <> + {TRAFFIC_LIGHT_DOT_DEFINITIONS.map( + ({ background, backgroundActive, Glyph, label }, index) => + returnedDots[index] ? null : ( + onCatchDot(index)} + ref={setFlyingRef(index)} + type="button" + > + onPopAnimationEnd(index)} + /> + + ), + )} + , + document.body, + ); +}; + +const FlyingDot = ({ + background, + backgroundActive, + Glyph, + onAnimationEnd, + returning, +}: { + background: string; + backgroundActive: string; + Glyph: ComponentType; + onAnimationEnd: () => void; + returning: boolean; +}) => ( + + + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLightDot.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLightDot.tsx new file mode 100644 index 0000000000..ce925d117a --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLightDot.tsx @@ -0,0 +1,91 @@ +import { styled } from '@linaria/react'; +import type { + MouseEvent as ReactMouseEvent, + RefCallback, + ComponentType, +} from 'react'; +import { TRAFFIC_LIGHT_DOT_SIZE } from './terminal-traffic-light-constants'; + +const DotButton = styled.button<{ + $background: string; + $backgroundActive: string; +}>` + align-items: center; + background: ${({ $background }) => $background}; + border: none; + border-radius: 999px; + cursor: pointer; + display: flex; + flex: 0 0 auto; + height: ${TRAFFIC_LIGHT_DOT_SIZE}px; + justify-content: center; + padding: 0; + position: relative; + transition: + background-color 0.12s ease, + transform 0.12s ease; + width: ${TRAFFIC_LIGHT_DOT_SIZE}px; + + &::after { + border-radius: 999px; + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.12); + content: ''; + inset: 0; + pointer-events: none; + position: absolute; + } + + svg { + opacity: 0; + transition: opacity 0.12s ease; + } + + &:hover { + background: ${({ $backgroundActive }) => $backgroundActive}; + + svg { + opacity: 1; + } + } + + &:active { + transform: scale(0.92); + } + + &[data-escaping='true'] { + pointer-events: none; + visibility: hidden; + } +`; + +type TerminalTrafficLightDotProps = { + background: string; + backgroundActive: string; + escaping: boolean; + Glyph: ComponentType; + label: string; + onClick?: (event: ReactMouseEvent) => void; + refCallback: RefCallback; +}; + +export const TerminalTrafficLightDot = ({ + background, + backgroundActive, + escaping, + Glyph, + label, + onClick, + refCallback, +}: TerminalTrafficLightDotProps) => ( + + + +); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLights.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLights.tsx new file mode 100644 index 0000000000..7ce5a6c61a --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLights.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { styled } from '@linaria/react'; +import type { MouseEvent as ReactMouseEvent } from 'react'; +import { + DEFAULT_TRAFFIC_LIGHT_HORIZONTAL_INSET, + TRAFFIC_LIGHT_GAP, +} from './terminal-traffic-light-constants'; +import { TRAFFIC_LIGHT_DOT_DEFINITIONS } from './terminal-traffic-light-definitions'; +import { TerminalFlyingTrafficLights } from './TerminalFlyingTrafficLights'; +import { TerminalTrafficLightDot } from './TerminalTrafficLightDot'; +import { useTerminalTrafficLightsEscape } from './use-terminal-traffic-lights-escape'; + +const TrafficLightsContainer = styled.div<{ $horizontalInset: number }>` + align-items: center; + display: flex; + gap: ${TRAFFIC_LIGHT_GAP}px; + padding: 0 ${({ $horizontalInset }) => `${$horizontalInset}px`}; +`; + +type TerminalTrafficLightsProps = { + horizontalInset?: number; + onZoomTripleClick?: () => void; +}; + +export const TerminalTrafficLights = ({ + horizontalInset = DEFAULT_TRAFFIC_LIGHT_HORIZONTAL_INSET, + onZoomTripleClick, +}: TerminalTrafficLightsProps) => { + const { + handleCatchDot, + handlePopAnimationEnd, + isEscaping, + portalReady, + returnedDots, + returningDots, + setFlyingRef, + setOriginalRef, + } = useTerminalTrafficLightsEscape(); + + const handleZoomClick = (event: ReactMouseEvent) => { + if (event.detail === 3) { + onZoomTripleClick?.(); + } + }; + + return ( + + {TRAFFIC_LIGHT_DOT_DEFINITIONS.map( + ({ background, backgroundActive, Glyph, label }, index) => ( + + ), + )} + + + ); +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-constants.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-constants.ts new file mode 100644 index 0000000000..b5a22678fc --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-constants.ts @@ -0,0 +1,8 @@ +export const TRAFFIC_LIGHT_DOT_SIZE = 12; +export const TRAFFIC_LIGHT_GAP = 8; +export const TRAFFIC_LIGHT_COUNT = 3; +export const DEFAULT_TRAFFIC_LIGHT_HORIZONTAL_INSET = 6; +export const TRAFFIC_LIGHTS_ESCAPE_EVENT = 'twenty-traffic-lights-escape'; + +export const createTrafficLightReturnState = (): boolean[] => + Array.from({ length: TRAFFIC_LIGHT_COUNT }, () => false); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-definitions.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-definitions.tsx new file mode 100644 index 0000000000..d7669b414a --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-definitions.tsx @@ -0,0 +1,53 @@ +import { TERMINAL_TOKENS } from '../terminalTokens'; + +const CloseGlyph = () => ( + + + +); + +const MinimizeGlyph = () => ( + + + +); + +const ZoomGlyph = () => ( + + + +); + +export const TRAFFIC_LIGHT_DOT_DEFINITIONS = [ + { + background: TERMINAL_TOKENS.trafficLight.close, + backgroundActive: TERMINAL_TOKENS.trafficLight.closeActive, + Glyph: CloseGlyph, + label: 'Close', + }, + { + background: TERMINAL_TOKENS.trafficLight.minimize, + backgroundActive: TERMINAL_TOKENS.trafficLight.minimizeActive, + Glyph: MinimizeGlyph, + label: 'Minimize', + }, + { + background: TERMINAL_TOKENS.trafficLight.zoom, + backgroundActive: TERMINAL_TOKENS.trafficLight.zoomActive, + Glyph: ZoomGlyph, + label: 'Zoom', + }, +] as const; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.test.ts new file mode 100644 index 0000000000..b4a519b113 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.test.ts @@ -0,0 +1,91 @@ +import { + createTrafficLightPhysicsState, + disturbTrafficLightPhysicsState, + stepTrafficLightPhysicsState, + type TrafficLightPhysicsState, +} from './terminal-traffic-light-physics'; + +const createRandomSequence = (values: number[]) => { + let index = 0; + + return () => values[index++] ?? values[values.length - 1] ?? 0.5; +}; + +describe('terminal traffic light physics', () => { + it('creates deterministic launch state from an element origin', () => { + expect( + createTrafficLightPhysicsState( + { left: 12, top: 24 }, + createRandomSequence([0.5, 0.5, 0.5]), + ), + ).toEqual({ + angularVelocity: 0, + isResting: false, + rotation: 0, + vx: 0, + vy: -6.5, + x: 12, + y: 24, + }); + }); + + it('marks a dot as resting when the floor impact is below the rest threshold', () => { + const state: TrafficLightPhysicsState = { + angularVelocity: 1, + isResting: false, + rotation: 0, + vx: 0.1, + vy: 0.1, + x: 0, + y: 100, + }; + + stepTrafficLightPhysicsState(state, { floor: 100, rightWall: 500 }); + + expect(state).toMatchObject({ + angularVelocity: 0, + isResting: true, + vx: 0, + vy: 0, + y: 100, + }); + }); + + it('clamps side-wall collisions and reflects horizontal velocity', () => { + const state: TrafficLightPhysicsState = { + angularVelocity: 0, + isResting: false, + rotation: 0, + vx: 10, + vy: -1, + x: 98, + y: 0, + }; + + stepTrafficLightPhysicsState(state, { floor: 500, rightWall: 100 }); + + expect(state.x).toBe(100); + expect(state.vx).toBeLessThan(0); + }); + + it('applies a scroll disturbance and clears resting state', () => { + const state: TrafficLightPhysicsState = { + angularVelocity: 0, + isResting: true, + rotation: 0, + vx: 1, + vy: 0, + x: 0, + y: 100, + }; + + disturbTrafficLightPhysicsState(state, createRandomSequence([0, 1, 0.5])); + + expect(state).toMatchObject({ + angularVelocity: 0, + isResting: false, + vx: 2.5, + vy: -7, + }); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.ts new file mode 100644 index 0000000000..188f6900d6 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.ts @@ -0,0 +1,103 @@ +const GRAVITY = 0.55; +const BOUNCE_DAMPING = 0.4; +const AIR_FRICTION = 0.915; +const GROUND_FRICTION = 0.32; +const REST_VELOCITY = 0.9; +const INITIAL_POP_MIN = -5; +const INITIAL_POP_MAX = -8; +const INITIAL_HORIZONTAL_RANGE = 3; +const INITIAL_SPIN = 4; +const SCROLL_IMPULSE_MIN = 7; +const SCROLL_IMPULSE_MAX = 12; +const SCROLL_HORIZONTAL_RANGE = 1.5; + +export type TrafficLightPhysicsState = { + x: number; + y: number; + vx: number; + vy: number; + rotation: number; + angularVelocity: number; + isResting: boolean; +}; + +type TrafficLightPhysicsOrigin = { + left: number; + top: number; +}; + +type TrafficLightPhysicsBounds = { + floor: number; + rightWall: number; +}; + +export const createTrafficLightPhysicsState = ( + origin: TrafficLightPhysicsOrigin | null | undefined, + random: () => number = Math.random, +): TrafficLightPhysicsState => ({ + x: origin?.left ?? 0, + y: origin?.top ?? 0, + vx: (random() - 0.5) * 2 * INITIAL_HORIZONTAL_RANGE, + vy: INITIAL_POP_MIN + random() * (INITIAL_POP_MAX - INITIAL_POP_MIN), + rotation: 0, + angularVelocity: (random() - 0.5) * 2 * INITIAL_SPIN, + isResting: false, +}); + +export const stepTrafficLightPhysicsState = ( + state: TrafficLightPhysicsState, + bounds: TrafficLightPhysicsBounds, +) => { + if (state.isResting) { + return state; + } + + state.vy += GRAVITY; + state.vx *= AIR_FRICTION; + state.x += state.vx; + state.y += state.vy; + state.rotation += state.angularVelocity; + + if (state.y >= bounds.floor) { + state.y = bounds.floor; + state.vx *= GROUND_FRICTION; + + if (Math.abs(state.vy) < REST_VELOCITY) { + state.vy = 0; + state.angularVelocity = 0; + + if (Math.abs(state.vx) < 0.3) { + state.vx = 0; + state.isResting = true; + } + } else { + state.vy = -state.vy * BOUNCE_DAMPING; + state.angularVelocity *= 0.7; + } + } + + if (state.x < 0) { + state.x = 0; + state.vx = -state.vx * BOUNCE_DAMPING; + } else if (state.x > bounds.rightWall) { + state.x = bounds.rightWall; + state.vx = -state.vx * BOUNCE_DAMPING; + } + + return state; +}; + +export const disturbTrafficLightPhysicsState = ( + state: TrafficLightPhysicsState, + random: () => number = Math.random, +) => { + state.isResting = false; + state.vy = -( + SCROLL_IMPULSE_MIN + + random() * (SCROLL_IMPULSE_MAX - SCROLL_IMPULSE_MIN) + ); + state.vx += (random() - 0.5) * 2 * SCROLL_HORIZONTAL_RANGE; + state.angularVelocity += (random() - 0.5) * 8; + + return state; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/use-terminal-traffic-lights-escape.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/use-terminal-traffic-lights-escape.ts new file mode 100644 index 0000000000..f144a87a3f --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/use-terminal-traffic-lights-escape.ts @@ -0,0 +1,188 @@ +import { useEffect, useRef, useState, type RefCallback } from 'react'; +import { createAnimationFrameLoop } from '@/lib/animation'; +import { + createTrafficLightPhysicsState, + disturbTrafficLightPhysicsState, + stepTrafficLightPhysicsState, + type TrafficLightPhysicsState, +} from './terminal-traffic-light-physics'; +import { + createTrafficLightReturnState, + TRAFFIC_LIGHT_DOT_SIZE, + TRAFFIC_LIGHTS_ESCAPE_EVENT, +} from './terminal-traffic-light-constants'; + +const FLOOR_PADDING = 0; + +export const useTerminalTrafficLightsEscape = () => { + const [isEscaping, setIsEscaping] = useState(false); + const [returningDots, setReturningDots] = useState( + createTrafficLightReturnState, + ); + const [returnedDots, setReturnedDots] = useState( + createTrafficLightReturnState, + ); + const [portalReady, setPortalReady] = useState(false); + const originalRefs = useRef>([ + null, + null, + null, + ]); + const flyingRefs = useRef>([ + null, + null, + null, + ]); + const physicsRef = useRef([]); + + useEffect(() => { + setPortalReady(true); + }, []); + + useEffect(() => { + const handleEscape = () => { + physicsRef.current = originalRefs.current.map((element) => { + const rect = element?.getBoundingClientRect(); + + return createTrafficLightPhysicsState(rect); + }); + + flyingRefs.current.forEach((element) => { + element?.removeAttribute('data-resting'); + }); + setReturningDots(createTrafficLightReturnState()); + setReturnedDots(createTrafficLightReturnState()); + setIsEscaping(true); + }; + + window.addEventListener(TRAFFIC_LIGHTS_ESCAPE_EVENT, handleEscape); + + return () => { + window.removeEventListener(TRAFFIC_LIGHTS_ESCAPE_EVENT, handleEscape); + }; + }, []); + + useEffect(() => { + if (!isEscaping) { + return; + } + + const physicsLoop = createAnimationFrameLoop({ + onFrame: () => { + const floor = + window.innerHeight - TRAFFIC_LIGHT_DOT_SIZE - FLOOR_PADDING; + const rightWall = window.innerWidth - TRAFFIC_LIGHT_DOT_SIZE; + + physicsRef.current.forEach((physicsState, index) => { + const element = flyingRefs.current[index]; + + if (!element) { + return; + } + + const wasResting = physicsState.isResting; + stepTrafficLightPhysicsState(physicsState, { floor, rightWall }); + + if (!wasResting && physicsState.isResting) { + element.setAttribute('data-resting', 'true'); + } + + element.style.transform = `translate(${physicsState.x}px, ${physicsState.y}px) rotate(${physicsState.rotation}deg)`; + }); + + return !physicsRef.current.every( + (physicsState, index) => + physicsState.isResting || !flyingRefs.current[index], + ); + }, + }); + + physicsLoop.start(); + + const handleScroll = () => { + let anyDisturbed = false; + + physicsRef.current.forEach((physicsState, index) => { + const element = flyingRefs.current[index]; + + if (!element) { + return; + } + + disturbTrafficLightPhysicsState(physicsState); + element.removeAttribute('data-resting'); + anyDisturbed = true; + }); + + if (anyDisturbed) { + physicsLoop.start(); + } + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + physicsLoop.stop(); + window.removeEventListener('scroll', handleScroll); + }; + }, [isEscaping]); + + const handleCatchDot = (index: number) => { + setReturningDots((previous) => { + if (previous[index]) { + return previous; + } + + const next = [...previous]; + next[index] = true; + + return next; + }); + }; + + const handlePopAnimationEnd = (index: number) => { + setReturnedDots((previous) => { + if (previous[index]) { + return previous; + } + + const next = [...previous]; + next[index] = true; + + return next; + }); + }; + + const setOriginalRef = + (index: number): RefCallback => + (element) => { + originalRefs.current[index] = element; + }; + + const setFlyingRef = + (index: number): RefCallback => + (element) => { + flyingRefs.current[index] = element; + + if (!element) { + return; + } + + const physicsState = physicsRef.current[index]; + + if (physicsState) { + element.style.transform = `translate(${physicsState.x}px, ${physicsState.y}px)`; + } + }; + + return { + handleCatchDot, + handlePopAnimationEnd, + isEscaping, + portalReady, + returnedDots, + returningDots, + setFlyingRef, + setOriginalRef, + }; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/AssistantResponse.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/AssistantResponse.tsx deleted file mode 100644 index 114cf0c016..0000000000 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/AssistantResponse.tsx +++ /dev/null @@ -1,501 +0,0 @@ -'use client'; - -import { styled } from '@linaria/react'; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from 'react'; -import { TERMINAL_TOKENS } from '../terminalTokens'; -import { CHAT_TIMINGS } from './animationTiming'; -import { ChangesSummaryCard } from './ChangesSummaryCard'; -import { StreamingText, type StreamingSegment } from './StreamingText'; -import { ThinkingIndicator } from './ThinkingIndicator'; - -const BETWEEN_PARAGRAPHS_MS = 320; -const AFTER_OBJECT_BEAT_MS = 520; -const BEFORE_CARD_MS = 420; -const AFTER_CARD_REVEAL_MS = 180; - -const ResponseRoot = styled.div` - display: flex; - flex-direction: column; - gap: 14px; - width: 100%; -`; - -const Paragraph = styled.p` - color: ${TERMINAL_TOKENS.text.prompt}; - font-family: ${TERMINAL_TOKENS.font.ui}; - font-size: 13px; - line-height: 20px; - margin: 0; -`; - -const InlineCode = styled.span` - background: rgba(0, 0, 0, 0.045); - border-radius: 3px; - color: rgba(0, 0, 0, 0.78); - font-family: ${TERMINAL_TOKENS.font.mono}; - font-size: 12px; - padding: 1px 5px; -`; - -const FileLink = styled.span` - color: #2a66de; - cursor: pointer; - font-family: ${TERMINAL_TOKENS.font.mono}; - font-size: 12px; - - &:hover { - text-decoration: underline; - } -`; - -const ReferenceLink = styled.a` - color: #2a66de; - text-decoration: underline; - text-underline-offset: 2px; - - &:hover { - color: #1e4ea8; - } -`; - -const CardWrap = styled.div<{ $instant: boolean }>` - animation: ${({ $instant }) => - $instant - ? 'none' - : 'chatCardRise 420ms cubic-bezier(0.22, 1, 0.36, 1) both'}; - - @keyframes chatCardRise { - from { - opacity: 0; - transform: translateY(6px); - } - to { - opacity: 1; - transform: translateY(0); - } - } -`; - -const text = (value: string, onReveal?: () => void): StreamingSegment => ({ - kind: 'text', - value, - onReveal, -}); -const node = ( - key: string, - value: ReactNode, - onReveal?: () => void, -): StreamingSegment => ({ - kind: 'node', - value: {value}, - onReveal, -}); - -const ROCKET_ID = 'rockets'; -const LAUNCH_ID = 'launches'; -const PAYLOAD_ID = 'payloads'; -const COMPANIES_ID = 'companies'; -const LAUNCH_SITE_ID = 'launch-sites'; - -const buildIntroAndRocketParagraph = ( - onObjectCreated?: (id: string) => void, -): StreamingSegment[] => [ - text( - "I'll scaffold a launch-ops CRM in your workspace: four new objects plus the standard ", - ), - node('rocket-companies', Companies), - text(' object for customers, with shared UUIDs in '), - node('rocket-ids', schema-identifiers.ts), - text('. First up: '), - node( - 'rocket-chip', - Rocket, - onObjectCreated ? () => onObjectCreated(ROCKET_ID) : undefined, - ), - text( - '. Each vehicle gets a serial number, manufacturer, lifecycle status, reusability, launch date, dimensions, and target orbit in ', - ), - node('rocket-file', rocket.object.ts), - text('.'), -]; - -const buildLaunchParagraph = ( - onObjectCreated?: (id: string) => void, -): StreamingSegment[] => [ - text('Next up: '), - node( - 'launch-chip', - Launch, - onObjectCreated ? () => onObjectCreated(LAUNCH_ID) : undefined, - ), - text( - '. Every mission gets a unique mission code, status, mission type, planned and actual launch times, and a summary. Defined in ', - ), - node('launch-file', launch.object.ts), - text('.'), -]; - -const buildPayloadParagraph = ( - onObjectCreated?: (id: string) => void, -): StreamingSegment[] => [ - text('Now '), - node( - 'payload-chip', - Payload, - onObjectCreated ? () => onObjectCreated(PAYLOAD_ID) : undefined, - ), - text( - '. This covers what actually flies: satellites, crew capsules, cargo, probes, and landers, with type, status, target orbit, mass, and a customer reference. Scoped in ', - ), - node('payload-file', payload.object.ts), - text('.'), -]; - -const buildCustomerParagraph = ( - onObjectCreated?: (id: string) => void, -): StreamingSegment[] => [ - text('For customers, there is no new object. I reuse the standard '), - node( - 'customer-chip', - Companies, - onObjectCreated ? () => onObjectCreated(COMPANIES_ID) : undefined, - ), - text( - ' object that ships with Twenty, so accounts, domain favicons, and the People relation work for free. ', - ), - node('customer-file', payload.object.ts), - text(' points its '), - node('customer-field', customer), - text(' relation straight at it.'), -]; - -const buildLaunchSiteParagraph = ( - onObjectCreated?: (id: string) => void, -): StreamingSegment[] => [ - text('Last object: '), - node( - 'launch-site-chip', - Launch site, - onObjectCreated ? () => onObjectCreated(LAUNCH_SITE_ID) : undefined, - ), - text( - '. This covers pads and ranges with a site code, country, region, pad name, and operational status. Lives in ', - ), - node('launch-site-file', launch-site.object.ts), - text('.'), -]; - -const PINNED_ACTIONS_PARAGRAPH: StreamingSegment[] = [ - text( - 'Each object also gets 2-3 relevant quick commands pinned to its header. Next to ', - ), - node('pa-new', New), - text(', '), - node('pa-rocket', Rocket), - text(' has reuse / retire shortcuts, '), - node('pa-launch', Launch), - text(' has '), - node('pa-l-resched', Reschedule), - text(' and '), - node('pa-l-payload', Add payload), - text(', '), - node('pa-payload', Payload), - text(' has '), - node('pa-p-book', Book slot), - text(', '), - node('pa-companies', Companies), - text(' has a quick '), - node('pa-c-status', Set status), - text(', and '), - node('pa-site', Launch site), - text(' has '), - node('pa-s-window', Book window), - text('. Defined under '), - node('pa-folder', src/command-menu-items/), - text('.'), -]; - -const WRAPUP_PARAGRAPH: StreamingSegment[] = [ - text('Relations wire '), - node('w-rl', Rocket → Launches), - text(', '), - node('w-sl', LaunchSite → Launches), - text(', '), - node('w-cp', Company → Payloads), - text(', and '), - node('w-lp', Launch → Payloads), - text('. Each object gets an index view and sidebar entry; '), - node('w-launches', Launches), - text(' also has '), - node('w-upcoming', upcoming-launches.view.ts), - text(' and '), - node('w-past', past-launches.view.ts), - text('. Verified with '), - node('w-lint', yarn lint), - text(', '), - node('w-tsc', tsc --noEmit), - text(', '), - node( - 'w-vitest', - vitest run schema.integration-test.ts, - ), - text(', and '), - node('w-dev', yarn twenty dev --once), - text('. Reference: '), - node( - 'w-docs', - event.preventDefault()} - > - Twenty app-building docs - , - ), - text('.'), -]; - -type Stage = - | 'thinking' - | 'rocket' - | 'launch' - | 'payload' - | 'customer' - | 'launchSite' - | 'actions' - | 'wrapup' - | 'card' - | 'done'; - -const STAGE_ORDER: Stage[] = [ - 'thinking', - 'rocket', - 'launch', - 'payload', - 'customer', - 'launchSite', - 'actions', - 'wrapup', - 'card', - 'done', -]; - -type AssistantResponseProps = { - instantComplete?: boolean; - onUndo?: () => void; - onObjectCreated?: (id: string) => void; - onChatFinished?: () => void; -}; - -export const AssistantResponse = ({ - instantComplete = false, - onUndo, - onObjectCreated, - onChatFinished, -}: AssistantResponseProps) => { - const [stage, setStage] = useState( - instantComplete ? 'done' : 'thinking', - ); - const hasNotifiedChatFinishedRef = useRef(false); - const advanceTimeoutsRef = useRef>(new Set()); - const objectCreationHandler = instantComplete ? undefined : onObjectCreated; - - useEffect(() => { - const timeouts = advanceTimeoutsRef.current; - return () => { - timeouts.forEach((id) => window.clearTimeout(id)); - timeouts.clear(); - }; - }, []); - - const rocketParagraph = useMemo( - () => buildIntroAndRocketParagraph(objectCreationHandler), - [objectCreationHandler], - ); - const launchParagraph = useMemo( - () => buildLaunchParagraph(objectCreationHandler), - [objectCreationHandler], - ); - const payloadParagraph = useMemo( - () => buildPayloadParagraph(objectCreationHandler), - [objectCreationHandler], - ); - const customerParagraph = useMemo( - () => buildCustomerParagraph(objectCreationHandler), - [objectCreationHandler], - ); - const launchSiteParagraph = useMemo( - () => buildLaunchSiteParagraph(objectCreationHandler), - [objectCreationHandler], - ); - - useEffect(() => { - if (instantComplete) { - return undefined; - } - const id = window.setTimeout( - () => setStage('rocket'), - CHAT_TIMINGS.thinkingMs, - ); - return () => window.clearTimeout(id); - }, [instantComplete]); - - useEffect(() => { - if (!instantComplete) { - return; - } - setStage('done'); - }, [instantComplete]); - - useEffect(() => { - if ( - (stage !== 'card' && stage !== 'done') || - hasNotifiedChatFinishedRef.current - ) { - return undefined; - } - hasNotifiedChatFinishedRef.current = true; - const id = window.setTimeout( - () => { - onChatFinished?.(); - }, - stage === 'done' ? 0 : AFTER_CARD_REVEAL_MS, - ); - return () => window.clearTimeout(id); - }, [stage, onChatFinished]); - - const advanceTo = useCallback( - (next: Stage, delayMs: number) => () => { - const id = window.setTimeout(() => { - advanceTimeoutsRef.current.delete(id); - setStage(next); - }, delayMs); - advanceTimeoutsRef.current.add(id); - }, - [], - ); - - const has = (target: Stage): boolean => - STAGE_ORDER.indexOf(stage) >= STAGE_ORDER.indexOf(target); - - return ( - - {stage === 'thinking' && } - - {has('rocket') && ( - - - - )} - - {has('launch') && ( - - - - )} - - {has('payload') && ( - - - - )} - - {has('customer') && ( - - - - )} - - {has('launchSite') && ( - - - - )} - - {has('actions') && ( - - - - )} - - {has('wrapup') && ( - - - - )} - - {has('card') && ( - - - - )} - - ); -}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/StreamingText.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/StreamingText.tsx deleted file mode 100644 index ec60def4c3..0000000000 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/StreamingText.tsx +++ /dev/null @@ -1,149 +0,0 @@ -'use client'; - -import { styled } from '@linaria/react'; -import { useEffect, useRef, useState, type ReactNode } from 'react'; - -type StreamingSegment = - | { kind: 'text'; value: string; onReveal?: () => void } - | { - kind: 'node'; - value: ReactNode; - length?: number; - onReveal?: () => void; - }; - -type StreamingTextProps = { - segments: ReadonlyArray; - charDurationMs?: number; - instant?: boolean; - onComplete?: () => void; -}; - -const StreamWrap = styled.span` - display: inline; -`; - -const Caret = styled.span` - animation: chatCaretBlink 1s steps(1, end) infinite; - background: currentColor; - display: inline-block; - height: 1em; - margin-left: 2px; - opacity: 0.55; - vertical-align: text-bottom; - width: 1.5px; - - @keyframes chatCaretBlink { - 0%, - 50% { - opacity: 0.55; - } - 51%, - 100% { - opacity: 0; - } - } -`; - -export const StreamingText = ({ - segments, - charDurationMs = 14, - instant = false, - onComplete, -}: StreamingTextProps) => { - const totalLength = segments.reduce( - (acc, segment) => - acc + - (segment.kind === 'text' ? segment.value.length : (segment.length ?? 1)), - 0, - ); - - const [revealed, setRevealed] = useState(0); - const onCompleteRef = useRef(onComplete); - const completedRef = useRef(false); - const firedSegmentCountRef = useRef(0); - - onCompleteRef.current = onComplete; - - useEffect(() => { - setRevealed(0); - completedRef.current = false; - firedSegmentCountRef.current = 0; - }, [segments]); - - useEffect(() => { - if (!instant) { - return; - } - setRevealed(totalLength); - }, [instant, totalLength]); - - useEffect(() => { - if (revealed >= totalLength) { - if (!completedRef.current) { - completedRef.current = true; - onCompleteRef.current?.(); - } - return undefined; - } - if (instant) { - return undefined; - } - const id = window.setTimeout(() => { - setRevealed((previous) => Math.min(previous + 1, totalLength)); - }, charDurationMs); - return () => window.clearTimeout(id); - }, [charDurationMs, instant, revealed, totalLength]); - - useEffect(() => { - let offset = 0; - for (let index = 0; index < segments.length; index += 1) { - const segment = segments[index]; - const cost = - segment.kind === 'text' ? segment.value.length : (segment.length ?? 1); - offset += cost; - if (revealed < offset) { - break; - } - if (index < firedSegmentCountRef.current) { - continue; - } - segment.onReveal?.(); - firedSegmentCountRef.current = index + 1; - } - }, [revealed, segments]); - - const rendered: ReactNode[] = []; - let remaining = revealed; - for (let index = 0; index < segments.length; index += 1) { - const segment = segments[index]; - if (remaining <= 0) { - break; - } - if (segment.kind === 'text') { - const take = Math.min(segment.value.length, remaining); - rendered.push( - {segment.value.slice(0, take)}, - ); - remaining -= take; - } else { - const cost = segment.length ?? 1; - if (remaining < cost) { - break; - } - rendered.push({segment.value}); - remaining -= cost; - } - } - - const isComplete = revealed >= totalLength; - - return ( - - {rendered} - {!isComplete && } - - ); -}; - -export type { StreamingSegment }; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-copy.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-copy.ts new file mode 100644 index 0000000000..c2451cb127 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-copy.ts @@ -0,0 +1,4 @@ +export const INITIAL_PROMPT_TEXT = + 'Scaffold a launch-ops CRM in my workspace with rockets, launches, payloads, customers, and launch sites, with relevant actions for each.'; + +export const CLEARED_PROMPT_TEXT = 'Ask anything…'; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.test.ts new file mode 100644 index 0000000000..e9e1aaa23c --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.test.ts @@ -0,0 +1,68 @@ +import { + INITIAL_TERMINAL_CONVERSATION_STATE, + terminalConversationReducer, +} from './terminal-conversation-state'; + +describe('terminalConversationReducer', () => { + it('creates one user message and one assistant message when sending a prompt', () => { + expect( + terminalConversationReducer(INITIAL_TERMINAL_CONVERSATION_STATE, { + type: 'send-prompt', + sentAt: 123, + }), + ).toMatchObject({ + instantComplete: false, + messages: [ + { id: 'u-123', role: 'user' }, + { id: 'a-123', role: 'assistant' }, + ], + }); + }); + + it('does not send a second prompt once the conversation has started', () => { + const started = terminalConversationReducer( + INITIAL_TERMINAL_CONVERSATION_STATE, + { type: 'send-prompt', sentAt: 123 }, + ); + + expect( + terminalConversationReducer(started, { + type: 'send-prompt', + sentAt: 456, + }), + ).toBe(started); + }); + + it('resets conversation state to the initial values', () => { + const started = terminalConversationReducer( + INITIAL_TERMINAL_CONVERSATION_STATE, + { type: 'jump-to-end', sentAt: 123 }, + ); + + expect(terminalConversationReducer(started, { type: 'reset' })).toBe( + INITIAL_TERMINAL_CONVERSATION_STATE, + ); + }); + + it('jumps to the completed chat state and closes the diff panel', () => { + expect( + terminalConversationReducer( + { + ...INITIAL_TERMINAL_CONVERSATION_STATE, + isDiffOpen: true, + view: 'editor', + }, + { type: 'jump-to-end', sentAt: 123 }, + ), + ).toMatchObject({ + instantComplete: true, + isChatFinished: true, + isDiffOpen: false, + messages: [ + { id: 'u-123', role: 'user' }, + { id: 'a-123', role: 'assistant' }, + ], + view: 'ai-chat', + }); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.ts new file mode 100644 index 0000000000..4fa4b1febe --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.ts @@ -0,0 +1,90 @@ +import type { ConversationMessage } from './Conversation/ConversationPanel'; +import type { TerminalToggleValue } from './TerminalToggle'; +import { INITIAL_PROMPT_TEXT } from './terminal-conversation-copy'; + +export type TerminalConversationState = { + instantComplete: boolean; + isChatFinished: boolean; + isDiffOpen: boolean; + messages: ConversationMessage[]; + view: TerminalToggleValue; +}; + +type TerminalConversationAction = + | { sentAt: number; type: 'send-prompt' } + | { type: 'change-view'; view: TerminalToggleValue } + | { type: 'reset' } + | { type: 'toggle-diff' } + | { type: 'finish-chat' } + | { sentAt: number; type: 'jump-to-end' }; + +export const INITIAL_TERMINAL_CONVERSATION_STATE: TerminalConversationState = { + instantComplete: false, + isChatFinished: false, + isDiffOpen: false, + messages: [], + view: 'ai-chat', +}; + +const createPromptMessages = (sentAt: number): ConversationMessage[] => [ + { id: `u-${sentAt}`, role: 'user', text: INITIAL_PROMPT_TEXT }, + { id: `a-${sentAt}`, role: 'assistant' }, +]; + +export const terminalConversationReducer = ( + state: TerminalConversationState, + action: TerminalConversationAction, +): TerminalConversationState => { + switch (action.type) { + case 'send-prompt': { + if (state.messages.length > 0) { + return state; + } + + return { + ...state, + instantComplete: false, + messages: createPromptMessages(action.sentAt), + }; + } + + case 'change-view': { + return { + ...state, + view: action.view, + }; + } + + case 'reset': { + return INITIAL_TERMINAL_CONVERSATION_STATE; + } + + case 'toggle-diff': { + return { + ...state, + isDiffOpen: !state.isDiffOpen, + }; + } + + case 'finish-chat': { + return { + ...state, + isChatFinished: true, + }; + } + + case 'jump-to-end': { + return { + ...state, + instantComplete: true, + isChatFinished: true, + isDiffOpen: false, + messages: + state.messages.length > 0 + ? state.messages + : createPromptMessages(action.sentAt), + view: 'ai-chat', + }; + } + } +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.test.ts new file mode 100644 index 0000000000..90c946f0f4 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.test.ts @@ -0,0 +1,96 @@ +import { + getAnchoredTerminalResizeLayout, + getInitialTerminalLayout, + getTerminalTargetSize, + isTerminalMobileBounds, +} from './terminal-window-geometry'; + +describe('terminal-window-geometry', () => { + it('detects mobile terminal bounds from parent width', () => { + expect(isTerminalMobileBounds({ width: 639, height: 500 })).toBe(true); + expect(isTerminalMobileBounds({ width: 640, height: 500 })).toBe(false); + expect(isTerminalMobileBounds(null)).toBe(false); + }); + + it('places the initial terminal at the desktop bottom-right anchor', () => { + expect(getInitialTerminalLayout({ width: 1000, height: 700 })).toEqual({ + position: { left: 620, top: 384 }, + size: { width: 380, height: 220 }, + }); + }); + + it('places the initial terminal with mobile offsets and clamped size', () => { + expect(getInitialTerminalLayout({ width: 360, height: 300 })).toEqual({ + position: { left: 16, top: 48 }, + size: { width: 344, height: 220 }, + }); + }); + + it('returns chat and editor target sizes clamped to parent bounds', () => { + expect( + getTerminalTargetSize({ + bounds: { width: 1000, height: 700 }, + chatStarted: false, + view: 'ai-chat', + }), + ).toEqual({ width: 380, height: 220 }); + + expect( + getTerminalTargetSize({ + bounds: { width: 1000, height: 700 }, + chatStarted: true, + view: 'ai-chat', + }), + ).toEqual({ width: 380, height: 480 }); + + expect( + getTerminalTargetSize({ + bounds: { width: 500, height: 420 }, + chatStarted: true, + view: 'editor', + }), + ).toEqual({ width: 484, height: 372 }); + }); + + it('preserves right and bottom anchors when resizing near the lower-right quadrant', () => { + expect( + getAnchoredTerminalResizeLayout({ + bounds: { width: 1000, height: 700 }, + currentPosition: { left: 620, top: 384 }, + currentSize: { width: 380, height: 220 }, + targetSize: { width: 720, height: 480 }, + }), + ).toEqual({ + position: { left: 280, top: 124 }, + size: { width: 720, height: 480 }, + }); + }); + + it('preserves left and top anchors when resizing near the upper-left quadrant', () => { + expect( + getAnchoredTerminalResizeLayout({ + bounds: { width: 1000, height: 700 }, + currentPosition: { left: 16, top: 48 }, + currentSize: { width: 344, height: 220 }, + targetSize: { width: 380, height: 480 }, + }), + ).toEqual({ + position: { left: 16, top: 48 }, + size: { width: 380, height: 480 }, + }); + }); + + it('updates size without inventing a position before initial layout is ready', () => { + expect( + getAnchoredTerminalResizeLayout({ + bounds: { width: 1000, height: 700 }, + currentPosition: null, + currentSize: { width: 380, height: 220 }, + targetSize: { width: 380, height: 480 }, + }), + ).toEqual({ + position: null, + size: { width: 380, height: 480 }, + }); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.ts new file mode 100644 index 0000000000..6513ca8da4 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.ts @@ -0,0 +1,170 @@ +import type { + WindowBounds, + WindowPosition, + WindowSize, +} from '../WindowInteraction/window-geometry'; +import type { TerminalToggleValue } from './TerminalToggle'; + +export const TERMINAL_INITIAL_WIDTH = 380; +export const TERMINAL_INITIAL_HEIGHT = 220; +export const TERMINAL_CHAT_EXPANDED_HEIGHT = 480; +export const TERMINAL_EDITOR_WIDTH = 720; +export const TERMINAL_EDITOR_HEIGHT = 480; +export const TERMINAL_MIN_WIDTH = 300; +export const TERMINAL_MIN_HEIGHT = 200; +export const TERMINAL_INITIAL_BOTTOM_OFFSET = 96; +export const TERMINAL_EDGE_GAP = 0; +export const TERMINAL_MOBILE_PARENT_BREAKPOINT = 640; +export const TERMINAL_MOBILE_OFFSET_X = 16; +export const TERMINAL_MOBILE_OFFSET_Y = 48; + +type TargetTerminalSizeInput = { + bounds: WindowBounds | null; + chatStarted: boolean; + view: TerminalToggleValue; +}; + +type AnchoredResizeInput = { + bounds: WindowBounds | null; + currentPosition: WindowPosition | null; + currentSize: WindowSize; + targetSize: WindowSize; +}; + +export const isTerminalMobileBounds = (bounds: WindowBounds | null) => + bounds !== null && bounds.width < TERMINAL_MOBILE_PARENT_BREAKPOINT; + +const getTerminalAvailableSize = (bounds: WindowBounds | null): WindowSize => { + if (!bounds) { + return { + width: Infinity, + height: Infinity, + }; + } + + const isMobile = isTerminalMobileBounds(bounds); + + return { + width: Math.max( + bounds.width - (isMobile ? TERMINAL_MOBILE_OFFSET_X : 0), + 0, + ), + height: Math.max( + bounds.height - (isMobile ? TERMINAL_MOBILE_OFFSET_Y : 0), + 0, + ), + }; +}; + +export const getTerminalTargetSize = ({ + bounds, + chatStarted, + view, +}: TargetTerminalSizeInput): WindowSize => { + const availableSize = getTerminalAvailableSize(bounds); + + if (view === 'editor') { + return { + width: Math.min(TERMINAL_EDITOR_WIDTH, availableSize.width), + height: Math.min(TERMINAL_EDITOR_HEIGHT, availableSize.height), + }; + } + + return { + width: Math.min(TERMINAL_INITIAL_WIDTH, availableSize.width), + height: Math.min( + chatStarted ? TERMINAL_CHAT_EXPANDED_HEIGHT : TERMINAL_INITIAL_HEIGHT, + availableSize.height, + ), + }; +}; + +export const getInitialTerminalLayout = ( + bounds: WindowBounds, +): { + position: WindowPosition; + size: WindowSize; +} => { + if (isTerminalMobileBounds(bounds)) { + const size = { + width: Math.min( + TERMINAL_INITIAL_WIDTH, + Math.max(bounds.width - TERMINAL_MOBILE_OFFSET_X, 0), + ), + height: Math.min( + TERMINAL_INITIAL_HEIGHT, + Math.max(bounds.height - TERMINAL_MOBILE_OFFSET_Y, 0), + ), + }; + + return { + position: { + left: TERMINAL_MOBILE_OFFSET_X, + top: TERMINAL_MOBILE_OFFSET_Y, + }, + size, + }; + } + + const size = { + width: Math.min(TERMINAL_INITIAL_WIDTH, bounds.width), + height: Math.min(TERMINAL_INITIAL_HEIGHT, bounds.height), + }; + + return { + position: { + left: Math.max(0, bounds.width - size.width), + top: Math.max( + 0, + bounds.height - size.height - TERMINAL_INITIAL_BOTTOM_OFFSET, + ), + }, + size, + }; +}; + +export const getAnchoredTerminalResizeLayout = ({ + bounds, + currentPosition, + currentSize, + targetSize, +}: AnchoredResizeInput): { + position: WindowPosition | null; + size: WindowSize; +} => { + if ( + currentSize.height === targetSize.height && + currentSize.width === targetSize.width + ) { + return { + position: currentPosition, + size: currentSize, + }; + } + + if (!currentPosition) { + return { + position: currentPosition, + size: targetSize, + }; + } + + const parentWidth = bounds?.width ?? currentSize.width; + const parentHeight = bounds?.height ?? currentSize.height; + const centerX = currentPosition.left + currentSize.width / 2; + const centerY = currentPosition.top + currentSize.height / 2; + const anchorRight = centerX > parentWidth / 2; + const anchorBottom = centerY > parentHeight / 2; + + return { + position: { + left: anchorRight + ? currentPosition.left + currentSize.width - targetSize.width + : currentPosition.left, + top: anchorBottom + ? currentPosition.top + currentSize.height - targetSize.height + : currentPosition.top, + }, + size: targetSize, + }; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-conversation-workflow.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-conversation-workflow.ts new file mode 100644 index 0000000000..e4984b92cb --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-conversation-workflow.ts @@ -0,0 +1,80 @@ +import { useCallback, useReducer, useRef } from 'react'; + +import type { TerminalToggleValue } from './TerminalToggle'; +import { + INITIAL_TERMINAL_CONVERSATION_STATE, + terminalConversationReducer, +} from './terminal-conversation-state'; + +type UseTerminalConversationWorkflowOptions = { + onChatFinished?: () => void; + onChatReset?: () => void; + onJumpToConversationEnd?: () => void; +}; + +export const useTerminalConversationWorkflow = ({ + onChatFinished, + onChatReset, + onJumpToConversationEnd, +}: UseTerminalConversationWorkflowOptions) => { + const [state, dispatch] = useReducer( + terminalConversationReducer, + INITIAL_TERMINAL_CONVERSATION_STATE, + ); + const hasAnnouncedChatFinishedRef = useRef(false); + const hasStartedConversation = state.messages.length > 0; + + const announceChatFinishedOnce = useCallback(() => { + if (hasAnnouncedChatFinishedRef.current) { + return; + } + + hasAnnouncedChatFinishedRef.current = true; + onChatFinished?.(); + }, [onChatFinished]); + + const sendPrompt = useCallback((): boolean => { + if (state.messages.length > 0) { + return false; + } + + dispatch({ type: 'send-prompt', sentAt: Date.now() }); + return true; + }, [state.messages.length]); + + const changeView = useCallback((view: TerminalToggleValue) => { + dispatch({ type: 'change-view', view }); + }, []); + + const resetConversation = useCallback(() => { + hasAnnouncedChatFinishedRef.current = false; + dispatch({ type: 'reset' }); + onChatReset?.(); + }, [onChatReset]); + + const toggleDiff = useCallback(() => { + dispatch({ type: 'toggle-diff' }); + }, []); + + const finishChat = useCallback(() => { + dispatch({ type: 'finish-chat' }); + announceChatFinishedOnce(); + }, [announceChatFinishedOnce]); + + const jumpToConversationEnd = useCallback(() => { + dispatch({ type: 'jump-to-end', sentAt: Date.now() }); + onJumpToConversationEnd?.(); + announceChatFinishedOnce(); + }, [announceChatFinishedOnce, onJumpToConversationEnd]); + + return { + ...state, + changeView, + finishChat, + hasStartedConversation, + jumpToConversationEnd, + resetConversation, + sendPrompt, + toggleDiff, + }; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-window-layout.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-window-layout.ts new file mode 100644 index 0000000000..115458acc2 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-window-layout.ts @@ -0,0 +1,146 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, +} from 'react'; +import { useTimeoutRegistry } from '@/lib/react'; +import { useWindowPointerInteractions } from '../WindowInteraction/use-window-pointer-interactions'; +import type { + WindowPosition as TerminalPosition, + WindowSize as TerminalSize, +} from '../WindowInteraction/window-geometry'; +import { useWindowOrder } from '../WindowOrder/WindowOrderProvider'; +import type { TerminalToggleValue } from './TerminalToggle'; +import { + getAnchoredTerminalResizeLayout, + getInitialTerminalLayout, + getTerminalTargetSize, + TERMINAL_EDGE_GAP, + TERMINAL_INITIAL_HEIGHT, + TERMINAL_INITIAL_WIDTH, + TERMINAL_MIN_HEIGHT, + TERMINAL_MIN_WIDTH, +} from './terminal-window-geometry'; + +const TERMINAL_WINDOW_ID = 'terminal-window'; +const TERMINAL_ANIMATION_ENABLE_DELAY = 150; + +type ResizeToTerminalTargetInput = { + chatStarted: boolean; + view: TerminalToggleValue; +}; + +export const useTerminalWindowLayout = () => { + const timeoutRegistry = useTimeoutRegistry(); + const shellRef = useRef(null); + const [position, setPosition] = useState(null); + const [size, setSize] = useState({ + width: TERMINAL_INITIAL_WIDTH, + height: TERMINAL_INITIAL_HEIGHT, + }); + const [animationsEnabled, setAnimationsEnabled] = useState(false); + const { activate, zIndex } = useWindowOrder(TERMINAL_WINDOW_ID); + + useEffect(() => { + const cancelAnimationEnable = timeoutRegistry.schedule(() => { + setAnimationsEnabled(true); + }, TERMINAL_ANIMATION_ENABLE_DELAY); + + return () => cancelAnimationEnable(); + }, [timeoutRegistry]); + + const getParentBounds = useCallback(() => { + const shell = shellRef.current; + const parent = shell?.parentElement as HTMLElement | null; + + return parent?.getBoundingClientRect() ?? null; + }, []); + + useLayoutEffect(() => { + const parentBounds = getParentBounds(); + + if (parentBounds === null) { + return; + } + + const initialLayout = getInitialTerminalLayout(parentBounds); + setSize(initialLayout.size); + setPosition(initialLayout.position); + }, [getParentBounds]); + + const resizeToTerminalTarget = useCallback( + ({ chatStarted, view }: ResizeToTerminalTargetInput) => { + const bounds = getParentBounds(); + const targetSize = getTerminalTargetSize({ + bounds, + chatStarted, + view, + }); + const next = getAnchoredTerminalResizeLayout({ + bounds, + currentPosition: position, + currentSize: size, + targetSize, + }); + + setSize(next.size); + setPosition(next.position); + }, + [getParentBounds, position, size], + ); + + const { + handleDragStart, + isDragging, + isResizing, + latestPositionRef, + latestSizeRef, + startResize, + } = useWindowPointerInteractions({ + activate, + blockedDragTargetSelector: 'button', + edgeGap: TERMINAL_EDGE_GAP, + getBounds: getParentBounds, + minSize: { + width: TERMINAL_MIN_WIDTH, + height: TERMINAL_MIN_HEIGHT, + }, + position, + setPosition, + setSize, + shellRef, + size, + }); + + const isInteracting = isDragging || isResizing; + const renderPosition = isInteracting + ? (latestPositionRef.current ?? position) + : position; + const renderSize = isInteracting ? (latestSizeRef.current ?? size) : size; + + const windowStyle: CSSProperties = { + height: `${renderSize.height}px`, + transform: + renderPosition !== null + ? `translate3d(${renderPosition.left}px, ${renderPosition.top}px, 0)` + : 'translate3d(0, 0, 0)', + width: `${renderSize.width}px`, + zIndex, + }; + + return { + activate, + animationsEnabled, + handleDragStart, + isDragging, + isReady: position !== null, + isResizing, + resizeToTerminalTarget, + shellRef, + startResize, + windowStyle, + }; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx index 96c379fd43..29486d03c8 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx @@ -1,248 +1,18 @@ 'use client'; -import dynamic from 'next/dynamic'; -import { getSharedCompanyLogoUrlFromDomainName } from '@/content/site/asset-paths'; -import { createBoundedFailureCache } from '@/lib/visual-runtime'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; -import { - IconBarcode, - IconBook, - IconBox, - IconBrandLinkedin, - IconBuildingFactory2, - IconBuildingSkyscraper, - IconCalendarClock, - IconCalendarEvent, - IconCalendarPlus, - IconCheck, - IconCheckbox, - IconChevronDown, - IconCopy, - IconCreativeCommonsSa, - IconDotsVertical, - IconFlag, - IconFolder, - IconHome2, - IconLayoutDashboard, - IconLayoutKanban, - IconLayoutSidebarLeftCollapse, - IconLink, - IconList, - IconMap2, - IconMapPin, - IconMessageCircle, - IconMessageCirclePlus, - IconMoneybag, - IconNotes, - IconPencil, - IconChevronUp, - IconHeart, - IconPlanet, - IconPlayerPause, - IconPlayerPlay, - IconPlus, - IconProgress, - IconRefresh, - IconRepeat, - IconRocket, - IconRuler, - IconSearch, - IconSettings, - IconSettingsAutomation, - IconTarget, - IconTargetArrow, - IconUser, - IconUserCircle, - IconUsers, - IconVersions, - IconWeight, - IconX, -} from '@tabler/icons-react'; -import { - useCallback, - useEffect, - useMemo, - useState, - type ReactNode, -} from 'react'; -import type { - HeroDashboardPageDefinition, - HeroCellEntity, - HeroKanbanPageDefinition, - HeroCellPerson, - HeroCellRelation, - HeroCellText, - HeroCellValue, - HeroNavbarActionType, - HeroPageDefinition, - HeroPageType, - HeroSidebarEntry, - HeroSidebarFolder, - HeroSidebarIcon, - HeroSidebarItem, - HeroTablePageDefinition, - HeroWorkflowPageDefinition, - HeroVisualType, -} from '../../types/HeroHomeData'; -import { Chip, ChipVariant } from './homeVisualChip'; -import { VISUAL_TOKENS } from './homeVisualTokens'; -import { normalizeHeroPage, type HeroPageDefaults } from './normalizeHeroPage'; -import { KanbanPage } from './KanbanPage'; -import { PagePreviewLoader } from './PagePreviewLoader'; -import { TablePage } from './TablePage'; +import type { HeroVisualType } from '@/sections/Hero/types'; import { DraggableAppWindow } from './DraggableAppWindow/DraggableAppWindow'; import { DraggableTerminal } from './DraggableTerminal/DraggableTerminal'; -import { OBJECT_PINNED_ACTIONS } from './objectPinnedActions'; -import { - COMPANIES_ITEM_ID, - COMPANIES_ITEM_LABEL, - CRM_OBJECT_SEQUENCE, -} from './rocketObject'; +import { COLORS } from './Shared/home-visual-theme'; +import { HomeVisualNavbar } from './Shell/HomeVisualNavbar'; +import { HomeVisualSidebar } from './Shell/HomeVisualSidebar'; +import { HomeVisualViewbar } from './Shell/HomeVisualViewbar'; +import { renderPageDefinition } from './Shell/home-visual-page-renderers'; +import { useHomeVisualState } from './Shell/use-home-visual-state'; import { WindowOrderProvider } from './WindowOrder/WindowOrderProvider'; -const APP_FONT = VISUAL_TOKENS.font.family; -const DEFAULT_TABLE_WIDTH = 1700; -const APPLE_WORKSPACE_LOGO_SRC = '/images/home/hero/apple-rainbow-logo.svg'; -const TABLE_CELL_HORIZONTAL_PADDING = 8; -const HOVER_ACTION_EDGE_INSET = 4; -const COMPLETED_CREATED_OBJECT_IDS = CRM_OBJECT_SEQUENCE.map(({ id }) => id); -const COMPLETED_REVEALED_OBJECT_IDS = [ - ...COMPLETED_CREATED_OBJECT_IDS, - COMPANIES_ITEM_ID, -]; -const COMPLETED_ACTIVE_OBJECT_LABEL = - CRM_OBJECT_SEQUENCE.at(-1)?.label ?? COMPANIES_ITEM_LABEL; - -const COLORS = { - accent: VISUAL_TOKENS.accent.accent9, - accentBorder: VISUAL_TOKENS.border.color.blue, - accentSurface: VISUAL_TOKENS.accent.primary, - accentSurfaceSoft: VISUAL_TOKENS.background.transparent.blue, - background: VISUAL_TOKENS.background.primary, - backgroundSecondary: VISUAL_TOKENS.background.secondary, - border: VISUAL_TOKENS.border.color.medium, - borderLight: VISUAL_TOKENS.border.color.light, - borderStrong: VISUAL_TOKENS.border.color.strong, - shadow: '0 14px 34px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04)', - text: VISUAL_TOKENS.font.color.primary, - textSecondary: VISUAL_TOKENS.font.color.secondary, - textTertiary: VISUAL_TOKENS.font.color.tertiary, - textLight: VISUAL_TOKENS.font.color.light, -}; - -const SIDEBAR_TONES: Record< - string, - { background: string; border: string; color: string } -> = { - amber: { background: '#FEF2A4', border: '#FEF2A4', color: '#35290F' }, - blue: { background: '#d9e2fc', border: '#c6d4f9', color: '#3A5CCC' }, - gray: { background: '#ebebeb', border: '#d6d6d6', color: '#838383' }, - green: { background: '#ccebd7', border: '#bbe4c9', color: '#153226' }, - orange: { background: '#ffdcc3', border: '#ffcca7', color: '#ED5F00' }, - pink: { background: '#ffe1e7', border: '#ffc8d6', color: '#a51853' }, - purple: { background: '#e0e7ff', border: '#c7d2fe', color: '#4f46e5' }, - teal: { background: '#c7ebe5', border: '#afdfd7', color: '#0E9888' }, - violet: { background: '#ebe5ff', border: '#d8cbff', color: '#5b3fd1' }, - red: { background: '#fdd8d8', border: '#f9c6c6', color: '#DC3D43' }, -}; - -const hexToRgbTuple = (hex: string): string => { - const clean = hex.replace('#', ''); - const expanded = - clean.length === 3 - ? clean - .split('') - .map((char) => char + char) - .join('') - : clean; - const value = parseInt(expanded, 16); - return `${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}`; -}; - -const SIDEBAR_TONE_RGB: Record = Object.fromEntries( - Object.entries(SIDEBAR_TONES).map(([tone, palette]) => [ - tone, - hexToRgbTuple(palette.color), - ]), -); - -const PERSON_TONES: Record = { - amber: { background: '#f6e6d7', color: '#7a4f2a' }, - blue: { background: '#dbeafe', color: '#1d4ed8' }, - gray: { background: '#e5e7eb', color: '#4b5563' }, - green: { background: '#dcfce7', color: '#15803d' }, - pink: { background: '#ffe4e6', color: '#be123c' }, - purple: { background: '#ede9fe', color: '#6d28d9' }, - red: { background: '#fee2e2', color: '#b91c1c' }, - teal: { background: '#ccfbf1', color: '#0f766e' }, -}; - -const TABLER_STROKE = 1.6; -const NAVIGATION_TABLER_STROKE = 2; -const NAVBAR_ACTION_TABLER_STROKE = 2; -const ROW_HOVER_ACTION_DISABLED_COLUMNS = new Set([ - 'createdBy', - 'accountOwner', -]); - -const NAVBAR_ACTION_ICON_MAP: Record = { - box: IconBox, - calendarClock: IconCalendarClock, - calendarEvent: IconCalendarEvent, - calendarPlus: IconCalendarPlus, - chevronDown: IconChevronDown, - chevronUp: IconChevronUp, - dotsVertical: IconDotsVertical, - flag: IconFlag, - heart: IconHeart, - playerPause: IconPlayerPause, - plus: IconPlus, - repeat: IconRepeat, - rocket: IconRocket, -}; - -const SalesDashboardPage = dynamic( - () => - import('./SalesDashboardPage').then((mod) => ({ - default: mod.SalesDashboardPage, - })), - { - loading: () => ( - - ), - ssr: false, - }, -); - -const WorkflowPage = dynamic( - () => - import('./WorkflowPage').then((mod) => ({ - default: mod.WorkflowPage, - })), - { - loading: () => , - ssr: false, - }, -); - -const PAGE_RENDERERS = { - table: (page: HeroTablePageDefinition) => , - kanban: (page: HeroKanbanPageDefinition) => , - dashboard: (page: HeroDashboardPageDefinition) => ( - - - - ), - workflow: (page: HeroWorkflowPageDefinition) => , -} satisfies { - [K in HeroPageType]: ( - page: Extract, - ) => ReactNode; -}; - const StyledHomeVisual = styled.div` isolation: isolate; margin-top: ${theme.spacing(5)}; @@ -279,519 +49,6 @@ const AppLayout = styled.div` z-index: 1; `; -const SidebarPanel = styled.aside` - background: transparent; - display: grid; - flex: 0 0 48px; - gap: 8px; - grid-template-rows: auto auto minmax(0, 1fr); - min-height: 0; - padding: 8px 4px; - width: 48px; - - @media (min-width: ${theme.breakpoints.md}px) { - flex-basis: 220px; - gap: 12px; - padding: 12px 8px; - width: 220px; - } -`; - -const SidebarTopBar = styled.div` - align-items: center; - display: grid; - grid-template-columns: minmax(0, 1fr); - min-height: 32px; - - @media (min-width: ${theme.breakpoints.md}px) { - gap: 8px; - grid-template-columns: minmax(0, 1fr) auto; - } -`; - -const WorkspaceMenu = styled.div` - align-items: center; - display: grid; - gap: 4px; - grid-auto-flow: column; - grid-template-columns: auto; - justify-content: center; - min-width: 0; - padding: 6px 4px; - - > svg:last-child { - display: none; - } - - @media (min-width: ${theme.breakpoints.md}px) { - gap: 8px; - grid-auto-flow: row; - grid-template-columns: auto 1fr auto; - justify-content: stretch; - - > svg:last-child { - display: block; - } - } -`; - -const WorkspaceIcon = styled.div` - align-items: center; - display: flex; - flex: 0 0 auto; - height: 16px; - justify-content: center; - width: 16px; -`; - -const WorkspaceIconImage = styled.img` - display: block; - height: 100%; - object-fit: contain; - object-position: center; - width: 100%; -`; - -const WorkspaceLabel = styled.span` - color: ${COLORS.text}; - display: none; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - @media (min-width: ${theme.breakpoints.md}px) { - display: block; - } -`; - -const SidebarTopActions = styled.div` - align-items: center; - display: none; - gap: 2px; - grid-auto-flow: column; - - @media (min-width: ${theme.breakpoints.md}px) { - display: grid; - } -`; - -const SidebarIconButton = styled.div` - align-items: center; - border-radius: 4px; - display: flex; - height: 24px; - justify-content: center; - width: 24px; -`; - -const SidebarControls = styled.div` - align-items: center; - display: grid; - gap: 8px; - grid-auto-flow: column; - grid-template-columns: auto; - justify-content: center; - min-width: 0; - - @media (min-width: ${theme.breakpoints.md}px) { - display: flex; - gap: 12px; - grid-auto-flow: row; - justify-content: space-between; - } -`; - -const SegmentedRail = styled.div` - background: #fcfcfccc; - border: 1px solid ${COLORS.border}; - border-radius: 40px; - display: none; - gap: 2px; - grid-auto-flow: column; - padding: 3px; - - @media (min-width: ${theme.breakpoints.md}px) { - display: grid; - } -`; - -const Segment = styled.div<{ $selected?: boolean }>` - align-items: center; - background: ${({ $selected }) => ($selected ? '#0000000a' : 'transparent')}; - border-radius: 16px; - display: flex; - height: 22px; - justify-content: center; - width: 22px; - - @media (min-width: ${theme.breakpoints.md}px) { - padding: 0 8px; - width: 32px; - } -`; - -const NewChat = styled.div` - align-items: center; - background: ${COLORS.backgroundSecondary}; - border: 1px solid ${COLORS.border}; - border-radius: 40px; - color: ${COLORS.textSecondary}; - display: flex; - gap: 4px; - height: 28px; - justify-content: center; - min-width: 0; - padding: 3px; - width: 28px; - - @media (min-width: ${theme.breakpoints.md}px) { - width: 103px; - } -`; - -const NewChatLabel = styled.span` - display: none; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - @media (min-width: ${theme.breakpoints.md}px) { - display: block; - } -`; - -const SidebarScroll = styled.div` - display: flex; - flex-direction: column; - gap: 2px; - min-height: 0; - overflow-x: hidden; - overflow-y: auto; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } -`; - -const SidebarSection = styled.div` - display: grid; - gap: 2px; - padding-bottom: 8px; -`; - -const SidebarSectionLabel = styled.span<{ $workspace?: boolean }>` - color: ${COLORS.textLight}; - display: none; - font-family: ${APP_FONT}; - font-size: 11px; - font-weight: 600; - line-height: 1; - padding: ${({ $workspace }) => ($workspace ? '4px 4px 8px' : '0 4px 4px')}; - - @media (min-width: ${theme.breakpoints.md}px) { - display: block; - } -`; - -const SidebarItemRow = styled.div<{ - $active?: boolean; - $depth?: number; - $interactive?: boolean; - $withBranch?: boolean; - $highlighted?: boolean; - $highlightRgb?: string; -}>` - --hero-highlight-rgb: ${({ $highlightRgb }) => $highlightRgb ?? '237, 95, 0'}; - align-items: center; - background: ${({ $active }) => - $active ? VISUAL_TOKENS.background.transparent.medium : 'transparent'}; - border-radius: 4px; - display: grid; - gap: 0; - grid-template-columns: auto; - justify-content: center; - height: 28px; - padding: 0; - position: relative; - text-decoration: none; - transition: background-color 0.14s ease; - animation: ${({ $highlighted }) => - $highlighted - ? 'heroObjectAppearRow 1800ms cubic-bezier(0.34, 1.56, 0.64, 1) both' - : 'none'}; - transform-origin: left center; - - @media (min-width: ${theme.breakpoints.md}px) { - grid-template-columns: ${({ $withBranch }) => - $withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'}; - justify-content: stretch; - padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`}; - } - - &:hover { - background: ${({ $active, $interactive }) => - $active || $interactive - ? VISUAL_TOKENS.background.transparent.medium - : 'transparent'}; - } - - @keyframes heroObjectAppearRow { - 0% { - background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0); - box-shadow: - 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0), - 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0); - opacity: 0; - transform: translateX(-32px) translateY(-6px) scale(0.6); - } - 16% { - background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.55); - box-shadow: - 0 0 0 6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.4), - 0 12px 28px -6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.55); - opacity: 1; - transform: translateX(0) translateY(0) scale(1.18); - } - 32% { - background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.42); - box-shadow: - 0 0 0 12px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.24), - 0 10px 22px -6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.38); - transform: translateX(0) scale(0.97); - } - 50% { - background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.28); - box-shadow: - 0 0 0 18px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.12), - 0 6px 16px -6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.22); - transform: translateX(0) scale(1.02); - } - 72% { - background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.16); - box-shadow: - 0 0 0 22px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0), - 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0); - transform: translateX(0) scale(1); - } - 100% { - background: ${VISUAL_TOKENS.background.transparent.medium}; - box-shadow: - 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0), - 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0); - transform: translateX(0) scale(1); - } - } -`; - -const SidebarItemRowLink = styled.a<{ - $active?: boolean; - $depth?: number; - $interactive?: boolean; - $withBranch?: boolean; -}>` - align-items: center; - background: ${({ $active }) => - $active ? VISUAL_TOKENS.background.transparent.medium : 'transparent'}; - border-radius: 4px; - display: grid; - gap: 0; - grid-template-columns: auto; - justify-content: center; - height: 28px; - padding: 0; - position: relative; - text-decoration: none; - transition: background-color 0.14s ease; - - @media (min-width: ${theme.breakpoints.md}px) { - grid-template-columns: ${({ $withBranch }) => - $withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'}; - justify-content: stretch; - padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`}; - } - - &:hover { - background: ${({ $active, $interactive }) => - $active || $interactive - ? VISUAL_TOKENS.background.transparent.medium - : 'transparent'}; - } -`; - -const SidebarIconSurface = styled.div<{ - $background: string; - $border: string; - $color: string; - $pulse?: boolean; -}>` - align-items: center; - animation: ${({ $pulse }) => - $pulse - ? 'heroObjectAppearIcon 1400ms cubic-bezier(0.34, 1.7, 0.64, 1) both' - : 'none'}; - background: ${({ $background }) => $background}; - border: 1px solid ${({ $border }) => $border}; - border-radius: 4px; - color: ${({ $color }) => $color}; - display: flex; - flex: 0 0 auto; - height: 16px; - justify-content: center; - position: relative; - width: 16px; - - @keyframes heroObjectAppearIcon { - 0% { - transform: scale(0.35) rotate(-18deg); - } - 30% { - transform: scale(1.45) rotate(8deg); - } - 55% { - transform: scale(0.9) rotate(-4deg); - } - 80% { - transform: scale(1.06) rotate(2deg); - } - 100% { - transform: scale(1) rotate(0deg); - } - } -`; - -const SidebarItemText = styled.div` - display: none; - min-width: 0; - - @media (min-width: ${theme.breakpoints.md}px) { - align-items: center; - display: flex; - gap: 2px; - } -`; - -const SidebarItemLabel = styled.span<{ $active?: boolean }>` - color: ${({ $active }) => ($active ? COLORS.text : COLORS.textSecondary)}; - display: none; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - @media (min-width: ${theme.breakpoints.md}px) { - display: block; - } -`; - -const SidebarItemMeta = styled.span` - color: ${COLORS.textLight}; - display: none; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - @media (min-width: ${theme.breakpoints.md}px) { - display: block; - } -`; - -const SidebarChevron = styled.div<{ $expanded?: boolean }>` - color: ${COLORS.textTertiary}; - display: none; - transform: rotate(${({ $expanded }) => ($expanded ? '0deg' : '-90deg')}); - transition: transform 0.16s ease; - - @media (min-width: ${theme.breakpoints.md}px) { - display: flex; - } -`; - -const SidebarChildStack = styled.div` - display: grid; - gap: 2px; - position: relative; -`; - -const BranchLine = styled.div` - background: ${COLORS.borderStrong}; - bottom: 14px; - left: 11px; - position: absolute; - top: 0; - width: 1px; -`; - -const SidebarBranchCell = styled.div<{ $isLastChild?: boolean }>` - align-self: stretch; - position: relative; - width: 9px; - - &::before { - background: ${COLORS.borderStrong}; - content: ''; - inset: 0 88.89% 0 0; - opacity: ${({ $isLastChild }) => ($isLastChild ? 0 : 1)}; - position: absolute; - } - - &::after { - border-bottom: 1px solid ${COLORS.borderStrong}; - border-left: 1px solid ${COLORS.borderStrong}; - border-radius: 0 0 0 4px; - content: ''; - inset: 0 0 45.83% 0; - position: absolute; - } -`; - -const SidebarRowMain = styled.div<{ $withBranch?: boolean }>` - align-items: center; - display: flex; - gap: 8px; - min-width: 0; - padding-left: ${({ $withBranch }) => ($withBranch ? '4px' : '0')}; -`; - -const SidebarAvatar = styled.div<{ - $background: string; - $color: string; - $shape?: 'circle' | 'square'; -}>` - align-items: center; - background: ${({ $background }) => $background}; - border-radius: ${({ $shape }) => ($shape === 'square' ? '4px' : '999px')}; - color: ${({ $color }) => $color}; - display: flex; - flex: 0 0 auto; - font-family: ${APP_FONT}; - font-size: 10px; - font-weight: ${theme.font.weight.medium}; - height: 16px; - justify-content: center; - line-height: 1; - width: 16px; -`; - const RightPane = styled.div` display: flex; flex: 1 1 0; @@ -806,160 +63,6 @@ const RightPane = styled.div` } `; -const NavbarBar = styled.div` - align-items: center; - background: transparent; - display: grid; - flex: 0 0 32px; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - height: 32px; - min-width: 0; - width: 100%; -`; - -const Breadcrumb = styled.div` - align-items: center; - display: flex; - flex: 1 1 auto; - gap: 2px; - min-width: 0; - overflow: hidden; -`; - -const BreadcrumbTag = styled.div` - align-items: center; - display: flex; - gap: 4px; - height: 20px; - min-width: 0; - padding: 0 2px; -`; - -const CrumbLabel = styled.span` - color: ${COLORS.text}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const NavbarActions = styled.div` - align-items: center; - display: flex; - flex: 0 1 auto; - gap: 8px; - justify-self: end; - max-width: 100%; - min-width: 0; - pointer-events: none; -`; - -const DesktopOnlyNavbarAction = styled.div` - display: none; - flex: 0 1 auto; - min-width: 0; - - @media (min-width: ${theme.breakpoints.md}px) { - display: block; - } -`; - -const NAVBAR_ACTION_BORDER = 'rgba(0, 0, 0, 0.08)'; - -const NavbarActionButton = styled.div<{ $iconOnly?: boolean }>` - align-items: center; - background: transparent; - border: 1px solid ${NAVBAR_ACTION_BORDER}; - border-radius: ${VISUAL_TOKENS.border.radius.sm}; - display: inline-flex; - flex: 0 1 auto; - font-family: ${APP_FONT}; - font-size: ${VISUAL_TOKENS.font.size.md}; - font-weight: ${VISUAL_TOKENS.font.weight.medium}; - gap: ${VISUAL_TOKENS.spacing[1]}; - height: 24px; - justify-content: center; - min-width: ${({ $iconOnly }) => ($iconOnly ? '24px' : '0')}; - max-width: 100%; - padding: ${({ $iconOnly }) => - $iconOnly ? '0' : `0 ${VISUAL_TOKENS.spacing[2]}`}; - white-space: nowrap; -`; - -const NavbarActionIconWrap = styled.span<{ $color?: string }>` - align-items: center; - color: ${({ $color }) => $color ?? VISUAL_TOKENS.font.color.secondary}; - display: flex; - flex: 0 0 auto; - justify-content: center; -`; - -const NavbarActionLabel = styled.span<{ $color?: string }>` - color: ${({ $color }) => $color ?? VISUAL_TOKENS.font.color.secondary}; - font-family: inherit; - font-size: inherit; - font-weight: inherit; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const DesktopOnlyNavbarTrailing = styled.div` - align-items: center; - display: none; - gap: ${VISUAL_TOKENS.spacing[1]}; - height: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - display: inline-flex; - } -`; - -const NavbarActionSeparator = styled.div` - background: ${VISUAL_TOKENS.background.transparent.medium}; - border-radius: 56px; - height: 100%; - width: 1px; -`; - -const PinnedActionButton = styled(NavbarActionButton)<{ - $pinnedActionIndex: number; -}>` - animation: pinnedActionIn 340ms cubic-bezier(0.22, 1, 0.36, 1) both; - animation-delay: calc( - ${({ $pinnedActionIndex }) => $pinnedActionIndex} * 90ms - ); - display: none; - gap: 4px; - padding: 0 6px; - - @media (min-width: ${theme.breakpoints.md}px) { - display: inline-flex; - } - - @keyframes pinnedActionIn { - from { - opacity: 0; - transform: translateY(-6px) scale(0.94); - } - 60% { - opacity: 1; - transform: translateY(1px) scale(1.02); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } - } -`; - const IndexSurface = styled.div` background: ${COLORS.background}; border: 1px solid ${COLORS.border}; @@ -972,1660 +75,62 @@ const IndexSurface = styled.div` overflow: hidden; `; -const ViewbarBar = styled.div` - align-items: center; - background: ${COLORS.background}; - border-bottom: 1px solid ${COLORS.borderLight}; - display: flex; - justify-content: space-between; - min-width: 0; - padding: 8px 8px 8px 12px; - width: 100%; -`; - -const ViewSwitcher = styled.div` - align-items: center; - display: flex; - flex: 1 1 auto; - gap: 4px; - height: 24px; - min-width: 0; - overflow: hidden; - padding: 0 4px; -`; - -const ViewName = styled.span` - color: ${COLORS.textSecondary}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - white-space: nowrap; -`; - -const ViewCount = styled.span` - color: ${COLORS.textLight}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - white-space: nowrap; -`; - -const TinyDot = styled.div` - background: ${COLORS.borderStrong}; - border-radius: 999px; - height: 2px; - width: 2px; -`; - -const ViewActions = styled.div` - align-items: center; - display: none; - flex: 0 0 auto; - gap: 2px; - margin-left: auto; - position: relative; - z-index: 1; - - @media (min-width: ${theme.breakpoints.md}px) { - display: flex; - } -`; - -const ViewAction = styled.span` - align-items: center; - border-radius: 4px; - color: ${COLORS.textSecondary}; - display: flex; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - height: 24px; - line-height: 1.4; - padding: 4px 8px; - white-space: nowrap; -`; - -const TableShell = styled.div` - display: flex; - flex: 1 1 auto; - min-height: 0; - min-width: 0; - overflow: hidden; - width: 100%; -`; - -const DashboardViewport = styled.div` - flex: 1 1 auto; - min-height: 0; - min-width: 0; - overflow: auto; - scrollbar-width: none; - width: 100%; - - &::-webkit-scrollbar { - display: none; - } -`; - -const GripRail = styled.div` - background: ${COLORS.background}; - display: grid; - flex: 0 0 12px; - grid-auto-rows: 32px; - width: 12px; -`; - -const GripCell = styled.div` - background: ${COLORS.background}; - border-bottom: 1px solid ${COLORS.borderLight}; -`; - -const TableViewport = styled.div<{ $dragging: boolean }>` - cursor: ${({ $dragging }) => ($dragging ? 'grabbing' : 'grab')}; - flex: 1 1 auto; - min-height: 0; - min-width: 0; - overflow-x: auto; - overflow-y: hidden; - overscroll-behavior-x: contain; - scrollbar-width: none; - width: 100%; - - &::-webkit-scrollbar { - display: none; - } -`; - -const TableCanvas = styled.div<{ $width: number }>` - display: flex; - flex-direction: column; - height: 100%; - min-height: 100%; - min-width: ${({ $width }) => `${$width}px`}; - width: ${({ $width }) => `${$width}px`}; -`; - -const HeaderRow = styled.div` - display: flex; -`; - -const DataRow = styled.div` - display: flex; -`; - -const FooterRow = styled.div` - display: flex; -`; - -const TableCell = styled.div<{ - $align?: 'left' | 'right'; - $header?: boolean; - $hovered?: boolean; - $sticky?: boolean; - $width: number; -}>` - align-items: center; - background: ${({ $header, $hovered }) => { - if ($header) { - return COLORS.background; - } - - return $hovered ? COLORS.backgroundSecondary : COLORS.background; - }}; - border-bottom: 1px solid ${COLORS.borderLight}; - border-right: 1px solid ${COLORS.borderLight}; - box-sizing: border-box; - display: flex; - flex: 0 0 ${({ $width }) => `${$width}px`}; - height: 32px; - justify-content: ${({ $align }) => - $align === 'right' ? 'flex-end' : 'flex-start'}; - left: ${({ $sticky }) => ($sticky ? '0' : 'auto')}; - min-width: ${({ $width }) => `${$width}px`}; - padding: 0 ${TABLE_CELL_HORIZONTAL_PADDING}px; - position: ${({ $sticky }) => ($sticky ? 'sticky' : 'relative')}; - z-index: ${({ $header, $sticky }) => { - if ($sticky && $header) { - return 6; - } - - if ($sticky) { - return 4; - } - - return 1; - }}; -`; - -const EmptyFillCell = styled.div<{ - $footer?: boolean; - $header?: boolean; - $hovered?: boolean; - $width: number; -}>` - background: ${({ $header, $hovered, $footer }) => { - if ($header || $footer) { - return COLORS.background; - } - - return $hovered ? COLORS.backgroundSecondary : COLORS.background; - }}; - border-bottom: 1px solid ${COLORS.borderLight}; - flex: 0 0 ${({ $width }) => `${$width}px`}; - min-width: ${({ $width }) => `${$width}px`}; -`; - -const HeaderCellContent = styled.div` - align-items: center; - display: flex; - gap: 4px; - height: 100%; - min-width: 0; - width: 100%; -`; - -const HeaderLabel = styled.span` - color: ${COLORS.textTertiary}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const EdgePlus = styled.div` - margin-left: auto; -`; - -const CheckboxContainer = styled.div` - align-items: center; - display: flex; - flex: 0 0 24px; - height: 24px; - justify-content: center; - width: 24px; -`; - -const CheckboxBox = styled.div<{ $checked?: boolean }>` - align-items: center; - background: ${({ $checked }) => - $checked ? COLORS.accentSurfaceSoft : 'transparent'}; - border: 1px solid - ${({ $checked }) => ($checked ? COLORS.accentBorder : COLORS.borderStrong)}; - border-radius: 3px; - display: flex; - flex: 0 0 auto; - height: 14px; - justify-content: center; - width: 14px; -`; - -const EntityCellLayout = styled.div` - align-items: center; - display: flex; - gap: 4px; - height: 100%; - min-width: 0; - position: relative; - width: 100%; -`; - -const CellHoverAnchor = styled.div` - align-items: center; - display: flex; - height: 100%; - min-width: 0; - position: relative; - width: 100%; -`; - -const CellChip = styled(Chip)` - max-width: 100%; - min-width: 0; -`; - -const InlineText = styled.span` - color: ${COLORS.text}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const MutedText = styled.span` - color: ${COLORS.textTertiary}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - line-height: 1.4; - white-space: nowrap; -`; - -const RightAlignedText = styled(InlineText)` - text-align: right; - width: 100%; -`; - -const PersonAvatarCircle = styled.div<{ - $background: string; - $color: string; - $square?: boolean; -}>` - align-items: center; - background: ${({ $background }) => $background}; - border-radius: ${({ $square }) => ($square ? '4px' : '999px')}; - color: ${({ $color }) => $color}; - display: flex; - flex: 0 0 auto; - font-family: ${APP_FONT}; - font-size: 10px; - font-weight: ${theme.font.weight.medium}; - height: 14px; - justify-content: center; - overflow: hidden; - width: 14px; -`; - -const AvatarImage = styled.img` - display: block; - height: 100%; - object-fit: cover; - width: 100%; -`; - -const BooleanRow = styled.div` - align-items: center; - display: inline-flex; - gap: 4px; -`; - -const HoverActions = styled.div<{ $rightInset?: number; $visible: boolean }>` - align-items: center; - background: ${VISUAL_TOKENS.background.transparent.primary}; - border: 1px solid ${VISUAL_TOKENS.background.transparent.light}; - border-radius: 4px; - bottom: 4px; - box-sizing: border-box; - box-shadow: ${VISUAL_TOKENS.boxShadow.light}; - display: flex; - gap: 0; - justify-content: center; - opacity: ${({ $visible }) => ($visible ? 1 : 0)}; - padding: 0 4px; - pointer-events: none; - position: absolute; - right: ${({ - $rightInset = HOVER_ACTION_EDGE_INSET - TABLE_CELL_HORIZONTAL_PADDING, - }) => `${$rightInset}px`}; - top: 4px; - transform: translateX(${({ $visible }) => ($visible ? '0' : '4px')}); - transition: - opacity 0.14s ease, - transform 0.14s ease; - width: 24px; -`; - -const MiniAction = styled.div` - align-items: center; - border-radius: 2px; - color: ${COLORS.textSecondary}; - display: flex; - height: 16px; - justify-content: center; - width: 16px; -`; - -const FooterFirstContent = styled.div` - align-items: center; - display: inline-flex; - gap: 4px; - padding-left: 28px; -`; - -const HeaderFillContent = styled.div` - align-items: center; - display: flex; - height: 100%; - padding: 0 8px; -`; - -const TagChip = styled.div` - align-items: center; - background: ${VISUAL_TOKENS.background.transparent.light}; - border-radius: 4px; - color: ${COLORS.textSecondary}; - display: inline-flex; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - height: 20px; - line-height: 1.4; - max-width: 100%; - min-width: 0; - overflow: hidden; - padding: 0 8px; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const MultiChipStack = styled.div` - align-items: center; - display: flex; - gap: 4px; - min-width: 0; - overflow: hidden; - width: 100%; -`; - -const FaviconImage = styled.img` - display: block; - height: 100%; - object-fit: contain; - width: 100%; -`; - -const TABLER_ICON_MAP: Record = { - book: IconBook, - buildingSkyscraper: IconBuildingSkyscraper, - calendarEvent: IconCalendarEvent, - checkbox: IconCheckbox, - folder: IconFolder, - layoutDashboard: IconLayoutDashboard, - mapPin: IconMapPin, - notes: IconNotes, - planet: IconPlanet, - playerPlay: IconPlayerPlay, - rocket: IconRocket, - settings: IconSettings, - settingsAutomation: IconSettingsAutomation, - targetArrow: IconTargetArrow, - user: IconUser, - versions: IconVersions, -}; - -const HEADER_ICON_MAP: Record = { - added: IconCalendarEvent, - accountOwner: IconUserCircle, - address: IconMap2, - arr: IconMoneybag, - createdBy: IconCreativeCommonsSa, - employees: IconUsers, - heightMeters: IconRuler, - icp: IconTarget, - industry: IconBuildingFactory2, - launchDate: IconCalendarEvent, - linkedin: IconBrandLinkedin, - mainContact: IconUser, - manufacturer: IconBuildingFactory2, - massKg: IconWeight, - name: IconRocket, - opportunities: IconTargetArrow, - reusable: IconRefresh, - serialNumber: IconBarcode, - status: IconProgress, - targetOrbit: IconPlanet, - url: IconLink, -}; - -const failedAvatarUrls = createBoundedFailureCache(256); -const failedFaviconUrls = createBoundedFailureCache(256); - -function getInitials(value: string) { - return value - .split(' ') - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0]) - .join('') - .toUpperCase(); -} - -function sanitizeURL(link: string | null | undefined) { - return link - ? link.replace(/(https?:\/\/)|(www\.)/g, '').replace(/\/$/, '') - : ''; -} - -function getLogoUrlFromDomainName(domainName?: string): string | undefined { - const sharedLogoUrl = getSharedCompanyLogoUrlFromDomainName(domainName); - - if (sharedLogoUrl) { - return sharedLogoUrl; - } - - const sanitizedDomain = sanitizeURL(domainName); - - return sanitizedDomain - ? `https://twenty-icons.com/${sanitizedDomain}` - : undefined; -} - -function isFolder(entry: HeroSidebarEntry): entry is HeroSidebarFolder { - return 'items' in entry; -} - -function hasRenderablePage( - item: HeroSidebarItem, - pageDefaults: HeroPageDefaults, -): boolean { - return normalizeHeroPage(item, pageDefaults) !== null; -} - -function findActiveItem( - entries: HeroSidebarEntry[], - activeLabel: string, - pageDefaults: HeroPageDefaults, -): HeroSidebarItem | undefined { - for (const entry of entries) { - if (isFolder(entry)) { - for (const child of entry.items) { - if (child.label === activeLabel) { - return child; - } - } - - continue; - } - - if (entry.children) { - for (const child of entry.children) { - if (child.label === activeLabel) { - return child; - } - } - } - - if (entry.label === activeLabel) { - if ( - !hasRenderablePage(entry, pageDefaults) && - entry.children && - entry.children.length > 0 - ) { - const firstChildWithRenderablePage = entry.children.find((child) => - hasRenderablePage(child, pageDefaults), - ); - - if (firstChildWithRenderablePage) { - return firstChildWithRenderablePage; - } - } - - return entry; - } - } - - return undefined; -} - -function findContainingFolderId( - entries: HeroSidebarEntry[], - label: string, -): string | undefined { - for (const entry of entries) { - if (!isFolder(entry)) { - continue; - } - - if ( - entry.items.some( - (item) => - item.label === label || - item.children?.some((child) => child.label === label) === true, - ) - ) { - return entry.id; - } - } - - return undefined; -} - -function renderPageDefinition( - page: HeroPageDefinition, - onNavigateToLabel?: (label: string) => void, - pageKey?: string, -) { - switch (page.type) { - case 'table': - return ( - - ); - case 'kanban': - return PAGE_RENDERERS.kanban(page); - case 'dashboard': - return PAGE_RENDERERS.dashboard(page); - case 'workflow': - return PAGE_RENDERERS.workflow(page); - } -} - -function getNavbarActionToneColor( - tone: HeroNavbarActionType['labelTone'], -): string { - if (tone === 'primary') { - return VISUAL_TOKENS.font.color.primary; - } - - if (tone === 'tertiary') { - return VISUAL_TOKENS.font.color.light; - } - - return VISUAL_TOKENS.font.color.secondary; -} - -function renderNavbarAction( - action: HeroNavbarActionType, - index: number, -): ReactNode { - const ActionIcon = NAVBAR_ACTION_ICON_MAP[action.icon]; - const isIconOnly = - action.variant === 'icon' || (!action.label && !action.trailingLabel); - const labelColor = getNavbarActionToneColor(action.labelTone); - - const button = ( - - {ActionIcon ? ( - - - - ) : null} - {action.label ? ( - - {action.label} - - ) : null} - {action.trailingLabel ? ( - - - - {action.trailingLabel} - - - ) : null} - - ); - - if (action.desktopOnly) { - return ( - - {button} - - ); - } - - return button; -} - -type MiniIconProps = { - color?: string; - size?: number; - stroke?: number; -}; - -function ChevronDownMini({ - color = COLORS.textTertiary, - size = 14, -}: MiniIconProps) { - return ( - - ); -} - -function SearchMini({ color = COLORS.textTertiary, size = 16 }: MiniIconProps) { - return ( - - ); -} - -function CollapseSidebarMini({ - color = COLORS.textTertiary, - size = 16, -}: MiniIconProps) { - return ( - - ); -} - -function HomeMini({ color = COLORS.textSecondary, size = 16 }: MiniIconProps) { - return ( - - ); -} - -function CommentMini({ - color = COLORS.textTertiary, - size = 16, -}: MiniIconProps) { - return ( - - ); -} - -function MessageCirclePlusMini({ - color = COLORS.textSecondary, - size = 16, -}: MiniIconProps) { - return ( - - ); -} - -function LinkMini({ color = COLORS.textTertiary, size = 16 }: MiniIconProps) { - return ( - - ); -} - -function ListMini({ color = COLORS.textSecondary, size = 16 }: MiniIconProps) { - return ( - - ); -} - -function KanbanMini({ - color = COLORS.textSecondary, - size = 16, -}: MiniIconProps) { - return ( - - ); -} - -function PlusMini({ color = COLORS.textSecondary, size = 14 }: MiniIconProps) { - return ( - - ); -} - -function CheckMini({ color = COLORS.text, size = 12 }: MiniIconProps) { - return ( - - ); -} - -function CloseMini({ color = COLORS.text, size = 12 }: MiniIconProps) { - return ; -} - -function PencilMini({ - color = COLORS.textSecondary, - size = 14, -}: MiniIconProps) { - return ( - - ); -} - -function CopyMini({ color = COLORS.textSecondary, size = 14 }: MiniIconProps) { - return ( - - ); -} - -function FaviconLogo({ - src, - domain, - label, - size = 14, -}: { - src?: string; - domain?: string; - label?: string; - size?: number; -}) { - const faviconUrl = src ?? getLogoUrlFromDomainName(domain); - const [localFailedUrl, setLocalFailedUrl] = useState(null); - const showFavicon = - faviconUrl !== undefined && - !failedFaviconUrls.has(faviconUrl) && - localFailedUrl !== faviconUrl; - - const baseStyle = { - width: `${size}px`, - height: `${size}px`, - borderRadius: '4px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flex: '0 0 auto', - overflow: 'hidden', - fontFamily: APP_FONT, - fontSize: size <= 14 ? '8px' : '9px', - fontWeight: 600, - lineHeight: 1, - } as const; - - if (showFavicon) { - return ( -
- { - failedFaviconUrls.add(faviconUrl); - setLocalFailedUrl(faviconUrl); - }} - /> -
- ); - } - - const initials = label ? getInitials(label) : '?'; - - return ( -
- {initials.slice(0, 1)} -
- ); -} - -function PersonAvatarContent({ token }: { token: HeroCellPerson }) { - const [localFailedUrl, setLocalFailedUrl] = useState(null); - const showAvatar = - token.avatarUrl !== undefined && - !failedAvatarUrls.has(token.avatarUrl) && - localFailedUrl !== token.avatarUrl; - - if (showAvatar) { - return ( - { - if (token.avatarUrl) { - failedAvatarUrls.add(token.avatarUrl); - setLocalFailedUrl(token.avatarUrl); - } - }} - /> - ); - } - - return token.shortLabel ?? getInitials(token.name); -} - -function renderSidebarIcon( - icon: HeroSidebarIcon, - pulse: boolean = false, -): ReactNode { - if (icon.kind === 'brand') { - return ( - - - {icon.overlay === 'link' ? ( -
- -
- ) : null} -
- ); - } - - if (icon.kind === 'avatar') { - const tone = SIDEBAR_TONES[icon.tone] ?? SIDEBAR_TONES.gray; - - return ( - - - {icon.label} - - - ); - } - - const tone = SIDEBAR_TONES[icon.tone] ?? SIDEBAR_TONES.gray; - const TablerIcon = TABLER_ICON_MAP[icon.name]; - - return ( - - {TablerIcon ? ( - - ) : null} - {icon.overlay === 'link' ? ( -
- -
- ) : null} -
- ); -} - -function SidebarItemComponent({ - collapsible = false, - expanded = false, - depth = 0, - interactive = true, - isLastChild = false, - item, - onToggleExpanded, - onSelect, - selectedLabel, - highlightedItemId, -}: { - collapsible?: boolean; - expanded?: boolean; - depth?: number; - interactive?: boolean; - isLastChild?: boolean; - item: HeroSidebarItem; - onToggleExpanded?: () => void; - onSelect?: (label: string) => void; - selectedLabel?: string; - highlightedItemId?: string; -}) { - const showBranch = depth > 0; - const rowSelectable = interactive && item.href === undefined && !collapsible; - const rowInteractive = - rowSelectable || item.href !== undefined || (interactive && collapsible); - const rowActive = - rowSelectable && - selectedLabel !== undefined && - item.label === selectedLabel; - const rowHighlighted = highlightedItemId === item.id; - const childItems = item.children ?? []; - const iconTone = - 'tone' in item.icon && typeof item.icon.tone === 'string' - ? item.icon.tone - : 'gray'; - const highlightRgb = SIDEBAR_TONE_RGB[iconTone] ?? SIDEBAR_TONE_RGB.gray; - const rowContent = ( - <> - {showBranch ? : null} - - {renderSidebarIcon(item.icon, rowHighlighted)} - - {item.label} - {item.meta ? · {item.meta} : null} - - - {item.showChevron || (item.children && item.children.length > 0) ? ( - - - - ) : null} - - ); - - return ( - <> - {item.href ? ( - - {rowContent} - - ) : ( - onSelect?.(item.label) - : undefined - } - style={{ cursor: rowInteractive ? 'pointer' : 'default' }} - > - {rowContent} - - )} - {childItems.length > 0 && (!collapsible || expanded) ? ( - - - {childItems.map((child, index) => ( - - ))} - - ) : null} - - ); -} - -function PersonTokenCell({ - token, - hovered = false, - withCopyAction = true, -}: { - token: HeroCellPerson; - hovered?: boolean; - withCopyAction?: boolean; -}) { - const tone = PERSON_TONES[token.tone ?? 'gray'] ?? PERSON_TONES.gray; - const square = - token.kind === 'api' || - token.kind === 'system' || - token.kind === 'workflow'; - - return ( - - - - - } - /> - - {withCopyAction ? ( - - ) : null} - - - ); -} - -function EntityCellComponent({ - cell, - hovered, - isFirstColumn, -}: { - cell: HeroCellEntity; - hovered: boolean; - isFirstColumn: boolean; -}) { - if (isFirstColumn) { - return ( - - - - - } - variant={ChipVariant.Highlighted} - /> - - - - - ); - } - - return ( - } - /> - ); -} - -function RelationCellComponent({ - cell, - hovered, -}: { - cell: HeroCellRelation; - hovered: boolean; -}) { - return ( - - - {cell.items.map((item) => { - const tone = PERSON_TONES[item.tone ?? 'gray'] ?? PERSON_TONES.gray; - - return ( - - {item.shortLabel ?? getInitials(item.name)} - - } - /> - ); - })} - - - - - - ); -} - -function TextCellComponent({ - cell, - isFirstColumn, -}: { - cell: HeroCellText; - isFirstColumn: boolean; -}) { - if (!isFirstColumn || !cell.shortLabel) { - return {cell.value}; - } - - const tone = PERSON_TONES[cell.tone ?? 'gray'] ?? PERSON_TONES.gray; - - return ( - - {cell.shortLabel} - - } - /> - ); -} - -function renderCellValue( - cell: HeroCellValue, - hovered: boolean, - isFirstColumn: boolean, - columnId: string, -): ReactNode { - const showHoverAction = !ROW_HOVER_ACTION_DISABLED_COLUMNS.has(columnId); - - switch (cell.type) { - case 'text': - return ; - case 'number': - return {cell.value}; - case 'link': - return ( -
- -
- ); - case 'boolean': - return ( - - {cell.value ? : } - {cell.value ? 'True' : 'False'} - - ); - case 'tag': - return {cell.value}; - case 'person': - return ( - - ); - case 'entity': - return ( - - ); - case 'relation': - return ( - - ); - } -} - export function HomeVisual({ visual }: { visual: HeroVisualType }) { - const defaultActiveLabel = - visual.favoritesNav?.find((item) => item.active)?.label ?? - visual.workspaceNav.find((entry) => !isFolder(entry) && entry.active) - ?.label ?? - visual.workspaceNav[0]?.label ?? - ''; - - const [activeLabel, setActiveLabel] = useState(defaultActiveLabel); - const [createdObjectIds, setCreatedObjectIds] = useState([]); - const [revealedObjectIds, setRevealedObjectIds] = useState([]); - const [highlightedItemId, setHighlightedItemId] = useState( - null, - ); - const [openFolderIds, setOpenFolderIds] = useState(() => { - const activeFolderId = findContainingFolderId( - visual.workspaceNav, - defaultActiveLabel, - ); - - return visual.workspaceNav.flatMap((entry) => { - if (!isFolder(entry)) { - return []; - } - - if (entry.defaultOpen || entry.id === activeFolderId) { - return [entry.id]; - } - - return []; - }); - }); - const pageDefaults = useMemo( - () => ({ - defaultActions: visual.actions ?? [], - defaultTableWidth: visual.tableWidth ?? DEFAULT_TABLE_WIDTH, - }), - [visual.actions, visual.tableWidth], - ); - - const workspaceNav = useMemo(() => { - if (createdObjectIds.length === 0) { - return visual.workspaceNav; - } - - const prepended = [...createdObjectIds] - .reverse() - .map( - (id) => - CRM_OBJECT_SEQUENCE.find((entry) => entry.id === id)?.sidebarItem, - ) - .filter((item): item is NonNullable => item !== undefined); - - return [...prepended, ...visual.workspaceNav]; - }, [createdObjectIds, visual.workspaceNav]); - - const handleObjectCreated = useCallback((id: string) => { - setRevealedObjectIds((current) => - current.includes(id) ? current : [...current, id], - ); - if (id === COMPANIES_ITEM_ID) { - setActiveLabel(COMPANIES_ITEM_LABEL); - setHighlightedItemId(COMPANIES_ITEM_ID); - return; - } - const entry = CRM_OBJECT_SEQUENCE.find((candidate) => candidate.id === id); - if (!entry) { - return; - } - setCreatedObjectIds((current) => - current.includes(id) ? current : [...current, id], - ); - setActiveLabel(entry.label); - setHighlightedItemId(entry.id); - }, []); - - const handleChatReset = useCallback(() => { - setCreatedObjectIds([]); - setRevealedObjectIds([]); - setHighlightedItemId(null); - setActiveLabel(defaultActiveLabel); - }, [defaultActiveLabel]); - - const handleJumpToConversationEnd = useCallback(() => { - setCreatedObjectIds(COMPLETED_CREATED_OBJECT_IDS); - setRevealedObjectIds(COMPLETED_REVEALED_OBJECT_IDS); - setHighlightedItemId(null); - setActiveLabel(COMPLETED_ACTIVE_OBJECT_LABEL); - }, []); - - useEffect(() => { - if (highlightedItemId === null) { - return undefined; - } - const id = window.setTimeout(() => setHighlightedItemId(null), 2000); - return () => window.clearTimeout(id); - }, [highlightedItemId]); - - const activeItem = useMemo( - () => - (visual.favoritesNav - ? findActiveItem(visual.favoritesNav, activeLabel, pageDefaults) - : undefined) ?? findActiveItem(workspaceNav, activeLabel, pageDefaults), - [activeLabel, pageDefaults, visual.favoritesNav, workspaceNav], - ); - const activePage = useMemo( - () => (activeItem ? normalizeHeroPage(activeItem, pageDefaults) : null), - [activeItem, pageDefaults], - ); + const { + activeItem, + activeLabel, + activePage, + handleChatReset, + handleJumpToConversationEnd, + handleObjectCreated, + handleSelectLabel, + handleToggleFolder, + highlightedItemId, + openFolderIds, + revealedObjectIds, + workspaceNav, + } = useHomeVisualState(visual); const activeHeader = activePage?.header; - const activeActions = activeHeader?.actions ?? []; - const navbarActions = activeHeader?.navbarActions; - const pinnedActions = - activeItem && revealedObjectIds.includes(activeItem.id) - ? OBJECT_PINNED_ACTIONS[activeItem.id] - : undefined; - const showPageCount = activeHeader?.count !== undefined; - const showListIcon = activeHeader?.showListIcon ?? false; const showViewBar = activePage !== null && activePage !== undefined && activePage.type !== 'dashboard' && activePage.type !== 'workflow'; - const handleSelectLabel = (label: string) => { - setActiveLabel(label); - - const containingFolderId = findContainingFolderId(workspaceNav, label); - - if (!containingFolderId) { - return; - } - - setOpenFolderIds((current) => - current.includes(containingFolderId) - ? current - : [...current, containingFolderId], - ); - }; - - const handleToggleFolder = (folderId: string) => { - setOpenFolderIds((current) => - current.includes(folderId) - ? current.filter((id) => id !== folderId) - : [...current, folderId], - ); - }; - - const renderSidebarEntry = (entry: HeroSidebarEntry) => { - if (isFolder(entry)) { - return ( - handleToggleFolder(entry.id)} - selectedLabel={activeLabel} - /> - ); - } - - return ( - - ); - }; - return ( - - - - - - {visual.workspace.name} - - - - - - - - - - - - - - - {visual.favoritesNav && visual.favoritesNav.length > 0 ? ( - - Favorites - {visual.favoritesNav.map((item) => ( - - ))} - - ) : null} - - - Workspace - - {workspaceNav.map(renderSidebarEntry)} - - - + - - - - {activeItem ? renderSidebarIcon(activeItem.icon) : null} - {activeLabel} - - - - - {navbarActions ? ( - navbarActions.map(renderNavbarAction) - ) : ( - <> - {pinnedActions?.map((action, index) => ( - - - {(() => { - const Icon = - NAVBAR_ACTION_ICON_MAP[action.icon] ?? - IconPlus; - return ( - - ); - })()} - - - {action.label} - - - ))} - - - - - - New - - - - - - - - - - ⌘K - - - - - )} - - + {showViewBar ? ( - - - {activeActions.length > 0 ? ( - - {activeActions.map((action) => ( - {action} - ))} - - ) : null} - + ) : null} {activePage @@ -2649,27 +154,3 @@ export function HomeVisual({ visual }: { visual: HeroVisualType }) { ); } - -function renderHeaderIcon(columnId: string): ReactNode { - const Icon = HEADER_ICON_MAP[columnId]; - - if (Icon) { - return ( - - ); - } - - return ( - - ); -} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/KanbanPage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/KanbanPage.tsx deleted file mode 100644 index 1621b84309..0000000000 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/KanbanPage.tsx +++ /dev/null @@ -1,652 +0,0 @@ -'use client'; - -import { getSharedCompanyLogoUrlFromDomainName } from '@/content/site/asset-paths'; -import { RatingStarIcon } from '@/icons'; -import { createBoundedFailureCache } from '@/lib/visual-runtime'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import { - IconBuildingSkyscraper, - IconCalendarEvent, - IconCheck, - IconCurrencyDollar, - IconId, - IconPlus, - IconStar, - IconUser, - IconUserCircle, -} from '@tabler/icons-react'; -import { useState } from 'react'; -import type { - HeroCellEntity, - HeroCellPerson, - HeroKanbanCardType, - HeroKanbanLaneType, - HeroKanbanPageDefinition, -} from '../../types/HeroHomeData'; -import { Chip } from './homeVisualChip'; -import { VISUAL_TOKENS } from './homeVisualTokens'; - -const APP_FONT = VISUAL_TOKENS.font.family; -const TABLER_STROKE = 1.6; -const LANE_WIDTH = 206.4; - -const COLORS = { - accentBorder: VISUAL_TOKENS.border.color.blue, - accentSurfaceSoft: VISUAL_TOKENS.background.transparent.blue, - background: VISUAL_TOKENS.background.primary, - backgroundSecondary: VISUAL_TOKENS.background.secondary, - border: VISUAL_TOKENS.border.color.medium, - borderLight: VISUAL_TOKENS.border.color.light, - borderStrong: VISUAL_TOKENS.border.color.strong, - shadow: VISUAL_TOKENS.boxShadow.light, - text: VISUAL_TOKENS.font.color.primary, - textLight: VISUAL_TOKENS.font.color.light, - textSecondary: VISUAL_TOKENS.font.color.secondary, - textTertiary: VISUAL_TOKENS.font.color.tertiary, -}; - -const PERSON_TONES: Record = { - amber: { background: '#f6e6d7', color: '#7a4f2a' }, - blue: { background: '#dbeafe', color: '#1d4ed8' }, - gray: { background: '#e5e7eb', color: '#4b5563' }, - green: { background: '#dcfce7', color: '#15803d' }, - pink: { background: '#ffe4e6', color: '#be123c' }, - purple: { background: '#ede9fe', color: '#6d28d9' }, - red: { background: '#fee2e2', color: '#b91c1c' }, - teal: { background: '#ccfbf1', color: '#0f766e' }, -}; - -const LANE_TONES: Record = { - blue: { background: '#def4ff', color: '#007bb8' }, - gray: { background: '#f3f1ef', color: '#666666' }, - green: { background: '#dcf7ed', color: '#1a7f50' }, - pink: { background: '#fce5f3', color: '#d6409f' }, - purple: { background: '#ede9fe', color: '#8e4ec6' }, -}; - -const failedAvatarUrls = createBoundedFailureCache(256); -const failedFaviconUrls = createBoundedFailureCache(256); - -const BoardShell = styled.div` - flex: 1 1 auto; - min-height: 0; - min-width: 0; - overflow: auto; - scrollbar-width: none; - width: 100%; - - &::-webkit-scrollbar { - display: none; - } -`; - -const BoardCanvas = styled.div<{ $laneCount: number }>` - box-sizing: border-box; - display: grid; - grid-template-columns: repeat( - ${({ $laneCount }) => $laneCount}, - minmax(${LANE_WIDTH}px, 1fr) - ); - min-height: 100%; - min-width: ${({ $laneCount }) => - `max(100%, ${$laneCount * LANE_WIDTH + 16}px)`}; - padding: 0 8px; - width: 100%; -`; - -const Lane = styled.div<{ $last?: boolean }>` - border-right: ${({ $last }) => - $last ? 'none' : `1px solid ${COLORS.borderLight}`}; - display: flex; - flex-direction: column; - min-height: 0; - min-width: 0; -`; - -const LaneHeader = styled.div` - align-items: center; - display: flex; - gap: 4px; - min-height: 40px; - padding: 8px; -`; - -const LaneTag = styled.span<{ $background: string; $color: string }>` - align-items: center; - background: ${({ $background }) => $background}; - border-radius: 4px; - color: ${({ $color }) => $color}; - display: inline-flex; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - height: 20px; - line-height: 1.4; - padding: 0 8px; - white-space: nowrap; -`; - -const LaneCount = styled.span` - color: ${COLORS.textTertiary}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - line-height: 1.4; - white-space: nowrap; -`; - -const LaneBody = styled.div` - display: flex; - flex: 1 1 auto; - flex-direction: column; - gap: 8px; - min-height: 0; - padding: 0 8px 8px; -`; - -const Card = styled.div` - background: ${COLORS.backgroundSecondary}; - border: 1px solid ${COLORS.border}; - border-radius: 4px; - box-shadow: ${COLORS.shadow}; - display: flex; - flex-direction: column; - overflow: hidden; -`; - -const CardHeader = styled.div` - align-items: center; - display: flex; - gap: 8px; - justify-content: space-between; - padding: 8px 8px 4px; -`; - -const CardTitle = styled.span` - color: ${COLORS.text}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const CheckboxContainer = styled.div` - align-items: center; - display: flex; - flex: 0 0 24px; - height: 24px; - justify-content: center; - width: 24px; -`; - -const CheckboxBox = styled.div<{ $checked?: boolean }>` - align-items: center; - background: ${({ $checked }) => - $checked ? COLORS.accentSurfaceSoft : 'transparent'}; - border: 1px solid - ${({ $checked }) => ($checked ? COLORS.accentBorder : COLORS.borderStrong)}; - border-radius: 3px; - color: ${COLORS.textSecondary}; - display: flex; - height: 14px; - justify-content: center; - width: 14px; -`; - -const CardFields = styled.div` - display: flex; - flex-direction: column; - gap: 2px; - padding: 0 8px 4px 10px; -`; - -const FieldRow = styled.div` - align-items: center; - display: flex; - gap: 4px; - min-height: 24px; - width: 100%; -`; - -const FieldIcon = styled.div` - align-items: center; - color: ${COLORS.textTertiary}; - display: flex; - flex: 0 0 16px; - height: 16px; - justify-content: center; - width: 16px; -`; - -const FieldValueWrap = styled.div` - align-items: center; - display: flex; - flex: 1 1 auto; - min-width: 0; - overflow: hidden; -`; - -const FieldText = styled.span` - color: ${COLORS.text}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const StarsRow = styled.div` - align-items: center; - display: inline-flex; - gap: 2px; - padding: 0 4px; -`; - -const StarGlyph = styled.span` - align-items: center; - display: inline-flex; - height: 12px; - justify-content: center; - width: 12px; -`; - -const AddCardButton = styled.div` - align-items: center; - color: ${COLORS.textTertiary}; - display: inline-flex; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - gap: 4px; - height: 24px; - line-height: 1.4; - padding: 0 4px; - white-space: nowrap; -`; - -const PersonAvatarCircle = styled.div<{ - $background: string; - $color: string; - $square?: boolean; -}>` - align-items: center; - background: ${({ $background }) => $background}; - border-radius: ${({ $square }) => ($square ? '4px' : '999px')}; - color: ${({ $color }) => $color}; - display: flex; - flex: 0 0 auto; - font-family: ${APP_FONT}; - font-size: 10px; - font-weight: ${theme.font.weight.medium}; - height: 14px; - justify-content: center; - overflow: hidden; - width: 14px; -`; - -const AvatarImage = styled.img` - display: block; - height: 100%; - object-fit: cover; - width: 100%; -`; - -const FaviconImage = styled.img` - display: block; - height: 100%; - object-fit: contain; - width: 100%; -`; - -function getInitials(value: string) { - return value - .split(' ') - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0]) - .join('') - .toUpperCase(); -} - -function sanitizeURL(link: string | null | undefined) { - return link - ? link.replace(/(https?:\/\/)|(www\.)/g, '').replace(/\/$/, '') - : ''; -} - -function getLogoUrlFromDomainName(domainName?: string): string | undefined { - const sharedLogoUrl = getSharedCompanyLogoUrlFromDomainName(domainName); - - if (sharedLogoUrl) { - return sharedLogoUrl; - } - - const sanitizedDomain = sanitizeURL(domainName); - - return sanitizedDomain - ? `https://twenty-icons.com/${sanitizedDomain}` - : undefined; -} - -function FaviconLogo({ - src, - domain, - label, - size = 14, -}: { - domain?: string; - label?: string; - size?: number; - src?: string; -}) { - const faviconUrl = src ?? getLogoUrlFromDomainName(domain); - const [localFailedUrl, setLocalFailedUrl] = useState(null); - const showFavicon = - faviconUrl !== undefined && - !failedFaviconUrls.has(faviconUrl) && - localFailedUrl !== faviconUrl; - - const baseStyle = { - alignItems: 'center', - borderRadius: '4px', - display: 'flex', - flex: '0 0 auto', - fontFamily: APP_FONT, - fontSize: size <= 14 ? '8px' : '9px', - fontWeight: 600, - height: `${size}px`, - justifyContent: 'center', - lineHeight: 1, - overflow: 'hidden', - width: `${size}px`, - } as const; - - if (showFavicon) { - return ( -
- { - failedFaviconUrls.add(faviconUrl); - setLocalFailedUrl(faviconUrl); - }} - /> -
- ); - } - - const initials = label ? getInitials(label) : '?'; - - return ( -
- {initials.slice(0, 1)} -
- ); -} - -function PersonAvatarContent({ token }: { token: HeroCellPerson }) { - const [localFailedUrl, setLocalFailedUrl] = useState(null); - const showAvatar = - token.avatarUrl !== undefined && - !failedAvatarUrls.has(token.avatarUrl) && - localFailedUrl !== token.avatarUrl; - - if (showAvatar) { - return ( - { - if (token.avatarUrl) { - failedAvatarUrls.add(token.avatarUrl); - setLocalFailedUrl(token.avatarUrl); - } - }} - /> - ); - } - - return token.shortLabel ?? getInitials(token.name); -} - -function EntityChip({ entity }: { entity: HeroCellEntity }) { - return ( - } - maxWidth={152} - /> - ); -} - -function PersonChip({ person }: { person: HeroCellPerson }) { - const tone = PERSON_TONES[person.tone ?? 'gray'] ?? PERSON_TONES.gray; - const square = - person.kind === 'api' || - person.kind === 'system' || - person.kind === 'workflow'; - - return ( - - - - } - maxWidth={152} - /> - ); -} - -function RatingValue({ rating }: { rating: number }) { - return ( - - {Array.from({ length: 5 }, (_, index) => ( - - - - ))} - - ); -} - -function Checkbox({ checked = false }: { checked?: boolean }) { - return ( - - - {checked ? ( - - ) : null} - - - ); -} - -function KanbanCard({ card }: { card: HeroKanbanCardType }) { - return ( - - - {card.title} - - - - - - - - - - {card.amount} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {card.date} - - - - - - - - - - - - - - - - - - {card.recordId} - - - - - ); -} - -function KanbanLane({ - lane, - isLast, -}: { - isLast: boolean; - lane: HeroKanbanLaneType; -}) { - const tone = LANE_TONES[lane.tone] ?? LANE_TONES.gray; - - return ( - - - - {lane.label} - - {lane.cards.length} - - - - {lane.cards.map((card) => ( - - ))} - - - - - ); -} - -export function KanbanPage({ page }: { page: HeroKanbanPageDefinition }) { - return ( - - - {page.lanes.map((lane, index) => ( - - ))} - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/SalesDashboardPage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Dashboard/SalesDashboardPage.tsx similarity index 98% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/SalesDashboardPage.tsx rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Dashboard/SalesDashboardPage.tsx index e7f51ba22f..dd41b9fb71 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/SalesDashboardPage.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Dashboard/SalesDashboardPage.tsx @@ -4,7 +4,7 @@ import type { } from '@/sections/Hero/types'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; -import { VISUAL_TOKENS } from './homeVisualTokens'; +import { VISUAL_TOKENS } from '../../Shared/homeVisualTokens'; const APP_FONT = VISUAL_TOKENS.font.family; const CARD_BACKGROUND = VISUAL_TOKENS.background.secondary; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanCard.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanCard.tsx new file mode 100644 index 0000000000..0edcf010b2 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanCard.tsx @@ -0,0 +1,313 @@ +import { RatingStarIcon } from '@/icons'; +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { + IconBuildingSkyscraper, + IconCalendarEvent, + IconCheck, + IconCurrencyDollar, + IconId, + IconStar, + IconUser, + IconUserCircle, +} from '@tabler/icons-react'; + +import type { + HeroCellEntity, + HeroCellPerson, + HeroKanbanCardType, +} from '@/sections/Hero/types'; +import { + HomeVisualFaviconLogo, + HomeVisualPersonAvatar, +} from '../../Shared/home-visual-identity'; +import { Chip } from '../../Shared/homeVisualChip'; +import { + KANBAN_PAGE_COLORS, + KANBAN_PAGE_FONT, + KANBAN_PAGE_TABLER_STROKE, +} from './kanban-page-theme'; + +const Card = styled.div` + background: ${KANBAN_PAGE_COLORS.backgroundSecondary}; + border: 1px solid ${KANBAN_PAGE_COLORS.border}; + border-radius: 4px; + box-shadow: ${KANBAN_PAGE_COLORS.shadow}; + display: flex; + flex-direction: column; + overflow: hidden; +`; + +const CardHeader = styled.div` + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + padding: 8px 8px 4px; +`; + +const CardTitle = styled.span` + color: ${KANBAN_PAGE_COLORS.text}; + font-family: ${KANBAN_PAGE_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const CheckboxContainer = styled.div` + align-items: center; + display: flex; + flex: 0 0 24px; + height: 24px; + justify-content: center; + width: 24px; +`; + +const CheckboxBox = styled.div<{ $checked?: boolean }>` + align-items: center; + background: ${({ $checked }) => + $checked ? KANBAN_PAGE_COLORS.accentSurfaceSoft : 'transparent'}; + border: 1px solid + ${({ $checked }) => + $checked + ? KANBAN_PAGE_COLORS.accentBorder + : KANBAN_PAGE_COLORS.borderStrong}; + border-radius: 3px; + color: ${KANBAN_PAGE_COLORS.textSecondary}; + display: flex; + height: 14px; + justify-content: center; + width: 14px; +`; + +const CardFields = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 8px 4px 10px; +`; + +const FieldRow = styled.div` + align-items: center; + display: flex; + gap: 4px; + min-height: 24px; + width: 100%; +`; + +const FieldIcon = styled.div` + align-items: center; + color: ${KANBAN_PAGE_COLORS.textTertiary}; + display: flex; + flex: 0 0 16px; + height: 16px; + justify-content: center; + width: 16px; +`; + +const FieldValueWrap = styled.div` + align-items: center; + display: flex; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +`; + +const FieldText = styled.span` + color: ${KANBAN_PAGE_COLORS.text}; + font-family: ${KANBAN_PAGE_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.regular}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StarsRow = styled.div` + align-items: center; + display: inline-flex; + gap: 2px; + padding: 0 4px; +`; + +const StarGlyph = styled.span` + align-items: center; + display: inline-flex; + height: 12px; + justify-content: center; + width: 12px; +`; + +function EntityChip({ entity }: { entity: HeroCellEntity }) { + return ( + + } + maxWidth={152} + /> + ); +} + +function PersonChip({ person }: { person: HeroCellPerson }) { + return ( + } + maxWidth={152} + /> + ); +} + +function RatingValue({ rating }: { rating: number }) { + return ( + + {Array.from({ length: 5 }, (_, index) => ( + + + + ))} + + ); +} + +function Checkbox({ checked = false }: { checked?: boolean }) { + return ( + + + {checked ? ( + + ) : null} + + + ); +} + +export function KanbanCard({ card }: { card: HeroKanbanCardType }) { + return ( + + + {card.title} + + + + + + + + + + {card.amount} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {card.date} + + + + + + + + + + + + + + + + + + {card.recordId} + + + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanLane.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanLane.tsx new file mode 100644 index 0000000000..17661482d9 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanLane.tsx @@ -0,0 +1,113 @@ +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { IconPlus } from '@tabler/icons-react'; + +import type { HeroKanbanLaneType } from '@/sections/Hero/types'; +import { KanbanCard } from './KanbanCard'; +import { + KANBAN_LANE_TONES, + KANBAN_PAGE_COLORS, + KANBAN_PAGE_FONT, + KANBAN_PAGE_TABLER_STROKE, +} from './kanban-page-theme'; + +const Lane = styled.div<{ $last?: boolean }>` + border-right: ${({ $last }) => + $last ? 'none' : `1px solid ${KANBAN_PAGE_COLORS.borderLight}`}; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +`; + +const LaneHeader = styled.div` + align-items: center; + display: flex; + gap: 4px; + min-height: 40px; + padding: 8px; +`; + +const LaneTag = styled.span<{ $background: string; $color: string }>` + align-items: center; + background: ${({ $background }) => $background}; + border-radius: 4px; + color: ${({ $color }) => $color}; + display: inline-flex; + font-family: ${KANBAN_PAGE_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + height: 20px; + line-height: 1.4; + padding: 0 8px; + white-space: nowrap; +`; + +const LaneCount = styled.span` + color: ${KANBAN_PAGE_COLORS.textTertiary}; + font-family: ${KANBAN_PAGE_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.regular}; + line-height: 1.4; + white-space: nowrap; +`; + +const LaneBody = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; + min-height: 0; + padding: 0 8px 8px; +`; + +const AddCardButton = styled.div` + align-items: center; + color: ${KANBAN_PAGE_COLORS.textTertiary}; + display: inline-flex; + font-family: ${KANBAN_PAGE_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.regular}; + gap: 4px; + height: 24px; + line-height: 1.4; + padding: 0 4px; + white-space: nowrap; +`; + +export function KanbanLane({ + lane, + isLast, +}: { + isLast: boolean; + lane: HeroKanbanLaneType; +}) { + const tone = KANBAN_LANE_TONES[lane.tone] ?? KANBAN_LANE_TONES.gray; + + return ( + + + + {lane.label} + + {lane.cards.length} + + + + {lane.cards.map((card) => ( + + ))} + + + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanPage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanPage.tsx new file mode 100644 index 0000000000..c766e69150 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanPage.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { styled } from '@linaria/react'; +import type { HeroKanbanPageDefinition } from '@/sections/Hero/types'; +import { KanbanLane } from './KanbanLane'; +import { KANBAN_LANE_WIDTH } from './kanban-page-theme'; + +const BoardShell = styled.div` + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: auto; + scrollbar-width: none; + width: 100%; + + &::-webkit-scrollbar { + display: none; + } +`; + +const BoardCanvas = styled.div<{ $laneCount: number }>` + box-sizing: border-box; + display: grid; + grid-template-columns: repeat( + ${({ $laneCount }) => $laneCount}, + minmax(${KANBAN_LANE_WIDTH}px, 1fr) + ); + min-height: 100%; + min-width: ${({ $laneCount }) => + `max(100%, ${$laneCount * KANBAN_LANE_WIDTH + 16}px)`}; + padding: 0 8px; + width: 100%; +`; + +export function KanbanPage({ page }: { page: HeroKanbanPageDefinition }) { + return ( + + + {page.lanes.map((lane, index) => ( + + ))} + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/kanban-page-theme.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/kanban-page-theme.ts new file mode 100644 index 0000000000..ca1b512d7f --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/kanban-page-theme.ts @@ -0,0 +1,31 @@ +import { VISUAL_TOKENS } from '../../Shared/homeVisualTokens'; + +export const KANBAN_PAGE_FONT = VISUAL_TOKENS.font.family; +export const KANBAN_PAGE_TABLER_STROKE = 1.6; +export const KANBAN_LANE_WIDTH = 206.4; + +export const KANBAN_PAGE_COLORS = { + accentBorder: VISUAL_TOKENS.border.color.blue, + accentSurfaceSoft: VISUAL_TOKENS.background.transparent.blue, + background: VISUAL_TOKENS.background.primary, + backgroundSecondary: VISUAL_TOKENS.background.secondary, + border: VISUAL_TOKENS.border.color.medium, + borderLight: VISUAL_TOKENS.border.color.light, + borderStrong: VISUAL_TOKENS.border.color.strong, + shadow: VISUAL_TOKENS.boxShadow.light, + text: VISUAL_TOKENS.font.color.primary, + textLight: VISUAL_TOKENS.font.color.light, + textSecondary: VISUAL_TOKENS.font.color.secondary, + textTertiary: VISUAL_TOKENS.font.color.tertiary, +} as const; + +export const KANBAN_LANE_TONES: Record< + string, + { background: string; color: string } +> = { + blue: { background: '#def4ff', color: '#007bb8' }, + gray: { background: '#f3f1ef', color: '#666666' }, + green: { background: '#dcf7ed', color: '#1a7f50' }, + pink: { background: '#fce5f3', color: '#d6409f' }, + purple: { background: '#ede9fe', color: '#8e4ec6' }, +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePage.tsx new file mode 100644 index 0000000000..dc57a7bfb1 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePage.tsx @@ -0,0 +1,365 @@ +'use client'; + +import { useHorizontalDragScroll } from '@/lib/dom/use-horizontal-drag-scroll'; +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { useState } from 'react'; +import type { HeroTablePageDefinition } from '@/sections/Hero/types'; +import { TablePageCheckbox } from './TablePageCheckbox'; +import { renderTableCellValue } from './TablePageCellValue'; +import { + ChevronDownMini, + PlusMini, + renderTableHeaderIcon, +} from './TablePageMiniIcons'; +import { + TABLE_PAGE_CELL_HORIZONTAL_PADDING, + TABLE_PAGE_COLORS, + TABLE_PAGE_FONT, +} from './table-page-theme'; + +const APP_FONT = TABLE_PAGE_FONT; +const COLORS = TABLE_PAGE_COLORS; +const TABLE_CELL_HORIZONTAL_PADDING = TABLE_PAGE_CELL_HORIZONTAL_PADDING; + +const TableShell = styled.div` + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: hidden; + width: 100%; +`; + +const GripRail = styled.div` + background: ${COLORS.background}; + display: grid; + flex: 0 0 12px; + grid-auto-rows: 32px; + width: 12px; +`; + +const GripCell = styled.div` + background: ${COLORS.background}; + border-bottom: 1px solid ${COLORS.borderLight}; +`; + +const TableViewport = styled.div<{ $dragging: boolean }>` + cursor: ${({ $dragging }) => ($dragging ? 'grabbing' : 'grab')}; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: none; + width: 100%; + + &::-webkit-scrollbar { + display: none; + } +`; + +const TableCanvas = styled.div<{ $width: number }>` + display: flex; + flex-direction: column; + height: 100%; + min-height: 100%; + min-width: ${({ $width }) => `${$width}px`}; + width: ${({ $width }) => `${$width}px`}; +`; + +const HeaderRow = styled.div` + animation: heroTableHeaderAppear 260ms ease-out both; + display: flex; + + @keyframes heroTableHeaderAppear { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const DataRow = styled.div<{ $rowIndex: number }>` + animation: heroTableRowAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: ${({ $rowIndex }) => `${120 + $rowIndex * 70}ms`}; + display: flex; + + @keyframes heroTableRowAppear { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const FooterRow = styled.div` + display: flex; +`; + +const TableCell = styled.div<{ + $align?: 'left' | 'right'; + $header?: boolean; + $hovered?: boolean; + $sticky?: boolean; + $width: number; +}>` + align-items: center; + background: ${({ $header, $hovered }) => { + if ($header) { + return COLORS.background; + } + + return $hovered ? COLORS.backgroundSecondary : COLORS.background; + }}; + border-bottom: 1px solid ${COLORS.borderLight}; + border-right: 1px solid ${COLORS.borderLight}; + box-sizing: border-box; + display: flex; + flex: 0 0 ${({ $width }) => `${$width}px`}; + height: 32px; + justify-content: ${({ $align }) => + $align === 'right' ? 'flex-end' : 'flex-start'}; + left: ${({ $sticky }) => ($sticky ? '0' : 'auto')}; + min-width: ${({ $width }) => `${$width}px`}; + padding: 0 ${TABLE_CELL_HORIZONTAL_PADDING}px; + position: ${({ $sticky }) => ($sticky ? 'sticky' : 'relative')}; + z-index: ${({ $header, $sticky }) => { + if ($sticky && $header) { + return 6; + } + + if ($sticky) { + return 4; + } + + return 1; + }}; +`; + +const EmptyFillCell = styled.div<{ + $footer?: boolean; + $header?: boolean; + $hovered?: boolean; + $width: number; +}>` + background: ${({ $header, $hovered, $footer }) => { + if ($header || $footer) { + return COLORS.background; + } + + return $hovered ? COLORS.backgroundSecondary : COLORS.background; + }}; + border-bottom: 1px solid ${COLORS.borderLight}; + flex: 0 0 ${({ $width }) => `${$width}px`}; + min-width: ${({ $width }) => `${$width}px`}; +`; + +const HeaderCellContent = styled.div` + align-items: center; + display: flex; + gap: 4px; + height: 100%; + min-width: 0; + width: 100%; +`; + +const HeaderLabel = styled.span` + color: ${COLORS.textTertiary}; + font-family: ${APP_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const EdgePlus = styled.div` + margin-left: auto; +`; + +const MutedText = styled.span` + color: ${COLORS.textTertiary}; + font-family: ${APP_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.regular}; + line-height: 1.4; + white-space: nowrap; +`; + +const FooterFirstContent = styled.div` + align-items: center; + display: inline-flex; + gap: 4px; + padding-left: 28px; +`; + +const HeaderFillContent = styled.div` + align-items: center; + display: flex; + height: 100%; + padding: 0 8px; +`; + +export function TablePage({ + page, + onNavigateToLabel, +}: { + page: HeroTablePageDefinition; + onNavigateToLabel?: (label: string) => void; +}) { + const { + dragging, + onPointerCancel, + onPointerDown, + onPointerLeave, + onPointerMove, + onPointerUp, + viewportRef, + } = useHorizontalDragScroll(); + const [hoveredRowId, setHoveredRowId] = useState(null); + + const columnWidth = page.columns.reduce( + (sum, column) => sum + column.width, + 0, + ); + const totalTableWidth = page.width ?? columnWidth; + const fillerWidth = Math.max(totalTableWidth - columnWidth, 0); + + return ( + + + + + + + {page.columns.map((column) => ( + + + {column.isFirstColumn ? ( + <> + + {renderTableHeaderIcon(column.id)} + {column.label} + + + ) : ( + <> + {renderTableHeaderIcon(column.id)} + {column.label} + + )} + + + ))} + + {fillerWidth > 0 ? ( + + + + ) : null} + + + + {page.rows.map((row, rowIndex) => { + const hovered = hoveredRowId === row.id; + + return ( + setHoveredRowId(row.id)} + onMouseLeave={() => + setHoveredRowId((current) => + current === row.id ? null : current, + ) + } + > + {page.columns.map((column) => { + const cell = row.cells[column.id]; + + return ( + + {cell + ? renderTableCellValue({ + cell, + columnId: column.id, + hovered, + isFirstColumn: !!column.isFirstColumn, + onNavigateToLabel, + }) + : null} + + ); + })} + + + ); + })} + + + {page.columns.length > 0 ? ( + + + Calculate + + + + ) : null} + {page.columns.slice(1).map((column) => ( + + ))} + + + + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageCellValue.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageCellValue.tsx new file mode 100644 index 0000000000..18792cbd6e --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageCellValue.tsx @@ -0,0 +1,347 @@ +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import type { ReactNode } from 'react'; + +import type { + HeroCellEntity, + HeroCellPerson, + HeroCellRelation, + HeroCellText, + HeroCellValue, +} from '@/sections/Hero/types'; +import { + getHomeVisualInitials, + HomeVisualAvatar, + HomeVisualFaviconLogo, + HomeVisualPersonAvatar, +} from '../../Shared/home-visual-identity'; +import { Chip, ChipVariant } from '../../Shared/homeVisualChip'; +import { VISUAL_TOKENS } from '../../Shared/homeVisualTokens'; +import { + CheckMini, + CloseMini, + CopyMini, + PencilMini, +} from './TablePageMiniIcons'; +import { TablePageCheckbox } from './TablePageCheckbox'; +import { + TABLE_PAGE_CELL_HORIZONTAL_PADDING, + TABLE_PAGE_COLORS, + TABLE_PAGE_FONT, + TABLE_PAGE_HOVER_ACTION_EDGE_INSET, +} from './table-page-theme'; + +const ROW_HOVER_ACTION_DISABLED_COLUMNS = new Set([ + 'createdBy', + 'accountOwner', +]); + +const EntityCellLayout = styled.div` + align-items: center; + display: flex; + gap: 4px; + height: 100%; + min-width: 0; + position: relative; + width: 100%; +`; + +const CellHoverAnchor = styled.div` + align-items: center; + display: flex; + height: 100%; + min-width: 0; + position: relative; + width: 100%; +`; + +const CellChip = styled(Chip)` + max-width: 100%; + min-width: 0; +`; + +const InlineText = styled.span` + color: ${TABLE_PAGE_COLORS.text}; + font-family: ${TABLE_PAGE_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.regular}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const RightAlignedText = styled(InlineText)` + text-align: right; + width: 100%; +`; + +const BooleanRow = styled.div` + align-items: center; + display: inline-flex; + gap: 4px; +`; + +const HoverActions = styled.div<{ $rightInset?: number; $visible: boolean }>` + align-items: center; + background: ${VISUAL_TOKENS.background.transparent.primary}; + border: 1px solid ${VISUAL_TOKENS.background.transparent.light}; + border-radius: 4px; + bottom: 4px; + box-sizing: border-box; + box-shadow: ${VISUAL_TOKENS.boxShadow.light}; + display: flex; + gap: 0; + justify-content: center; + opacity: ${({ $visible }) => ($visible ? 1 : 0)}; + padding: 0 4px; + pointer-events: none; + position: absolute; + right: ${({ + $rightInset = TABLE_PAGE_HOVER_ACTION_EDGE_INSET - + TABLE_PAGE_CELL_HORIZONTAL_PADDING, + }) => `${$rightInset}px`}; + top: 4px; + transform: translateX(${({ $visible }) => ($visible ? '0' : '4px')}); + transition: + opacity 0.14s ease, + transform 0.14s ease; + width: 24px; +`; + +const MiniAction = styled.div` + align-items: center; + border-radius: 2px; + color: ${TABLE_PAGE_COLORS.textSecondary}; + display: flex; + height: 16px; + justify-content: center; + width: 16px; +`; + +const TagChip = styled.div` + align-items: center; + background: ${VISUAL_TOKENS.background.transparent.light}; + border-radius: 4px; + color: ${TABLE_PAGE_COLORS.textSecondary}; + display: inline-flex; + font-family: ${TABLE_PAGE_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.regular}; + height: 20px; + line-height: 1.4; + max-width: 100%; + min-width: 0; + overflow: hidden; + padding: 0 8px; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const MultiChipStack = styled.div` + align-items: center; + display: flex; + gap: 4px; + min-width: 0; + overflow: hidden; + width: 100%; +`; + +function PersonTokenCell({ + token, + hovered = false, + withCopyAction = true, +}: { + hovered?: boolean; + token: HeroCellPerson; + withCopyAction?: boolean; +}) { + return ( + + } + /> + + {withCopyAction ? ( + + ) : null} + + + ); +} + +function EntityCellComponent({ + cell, + hovered, + isFirstColumn, +}: { + cell: HeroCellEntity; + hovered: boolean; + isFirstColumn: boolean; +}) { + if (isFirstColumn) { + return ( + + + + } + variant={ChipVariant.Highlighted} + /> + + + + + ); + } + + return ( + + } + /> + ); +} + +function RelationCellComponent({ + cell, + hovered, +}: { + cell: HeroCellRelation; + hovered: boolean; +}) { + return ( + + + {cell.items.map((item) => ( + + {item.shortLabel ?? getHomeVisualInitials(item.name)} + + } + /> + ))} + + + + + + ); +} + +function TextCellComponent({ + cell, + isFirstColumn, + onNavigateToLabel, +}: { + cell: HeroCellText; + isFirstColumn: boolean; + onNavigateToLabel?: (label: string) => void; +}) { + const targetLabel = cell.targetLabel; + const handleNavigate = + targetLabel && onNavigateToLabel + ? () => onNavigateToLabel(targetLabel) + : undefined; + + if (!isFirstColumn || !cell.shortLabel) { + return {cell.value}; + } + + return ( + {cell.shortLabel} + } + onClick={handleNavigate} + /> + ); +} + +export function renderTableCellValue({ + cell, + columnId, + hovered, + isFirstColumn, + onNavigateToLabel, +}: { + cell: HeroCellValue; + columnId: string; + hovered: boolean; + isFirstColumn: boolean; + onNavigateToLabel?: (label: string) => void; +}): ReactNode { + const showHoverAction = !ROW_HOVER_ACTION_DISABLED_COLUMNS.has(columnId); + + switch (cell.type) { + case 'text': + return ( + + ); + case 'number': + return {cell.value}; + case 'link': + return ( +
+ +
+ ); + case 'boolean': + return ( + + {cell.value ? : } + {cell.value ? 'True' : 'False'} + + ); + case 'tag': + return {cell.value}; + case 'person': + return ( + + ); + case 'entity': + return ( + + ); + case 'relation': + return ( + + ); + } +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageCheckbox.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageCheckbox.tsx new file mode 100644 index 0000000000..e261e3ab1d --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageCheckbox.tsx @@ -0,0 +1,44 @@ +import { styled } from '@linaria/react'; +import type { ReactNode } from 'react'; + +import { TABLE_PAGE_COLORS } from './table-page-theme'; + +const CheckboxContainer = styled.div` + align-items: center; + display: flex; + flex: 0 0 24px; + height: 24px; + justify-content: center; + width: 24px; +`; + +const CheckboxBox = styled.div<{ $checked?: boolean }>` + align-items: center; + background: ${({ $checked }) => + $checked ? TABLE_PAGE_COLORS.accentSurfaceSoft : 'transparent'}; + border: 1px solid + ${({ $checked }) => + $checked + ? TABLE_PAGE_COLORS.accentBorder + : TABLE_PAGE_COLORS.borderStrong}; + border-radius: 3px; + display: flex; + flex: 0 0 auto; + height: 14px; + justify-content: center; + width: 14px; +`; + +export function TablePageCheckbox({ + checked, + children, +}: { + checked?: boolean; + children?: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageMiniIcons.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageMiniIcons.tsx new file mode 100644 index 0000000000..da609cc6a5 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageMiniIcons.tsx @@ -0,0 +1,143 @@ +import { + IconBrandLinkedin, + IconBuildingFactory2, + IconCalendarEvent, + IconCheck, + IconChevronDown, + IconCopy, + IconCreativeCommonsSa, + IconLink, + IconMap2, + IconMoneybag, + IconPencil, + IconPlus, + IconTarget, + IconTargetArrow, + IconUser, + IconUserCircle, + IconUsers, + IconX, +} from '@tabler/icons-react'; +import type { ReactNode } from 'react'; + +import { + TABLE_PAGE_COLORS, + TABLE_PAGE_TABLER_STROKE, +} from './table-page-theme'; + +type MiniIconProps = { + color?: string; + size?: number; +}; + +const HEADER_ICON_MAP: Record = { + added: IconCalendarEvent, + accountOwner: IconUserCircle, + address: IconMap2, + arr: IconMoneybag, + createdBy: IconCreativeCommonsSa, + employees: IconUsers, + icp: IconTarget, + industry: IconBuildingFactory2, + linkedin: IconBrandLinkedin, + mainContact: IconUser, + opportunities: IconTargetArrow, + url: IconLink, +}; + +export function PlusMini({ + color = TABLE_PAGE_COLORS.textSecondary, + size = 14, +}: MiniIconProps) { + return ( + + ); +} + +export function CheckMini({ + color = TABLE_PAGE_COLORS.text, + size = 12, +}: MiniIconProps) { + return ( + + ); +} + +export function CloseMini({ + color = TABLE_PAGE_COLORS.text, + size = 12, +}: MiniIconProps) { + return ( + + ); +} + +export function PencilMini({ + color = TABLE_PAGE_COLORS.textSecondary, + size = 14, +}: MiniIconProps) { + return ( + + ); +} + +export function CopyMini({ + color = TABLE_PAGE_COLORS.textSecondary, + size = 14, +}: MiniIconProps) { + return ( + + ); +} + +export function ChevronDownMini({ + color = TABLE_PAGE_COLORS.textTertiary, + size = 14, +}: MiniIconProps) { + return ( + + ); +} + +export function renderTableHeaderIcon(columnId: string): ReactNode { + const Icon = HEADER_ICON_MAP[columnId] ?? IconCalendarEvent; + + return ( + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/table-page-theme.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/table-page-theme.ts new file mode 100644 index 0000000000..2812f1005d --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/table-page-theme.ts @@ -0,0 +1,20 @@ +import { VISUAL_TOKENS } from '../../Shared/homeVisualTokens'; + +export const TABLE_PAGE_FONT = VISUAL_TOKENS.font.family; +export const TABLE_PAGE_CELL_HORIZONTAL_PADDING = 8; +export const TABLE_PAGE_HOVER_ACTION_EDGE_INSET = 4; +export const TABLE_PAGE_TABLER_STROKE = 1.6; + +export const TABLE_PAGE_COLORS = { + accentBorder: VISUAL_TOKENS.border.color.blue, + accentSurface: VISUAL_TOKENS.accent.primary, + accentSurfaceSoft: VISUAL_TOKENS.background.transparent.blue, + background: VISUAL_TOKENS.background.primary, + backgroundSecondary: VISUAL_TOKENS.background.secondary, + border: VISUAL_TOKENS.border.color.medium, + borderLight: VISUAL_TOKENS.border.color.light, + borderStrong: VISUAL_TOKENS.border.color.strong, + text: VISUAL_TOKENS.font.color.primary, + textSecondary: VISUAL_TOKENS.font.color.secondary, + textTertiary: VISUAL_TOKENS.font.color.tertiary, +} as const; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowBranchLabel.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowBranchLabel.tsx new file mode 100644 index 0000000000..8c74913edf --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowBranchLabel.tsx @@ -0,0 +1,34 @@ +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; + +import type { WorkflowBranchLabel as WorkflowBranchLabelDefinition } from './workflow-page-data'; +import { + WORKFLOW_PAGE_COLORS, + WORKFLOW_PAGE_FONT, +} from './workflow-page-theme'; + +const BranchLabel = styled.div` + align-items: center; + background: ${WORKFLOW_PAGE_COLORS.nodeSurface}; + border: 1px solid ${WORKFLOW_PAGE_COLORS.nodeBorder}; + border-radius: 6px; + color: ${WORKFLOW_PAGE_COLORS.textTertiary}; + display: inline-flex; + font-family: ${WORKFLOW_PAGE_FONT}; + font-size: 11px; + font-weight: ${theme.font.weight.medium}; + height: 22px; + justify-content: center; + min-width: 24px; + padding: 0 6px; + position: absolute; + z-index: 2; +`; + +export function WorkflowBranchLabel({ + text, + x, + y, +}: WorkflowBranchLabelDefinition) { + return {text}; +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowEdges.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowEdges.tsx new file mode 100644 index 0000000000..e910ab4c5d --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowEdges.tsx @@ -0,0 +1,66 @@ +import { styled } from '@linaria/react'; + +import type { + WorkflowEdgeDefinition, + WorkflowNodeDefinition, +} from './workflow-page-data'; +import { getWorkflowEdgePath } from './workflow-page-geometry'; +import { + WORKFLOW_CANVAS_HEIGHT, + WORKFLOW_CANVAS_WIDTH, + WORKFLOW_PAGE_COLORS, +} from './workflow-page-theme'; + +const CanvasOverlay = styled.svg` + inset: 0; + overflow: visible; + pointer-events: none; + position: absolute; +`; + +export function WorkflowEdges({ + edges, + nodes, +}: { + edges: ReadonlyArray; + nodes: ReadonlyArray; +}) { + return ( + + + + + + + {edges.map((edge) => ( + + ))} + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowNode.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowNode.tsx new file mode 100644 index 0000000000..bf0c32f437 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowNode.tsx @@ -0,0 +1,95 @@ +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; + +import type { WorkflowNodeDefinition } from './workflow-page-data'; +import { + WORKFLOW_NODE_HEIGHT, + WORKFLOW_PAGE_COLORS, + WORKFLOW_PAGE_FONT, + WORKFLOW_PAGE_TABLER_STROKE, +} from './workflow-page-theme'; + +const Node = styled.div` + align-items: center; + background: ${WORKFLOW_PAGE_COLORS.nodeSurface}; + border: 1px solid ${WORKFLOW_PAGE_COLORS.nodeBorder}; + border-radius: 8px; + box-sizing: border-box; + display: flex; + gap: 8px; + height: ${WORKFLOW_NODE_HEIGHT}px; + left: 0; + padding: 8px; + position: absolute; + top: 0; + z-index: 1; +`; + +const NodeIconContainer = styled.div` + align-items: center; + background: ${WORKFLOW_PAGE_COLORS.nodeIconSurface}; + border-radius: 4px; + display: flex; + flex: 0 0 auto; + height: 32px; + justify-content: center; + width: 32px; +`; + +const NodeContent = styled.div` + align-items: stretch; + align-self: stretch; + display: flex; + flex: 1 1 auto; + flex-direction: column; + justify-content: space-between; + max-width: 184px; + min-width: 0; + padding-bottom: 2px; +`; + +const NodeLabel = styled.div` + color: ${WORKFLOW_PAGE_COLORS.textTertiary}; + font-family: ${WORKFLOW_PAGE_FONT}; + font-size: 11px; + font-weight: 600; + line-height: 1; +`; + +const NodeTitle = styled.div` + color: ${WORKFLOW_PAGE_COLORS.textPrimary}; + font-family: ${WORKFLOW_PAGE_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export function WorkflowNode({ + x, + y, + width, + label, + title, + Icon, + iconColor, +}: WorkflowNodeDefinition) { + return ( + + + + + + {label} + {title} + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowPage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowPage.tsx new file mode 100644 index 0000000000..913fe2d0c8 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/WorkflowPage.tsx @@ -0,0 +1,139 @@ +'use client'; + +import type { HeroWorkflowPageDefinition } from '@/sections/Hero/types'; +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; + +import { WorkflowBranchLabel } from './WorkflowBranchLabel'; +import { WorkflowEdges } from './WorkflowEdges'; +import { WorkflowNode } from './WorkflowNode'; +import { + workflowBranchLabels, + workflowEdges, + workflowNodes, +} from './workflow-page-data'; +import { + WORKFLOW_CANVAS_HEIGHT, + WORKFLOW_CANVAS_TOP_OFFSET, + WORKFLOW_CANVAS_WIDTH, + WORKFLOW_PAGE_COLORS, + WORKFLOW_PAGE_FONT, +} from './workflow-page-theme'; + +const PageShell = styled.div` + background: ${WORKFLOW_PAGE_COLORS.canvasBackground}; + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 100%; + min-width: 100%; +`; + +const CanvasViewportShell = styled.div` + background: ${WORKFLOW_PAGE_COLORS.canvasBackground}; + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + position: relative; +`; + +const CanvasViewport = styled.div` + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: auto; +`; + +const Canvas = styled.div` + background-color: ${WORKFLOW_PAGE_COLORS.canvasBackground}; + background-image: radial-gradient( + circle, + ${WORKFLOW_PAGE_COLORS.canvasDot} 1px, + transparent 1.2px + ); + background-position: 10px 10px; + background-size: 20px 20px; + box-sizing: border-box; + height: ${WORKFLOW_CANVAS_HEIGHT + WORKFLOW_CANVAS_TOP_OFFSET}px; + min-height: 100%; + min-width: 100%; + overflow: hidden; + position: relative; + width: ${WORKFLOW_CANVAS_WIDTH}px; +`; + +const CanvasContent = styled.div` + height: ${WORKFLOW_CANVAS_HEIGHT}px; + left: calc((100% - ${WORKFLOW_CANVAS_WIDTH}px) / 2); + position: absolute; + top: ${WORKFLOW_CANVAS_TOP_OFFSET}px; + width: ${WORKFLOW_CANVAS_WIDTH}px; +`; + +const ActiveBadge = styled.div` + left: 8px; + pointer-events: none; + position: absolute; + top: 8px; + z-index: 3; +`; + +const ActiveBadgeLabel = styled.span` + align-items: center; + background: ${WORKFLOW_PAGE_COLORS.activeBadgeBackground}; + border-radius: 4px; + color: ${WORKFLOW_PAGE_COLORS.activeBadgeText}; + display: inline-flex; + font-family: ${WORKFLOW_PAGE_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.regular}; + height: 20px; + line-height: 1.4; + padding: 0 8px; +`; + +export function WorkflowPage({ page }: { page: HeroWorkflowPageDefinition }) { + return ( + + + + Active + + + + + + + {workflowNodes.map((node) => ( + + ))} + + {workflowBranchLabels.map((label) => ( + + ))} + + + + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-data.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-data.ts new file mode 100644 index 0000000000..67ac35434b --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-data.ts @@ -0,0 +1,190 @@ +import { + IconCode, + IconFilter, + IconPlug, + IconPlus, + IconRepeat, + IconSearch, + IconSitemap, +} from '@tabler/icons-react'; + +import { WORKFLOW_PAGE_COLORS } from './workflow-page-theme'; + +export type WorkflowNodeDefinition = { + Icon: typeof IconPlug; + id: string; + iconColor: string; + label: 'Trigger' | 'Action'; + title: string; + width: number; + x: number; + y: number; +}; + +export type WorkflowBranchLabel = { + text: string; + x: number; + y: number; +}; + +export type WorkflowEdgeDefinition = { + from: string; + to: string; + type: 'branch' | 'curve' | 'vertical'; +}; + +export const workflowNodes: WorkflowNodeDefinition[] = [ + { + id: 'trigger', + x: 370, + y: 80, + width: 238, + label: 'Trigger', + title: 'Record is created or updated', + Icon: IconPlug, + iconColor: WORKFLOW_PAGE_COLORS.nodeTriggerIcon, + }, + { + id: 'is-personal-email', + x: 620, + y: 210, + width: 220, + label: 'Action', + title: 'Is this a personal email?', + Icon: IconCode, + iconColor: WORKFLOW_PAGE_COLORS.nodeActionIcon, + }, + { + id: 'if-business-email', + x: 640, + y: 340, + width: 180, + label: 'Action', + title: 'If business email', + Icon: IconFilter, + iconColor: WORKFLOW_PAGE_COLORS.nodeIconFallback, + }, + { + id: 'extract-domain', + x: 620, + y: 470, + width: 220, + label: 'Action', + title: 'Extract domain from email', + Icon: IconCode, + iconColor: WORKFLOW_PAGE_COLORS.nodeActionIcon, + }, + { + id: 'search-company', + x: 640, + y: 600, + width: 180, + label: 'Action', + title: 'Search Company', + Icon: IconSearch, + iconColor: WORKFLOW_PAGE_COLORS.nodeIconFallback, + }, + { + id: 'find-exact-match', + x: 610, + y: 730, + width: 240, + label: 'Action', + title: 'Find exact company match', + Icon: IconCode, + iconColor: WORKFLOW_PAGE_COLORS.nodeActionIcon, + }, + { + id: 'company-already-exists', + x: 600, + y: 860, + width: 260, + label: 'Action', + title: 'If a company already exists', + Icon: IconSitemap, + iconColor: WORKFLOW_PAGE_COLORS.nodeIconFallback, + }, + { + id: 'attach-existing-company', + x: 370, + y: 990, + width: 240, + label: 'Action', + title: 'Attach person to existing company', + Icon: IconRepeat, + iconColor: WORKFLOW_PAGE_COLORS.nodeIconFallback, + }, + { + id: 'create-company', + x: 840, + y: 990, + width: 220, + label: 'Action', + title: 'Create a new company', + Icon: IconPlus, + iconColor: WORKFLOW_PAGE_COLORS.nodeIconFallback, + }, + { + id: 'attach-created-company', + x: 850, + y: 1120, + width: 240, + label: 'Action', + title: 'Attach person to this company', + Icon: IconRepeat, + iconColor: WORKFLOW_PAGE_COLORS.nodeIconFallback, + }, +]; + +export const workflowEdges: WorkflowEdgeDefinition[] = [ + { + from: 'trigger', + to: 'is-personal-email', + type: 'curve', + }, + { + from: 'is-personal-email', + to: 'if-business-email', + type: 'vertical', + }, + { + from: 'if-business-email', + to: 'extract-domain', + type: 'vertical', + }, + { + from: 'extract-domain', + to: 'search-company', + type: 'vertical', + }, + { + from: 'search-company', + to: 'find-exact-match', + type: 'vertical', + }, + { + from: 'find-exact-match', + to: 'company-already-exists', + type: 'vertical', + }, + { + from: 'company-already-exists', + to: 'attach-existing-company', + type: 'branch', + }, + { + from: 'company-already-exists', + to: 'create-company', + type: 'branch', + }, + { + from: 'create-company', + to: 'attach-created-company', + type: 'vertical', + }, +]; + +export const workflowBranchLabels: WorkflowBranchLabel[] = [ + { x: 566, y: 944, text: 'if' }, + { x: 820, y: 944, text: 'else' }, +]; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.test.ts new file mode 100644 index 0000000000..d1227a2b84 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.test.ts @@ -0,0 +1,59 @@ +import type { WorkflowNodeDefinition } from './workflow-page-data'; +import { + getWorkflowEdgePath, + getWorkflowNodeById, +} from './workflow-page-geometry'; +import { IconPlug } from '@tabler/icons-react'; + +const nodes: WorkflowNodeDefinition[] = [ + { + Icon: IconPlug, + iconColor: '#000000', + id: 'first', + label: 'Trigger', + title: 'First', + width: 100, + x: 10, + y: 20, + }, + { + Icon: IconPlug, + iconColor: '#000000', + id: 'second', + label: 'Action', + title: 'Second', + width: 80, + x: 120, + y: 140, + }, +]; + +describe('workflow-page-geometry', () => { + it('returns workflow nodes by id', () => { + expect(getWorkflowNodeById(nodes, 'first')).toBe(nodes[0]); + }); + + it('throws for unknown workflow nodes', () => { + expect(() => getWorkflowNodeById(nodes, 'missing')).toThrow( + 'Unknown workflow node: missing', + ); + }); + + it('creates vertical edge paths from bottom-center to top-center', () => { + expect( + getWorkflowEdgePath({ + edge: { from: 'first', to: 'second', type: 'vertical' }, + nodes, + }), + ).toBe('M60 69 L160 140'); + }); + + it('creates curved edge paths for non-vertical edges', () => { + expect( + getWorkflowEdgePath({ + edge: { from: 'first', to: 'second', type: 'curve' }, + nodes, + }), + ).toBe('M60 69 C60 97 160 112 160 140'); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.ts new file mode 100644 index 0000000000..c339095b97 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.ts @@ -0,0 +1,59 @@ +import { WORKFLOW_NODE_HEIGHT } from './workflow-page-theme'; +import type { + WorkflowEdgeDefinition, + WorkflowNodeDefinition, +} from './workflow-page-data'; + +type Point = { + x: number; + y: number; +}; + +export function getWorkflowNodeById( + nodes: ReadonlyArray, + nodeId: string, +) { + const node = nodes.find((workflowNode) => workflowNode.id === nodeId); + + if (!node) { + throw new Error(`Unknown workflow node: ${nodeId}`); + } + + return node; +} + +function getNodeTopCenter(node: WorkflowNodeDefinition): Point { + return { + x: node.x + node.width / 2, + y: node.y, + }; +} + +function getNodeBottomCenter(node: WorkflowNodeDefinition): Point { + return { + x: node.x + node.width / 2, + y: node.y + WORKFLOW_NODE_HEIGHT + 1, + }; +} + +export function getWorkflowEdgePath({ + edge, + nodes, +}: { + edge: WorkflowEdgeDefinition; + nodes: ReadonlyArray; +}) { + const fromNode = getWorkflowNodeById(nodes, edge.from); + const toNode = getWorkflowNodeById(nodes, edge.to); + const start = getNodeBottomCenter(fromNode); + const end = getNodeTopCenter(toNode); + + if (edge.type === 'vertical') { + return `M${start.x} ${start.y} L${end.x} ${end.y}`; + } + + const controlStartY = start.y + 28; + const controlEndY = end.y - 28; + + return `M${start.x} ${start.y} C${start.x} ${controlStartY} ${end.x} ${controlEndY} ${end.x} ${end.y}`; +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-theme.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-theme.ts new file mode 100644 index 0000000000..40d68ecb56 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-theme.ts @@ -0,0 +1,24 @@ +import { VISUAL_TOKENS } from '../../Shared/homeVisualTokens'; + +export const WORKFLOW_PAGE_FONT = VISUAL_TOKENS.font.family; +export const WORKFLOW_PAGE_TABLER_STROKE = 1.6; +export const WORKFLOW_CANVAS_WIDTH = 1480; +export const WORKFLOW_CANVAS_HEIGHT = 1260; +export const WORKFLOW_NODE_HEIGHT = 48; +export const WORKFLOW_CANVAS_TOP_OFFSET = 16; + +export const WORKFLOW_PAGE_COLORS = { + activeBadgeBackground: '#dff3e6', + activeBadgeText: '#228b52', + arrowStroke: '#d8d2cb', + canvasBackground: '#ffffff', + canvasDot: '#ebebeb', + nodeActionIcon: '#FF6B5F', + nodeTriggerIcon: '#4A67F6', + nodeIconFallback: VISUAL_TOKENS.font.color.secondary, + nodeBorder: VISUAL_TOKENS.border.color.strong, + nodeSurface: VISUAL_TOKENS.background.secondary, + nodeIconSurface: VISUAL_TOKENS.background.transparent.light, + textPrimary: VISUAL_TOKENS.font.color.primary, + textTertiary: VISUAL_TOKENS.font.color.tertiary, +} as const; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisualLoader.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/HomeVisualLoader.tsx similarity index 100% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisualLoader.tsx rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/HomeVisualLoader.tsx diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/PagePreviewLoader.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/PagePreviewLoader.tsx similarity index 100% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/PagePreviewLoader.tsx rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/PagePreviewLoader.tsx diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-icon.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-icon.tsx new file mode 100644 index 0000000000..57af250d75 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-icon.tsx @@ -0,0 +1,253 @@ +import { styled } from '@linaria/react'; +import { + IconBook, + IconBuildingSkyscraper, + IconCalendarEvent, + IconCheckbox, + IconFolder, + IconLayoutDashboard, + IconLink, + IconMapPin, + IconNotes, + IconPlanet, + IconPlayerPlay, + IconRocket, + IconSettings, + IconSettingsAutomation, + IconTargetArrow, + IconUser, + IconVersions, +} from '@tabler/icons-react'; +import type { ReactNode } from 'react'; + +import type { HeroSidebarIcon } from '@/sections/Hero/types'; +import { HomeVisualFaviconLogo } from './home-visual-identity'; +import { + APP_FONT, + COLORS, + NAVIGATION_TABLER_STROKE, + TABLER_STROKE, +} from './home-visual-theme'; + +const SIDEBAR_TONES: Record< + string, + { background: string; border: string; color: string } +> = { + amber: { background: '#FEF2A4', border: '#FEF2A4', color: '#35290F' }, + blue: { background: '#d9e2fc', border: '#c6d4f9', color: '#3A5CCC' }, + gray: { background: '#ebebeb', border: '#d6d6d6', color: '#838383' }, + green: { background: '#ccebd7', border: '#bbe4c9', color: '#153226' }, + orange: { background: '#ffdcc3', border: '#ffcca7', color: '#ED5F00' }, + pink: { background: '#ffe1e7', border: '#ffc8d6', color: '#a51853' }, + purple: { background: '#e0e7ff', border: '#c7d2fe', color: '#4f46e5' }, + red: { background: '#fdd8d8', border: '#f9c6c6', color: '#DC3D43' }, + teal: { background: '#c7ebe5', border: '#afdfd7', color: '#0E9888' }, + violet: { background: '#ebe5ff', border: '#d8cbff', color: '#5b3fd1' }, +}; + +const hexToRgbTuple = (hex: string): string => { + const clean = hex.replace('#', ''); + const expanded = + clean.length === 3 + ? clean + .split('') + .map((char) => char + char) + .join('') + : clean; + const value = parseInt(expanded, 16); + return `${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}`; +}; + +const SIDEBAR_TONE_RGB: Record = Object.fromEntries( + Object.entries(SIDEBAR_TONES).map(([tone, palette]) => [ + tone, + hexToRgbTuple(palette.color), + ]), +); + +const TABLER_ICON_MAP: Record = { + book: IconBook, + buildingSkyscraper: IconBuildingSkyscraper, + calendarEvent: IconCalendarEvent, + checkbox: IconCheckbox, + folder: IconFolder, + layoutDashboard: IconLayoutDashboard, + mapPin: IconMapPin, + notes: IconNotes, + planet: IconPlanet, + playerPlay: IconPlayerPlay, + rocket: IconRocket, + settings: IconSettings, + settingsAutomation: IconSettingsAutomation, + targetArrow: IconTargetArrow, + user: IconUser, + versions: IconVersions, +}; + +const SidebarIconSurface = styled.div<{ + $background: string; + $border: string; + $color: string; + $pulse?: boolean; +}>` + align-items: center; + animation: ${({ $pulse }) => + $pulse + ? 'heroObjectAppearIcon 1400ms cubic-bezier(0.34, 1.7, 0.64, 1) both' + : 'none'}; + background: ${({ $background }) => $background}; + border: 1px solid ${({ $border }) => $border}; + border-radius: 4px; + color: ${({ $color }) => $color}; + display: flex; + flex: 0 0 auto; + height: 16px; + justify-content: center; + position: relative; + width: 16px; + + @keyframes heroObjectAppearIcon { + 0% { + transform: scale(0.35) rotate(-18deg); + } + 30% { + transform: scale(1.45) rotate(8deg); + } + 55% { + transform: scale(0.9) rotate(-4deg); + } + 80% { + transform: scale(1.06) rotate(2deg); + } + 100% { + transform: scale(1) rotate(0deg); + } + } +`; + +const SidebarAvatar = styled.div<{ + $background: string; + $color: string; + $shape?: 'circle' | 'square'; +}>` + align-items: center; + background: ${({ $background }) => $background}; + border-radius: ${({ $shape }) => ($shape === 'square' ? '4px' : '999px')}; + color: ${({ $color }) => $color}; + display: flex; + flex: 0 0 auto; + font-family: ${APP_FONT}; + font-size: 10px; + font-weight: 500; + height: 16px; + justify-content: center; + line-height: 1; + width: 16px; +`; + +function LinkMini({ + color = COLORS.textTertiary, + size = 16, +}: { + color?: string; + size?: number; +}) { + return ( + + ); +} + +function LinkOverlay() { + return ( +
+ +
+ ); +} + +export function getSidebarIconToneRgb(icon: HeroSidebarIcon): string { + const iconTone = + 'tone' in icon && typeof icon.tone === 'string' ? icon.tone : 'gray'; + + return SIDEBAR_TONE_RGB[iconTone] ?? SIDEBAR_TONE_RGB.gray; +} + +export function renderHomeVisualIcon( + icon: HeroSidebarIcon, + pulse: boolean = false, +): ReactNode { + if (icon.kind === 'brand') { + return ( + + + {icon.overlay === 'link' ? : null} + + ); + } + + if (icon.kind === 'avatar') { + const tone = SIDEBAR_TONES[icon.tone] ?? SIDEBAR_TONES.gray; + + return ( + + {icon.label} + + ); + } + + const tone = SIDEBAR_TONES[icon.tone] ?? SIDEBAR_TONES.gray; + const TablerIcon = TABLER_ICON_MAP[icon.name]; + + return ( + + {TablerIcon ? ( + + ) : null} + {icon.overlay === 'link' ? : null} + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-identity.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-identity.tsx new file mode 100644 index 0000000000..bb0c8ec1b5 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-identity.tsx @@ -0,0 +1,224 @@ +import { getSharedCompanyLogoUrlFromDomainName } from '@/content/site/asset-paths'; +import { createBoundedFailureCache } from '@/lib/visual-runtime'; +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { useState, type ReactNode } from 'react'; + +import type { HeroCellPerson } from '@/sections/Hero/types'; +import { APP_FONT } from './home-visual-theme'; + +const HOME_VISUAL_PERSON_TONES: Record< + string, + { background: string; color: string } +> = { + amber: { background: '#f6e6d7', color: '#7a4f2a' }, + blue: { background: '#dbeafe', color: '#1d4ed8' }, + gray: { background: '#e5e7eb', color: '#4b5563' }, + green: { background: '#dcfce7', color: '#15803d' }, + orange: { background: '#ffdcc3', color: '#ED5F00' }, + pink: { background: '#ffe4e6', color: '#be123c' }, + purple: { background: '#ede9fe', color: '#6d28d9' }, + red: { background: '#fee2e2', color: '#b91c1c' }, + teal: { background: '#ccfbf1', color: '#0f766e' }, +}; + +const failedAvatarUrls = createBoundedFailureCache(256); +const failedFaviconUrls = createBoundedFailureCache(256); + +type HomeVisualPersonIdentity = Pick< + HeroCellPerson, + 'avatarUrl' | 'kind' | 'name' | 'shortLabel' | 'tone' +>; + +const AvatarFrame = styled.div<{ + $background: string; + $color: string; + $size: number; + $square?: boolean; +}>` + align-items: center; + background: ${({ $background }) => $background}; + border-radius: ${({ $square }) => ($square ? '4px' : '999px')}; + color: ${({ $color }) => $color}; + display: flex; + flex: 0 0 auto; + font-family: ${APP_FONT}; + font-size: 10px; + font-weight: ${theme.font.weight.medium}; + height: ${({ $size }) => `${$size}px`}; + justify-content: center; + line-height: 1; + overflow: hidden; + width: ${({ $size }) => `${$size}px`}; +`; + +const AvatarImage = styled.img` + display: block; + height: 100%; + object-fit: cover; + width: 100%; +`; + +const FaviconFrame = styled.div<{ $size: number }>` + align-items: center; + border-radius: 4px; + display: flex; + flex: 0 0 auto; + font-family: ${APP_FONT}; + font-size: ${({ $size }) => ($size <= 14 ? '8px' : '9px')}; + font-weight: 600; + height: ${({ $size }) => `${$size}px`}; + justify-content: center; + line-height: 1; + overflow: hidden; + width: ${({ $size }) => `${$size}px`}; +`; + +const FaviconFallbackFrame = styled(FaviconFrame)` + background: #ebebeb; + color: #666666; +`; + +const FaviconImage = styled.img` + display: block; + height: 100%; + object-fit: contain; + width: 100%; +`; + +export function getHomeVisualInitials(value: string) { + return value + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase(); +} + +function sanitizeURL(link: string | null | undefined) { + return link + ? link.replace(/(https?:\/\/)|(www\.)/g, '').replace(/\/$/, '') + : ''; +} + +function getHomeVisualLogoUrlFromDomainName( + domainName?: string, +): string | undefined { + const sharedLogoUrl = getSharedCompanyLogoUrlFromDomainName(domainName); + + if (sharedLogoUrl) { + return sharedLogoUrl; + } + + const sanitizedDomain = sanitizeURL(domainName); + + return sanitizedDomain + ? `https://twenty-icons.com/${sanitizedDomain}` + : undefined; +} + +export function HomeVisualFaviconLogo({ + domain, + label, + size = 14, + src, +}: { + domain?: string; + label?: string; + size?: number; + src?: string; +}) { + const faviconUrl = src ?? getHomeVisualLogoUrlFromDomainName(domain); + const [localFailedUrl, setLocalFailedUrl] = useState(null); + const showFavicon = + faviconUrl !== undefined && + !failedFaviconUrls.has(faviconUrl) && + localFailedUrl !== faviconUrl; + + if (showFavicon) { + return ( + + { + failedFaviconUrls.add(faviconUrl); + setLocalFailedUrl(faviconUrl); + }} + /> + + ); + } + + const initials = label ? getHomeVisualInitials(label) : '?'; + + return ( + + {initials.slice(0, 1)} + + ); +} + +export function HomeVisualAvatar({ + children, + size = 14, + square = false, + tone = 'gray', +}: { + children: ReactNode; + size?: number; + square?: boolean; + tone?: string; +}) { + const resolvedTone = + HOME_VISUAL_PERSON_TONES[tone] ?? HOME_VISUAL_PERSON_TONES.gray; + + return ( + + {children} + + ); +} + +export function HomeVisualPersonAvatar({ + person, + size = 14, +}: { + person: HomeVisualPersonIdentity; + size?: number; +}) { + const [localFailedUrl, setLocalFailedUrl] = useState(null); + const square = + person.kind === 'api' || + person.kind === 'system' || + person.kind === 'workflow'; + const showAvatar = + person.avatarUrl !== undefined && + !failedAvatarUrls.has(person.avatarUrl) && + localFailedUrl !== person.avatarUrl; + + return ( + + {showAvatar ? ( + { + if (person.avatarUrl) { + failedAvatarUrls.add(person.avatarUrl); + setLocalFailedUrl(person.avatarUrl); + } + }} + /> + ) : ( + (person.shortLabel ?? getHomeVisualInitials(person.name)) + )} + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-theme.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-theme.ts new file mode 100644 index 0000000000..5eaa9d8599 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-theme.ts @@ -0,0 +1,24 @@ +import { VISUAL_TOKENS } from './homeVisualTokens'; + +export const APP_FONT = VISUAL_TOKENS.font.family; + +export const COLORS = { + accent: VISUAL_TOKENS.accent.accent9, + accentBorder: VISUAL_TOKENS.border.color.blue, + accentSurface: VISUAL_TOKENS.accent.primary, + accentSurfaceSoft: VISUAL_TOKENS.background.transparent.blue, + background: VISUAL_TOKENS.background.primary, + backgroundSecondary: VISUAL_TOKENS.background.secondary, + border: VISUAL_TOKENS.border.color.medium, + borderLight: VISUAL_TOKENS.border.color.light, + borderStrong: VISUAL_TOKENS.border.color.strong, + shadow: '0 14px 34px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04)', + text: VISUAL_TOKENS.font.color.primary, + textLight: VISUAL_TOKENS.font.color.light, + textSecondary: VISUAL_TOKENS.font.color.secondary, + textTertiary: VISUAL_TOKENS.font.color.tertiary, +}; + +export const TABLER_STROKE = 1.6; +export const NAVIGATION_TABLER_STROKE = 2; +export const NAVBAR_ACTION_TABLER_STROKE = 2; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/homeVisualChip.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/homeVisualChip.tsx similarity index 100% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/homeVisualChip.tsx rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/homeVisualChip.tsx diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/homeVisualTokens.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/homeVisualTokens.ts similarity index 100% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/homeVisualTokens.ts rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/homeVisualTokens.ts diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/windowShadows.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/windowShadows.ts similarity index 100% rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/windowShadows.ts rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/windowShadows.ts diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualNavbar.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualNavbar.tsx new file mode 100644 index 0000000000..26b5330478 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualNavbar.tsx @@ -0,0 +1,367 @@ +'use client'; + +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { + IconBox, + IconCalendarClock, + IconCalendarEvent, + IconCalendarPlus, + IconChevronDown, + IconChevronUp, + IconDotsVertical, + IconFlag, + IconHeart, + IconPlayerPause, + IconPlus, + IconRepeat, + IconRocket, +} from '@tabler/icons-react'; +import type { ReactNode } from 'react'; + +import type { + HeroNavbarActionType, + HeroSidebarItem, +} from '@/sections/Hero/types'; +import { OBJECT_PINNED_ACTIONS } from '../Data/objectPinnedActions'; +import { renderHomeVisualIcon } from '../Shared/home-visual-icon'; +import { + APP_FONT, + COLORS, + NAVBAR_ACTION_TABLER_STROKE, +} from '../Shared/home-visual-theme'; +import { VISUAL_TOKENS } from '../Shared/homeVisualTokens'; + +const NAVBAR_ACTION_ICON_MAP: Record = { + box: IconBox, + calendarClock: IconCalendarClock, + calendarEvent: IconCalendarEvent, + calendarPlus: IconCalendarPlus, + chevronDown: IconChevronDown, + chevronUp: IconChevronUp, + dotsVertical: IconDotsVertical, + flag: IconFlag, + heart: IconHeart, + playerPause: IconPlayerPause, + plus: IconPlus, + repeat: IconRepeat, + rocket: IconRocket, +}; + +const NavbarBar = styled.div` + align-items: center; + background: transparent; + display: grid; + flex: 0 0 32px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + height: 32px; + min-width: 0; + width: 100%; +`; + +const Breadcrumb = styled.div` + align-items: center; + display: flex; + flex: 1 1 auto; + gap: 2px; + min-width: 0; + overflow: hidden; +`; + +const BreadcrumbTag = styled.div` + align-items: center; + display: flex; + gap: 4px; + height: 20px; + min-width: 0; + padding: 0 2px; +`; + +const CrumbLabel = styled.span` + color: ${COLORS.text}; + font-family: ${APP_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const NavbarActions = styled.div` + align-items: center; + display: flex; + flex: 0 1 auto; + gap: 8px; + justify-self: end; + max-width: 100%; + min-width: 0; + pointer-events: none; +`; + +const DesktopOnlyNavbarAction = styled.div` + display: none; + flex: 0 1 auto; + min-width: 0; + + @media (min-width: ${theme.breakpoints.md}px) { + display: block; + } +`; + +const NAVBAR_ACTION_BORDER = 'rgba(0, 0, 0, 0.08)'; + +const NavbarActionButton = styled.div<{ $iconOnly?: boolean }>` + align-items: center; + background: transparent; + border: 1px solid ${NAVBAR_ACTION_BORDER}; + border-radius: ${VISUAL_TOKENS.border.radius.sm}; + display: inline-flex; + flex: 0 1 auto; + font-family: ${APP_FONT}; + font-size: ${VISUAL_TOKENS.font.size.md}; + font-weight: ${VISUAL_TOKENS.font.weight.medium}; + gap: ${VISUAL_TOKENS.spacing[1]}; + height: 24px; + justify-content: center; + min-width: ${({ $iconOnly }) => ($iconOnly ? '24px' : '0')}; + max-width: 100%; + padding: ${({ $iconOnly }) => + $iconOnly ? '0' : `0 ${VISUAL_TOKENS.spacing[2]}`}; + white-space: nowrap; +`; + +const NavbarActionIconWrap = styled.span<{ $color?: string }>` + align-items: center; + color: ${({ $color }) => $color ?? VISUAL_TOKENS.font.color.secondary}; + display: flex; + flex: 0 0 auto; + justify-content: center; +`; + +const NavbarActionLabel = styled.span<{ $color?: string }>` + color: ${({ $color }) => $color ?? VISUAL_TOKENS.font.color.secondary}; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const DesktopOnlyNavbarTrailing = styled.div` + align-items: center; + display: none; + gap: ${VISUAL_TOKENS.spacing[1]}; + height: 100%; + + @media (min-width: ${theme.breakpoints.md}px) { + display: inline-flex; + } +`; + +const NavbarActionSeparator = styled.div` + background: ${VISUAL_TOKENS.background.transparent.medium}; + border-radius: 56px; + height: 100%; + width: 1px; +`; + +const PinnedActionButton = styled(NavbarActionButton)<{ + $pinnedActionIndex: number; +}>` + animation: pinnedActionIn 340ms cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: calc( + ${({ $pinnedActionIndex }) => $pinnedActionIndex} * 90ms + ); + display: none; + gap: 4px; + padding: 0 6px; + + @media (min-width: ${theme.breakpoints.md}px) { + display: inline-flex; + } + + @keyframes pinnedActionIn { + from { + opacity: 0; + transform: translateY(-6px) scale(0.94); + } + 60% { + opacity: 1; + transform: translateY(1px) scale(1.02); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +`; + +function getNavbarActionToneColor( + tone: HeroNavbarActionType['labelTone'], +): string { + if (tone === 'primary') { + return VISUAL_TOKENS.font.color.primary; + } + + if (tone === 'tertiary') { + return VISUAL_TOKENS.font.color.light; + } + + return VISUAL_TOKENS.font.color.secondary; +} + +function renderNavbarAction( + action: HeroNavbarActionType, + index: number, +): ReactNode { + const ActionIcon = NAVBAR_ACTION_ICON_MAP[action.icon]; + const isIconOnly = + action.variant === 'icon' || (!action.label && !action.trailingLabel); + const labelColor = getNavbarActionToneColor(action.labelTone); + + const button = ( + + {ActionIcon ? ( + + + + ) : null} + {action.label ? ( + + {action.label} + + ) : null} + {action.trailingLabel ? ( + + + + {action.trailingLabel} + + + ) : null} + + ); + + if (action.desktopOnly) { + return ( + + {button} + + ); + } + + return button; +} + +function renderPinnedAction( + action: HeroNavbarActionType, + index: number, + activeItemId: string | undefined, +) { + const Icon = NAVBAR_ACTION_ICON_MAP[action.icon] ?? IconPlus; + + return ( + + + + + {action.label} + + ); +} + +function renderDefaultActions( + activeItem: HeroSidebarItem | undefined, + revealedObjectIds: string[], +) { + const pinnedActions = + activeItem && revealedObjectIds.includes(activeItem.id) + ? OBJECT_PINNED_ACTIONS[activeItem.id] + : undefined; + + return ( + <> + {pinnedActions?.map((action, index) => + renderPinnedAction(action, index, activeItem?.id), + )} + + + + + + New + + + + + + + + + + ⌘K + + + + + ); +} + +export type HomeVisualNavbarProps = { + activeItem?: HeroSidebarItem; + activeLabel: string; + navbarActions?: HeroNavbarActionType[]; + revealedObjectIds: string[]; +}; + +export function HomeVisualNavbar({ + activeItem, + activeLabel, + navbarActions, + revealedObjectIds, +}: HomeVisualNavbarProps) { + return ( + + + + {activeItem ? renderHomeVisualIcon(activeItem.icon) : null} + {activeLabel} + + + + + {navbarActions + ? navbarActions.map(renderNavbarAction) + : renderDefaultActions(activeItem, revealedObjectIds)} + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebar.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebar.tsx new file mode 100644 index 0000000000..a107e4c941 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebar.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import type { ReactNode } from 'react'; + +import type { HeroSidebarEntry, HeroSidebarItem } from '@/sections/Hero/types'; +import { HomeVisualSidebarControls } from './HomeVisualSidebarControls'; +import { HomeVisualSidebarHeader } from './HomeVisualSidebarHeader'; +import { HomeVisualSidebarItem } from './HomeVisualSidebarItem'; +import { isFolder } from './home-visual-navigation'; +import { APP_FONT, COLORS } from '../Shared/home-visual-theme'; + +const SidebarPanel = styled.aside` + background: transparent; + display: grid; + flex: 0 0 48px; + gap: 8px; + grid-template-rows: auto auto minmax(0, 1fr); + min-height: 0; + padding: 8px 4px; + width: 48px; + + @media (min-width: ${theme.breakpoints.md}px) { + flex-basis: 220px; + gap: 12px; + padding: 12px 8px; + width: 220px; + } +`; + +const SidebarScroll = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const SidebarSection = styled.div` + display: grid; + gap: 2px; + padding-bottom: 8px; +`; + +const SidebarSectionLabel = styled.span<{ $workspace?: boolean }>` + color: ${COLORS.textLight}; + display: none; + font-family: ${APP_FONT}; + font-size: 11px; + font-weight: 600; + line-height: 1; + padding: ${({ $workspace }) => ($workspace ? '4px 4px 8px' : '0 4px 4px')}; + + @media (min-width: ${theme.breakpoints.md}px) { + display: block; + } +`; + +export type HomeVisualSidebarProps = { + favoritesNav?: HeroSidebarItem[]; + highlightedItemId?: string; + onSelectLabel: (label: string) => void; + onToggleFolder: (folderId: string) => void; + openFolderIds: string[]; + selectedLabel: string; + workspaceName: string; + workspaceNav: HeroSidebarEntry[]; +}; + +export function HomeVisualSidebar({ + favoritesNav, + highlightedItemId, + onSelectLabel, + onToggleFolder, + openFolderIds, + selectedLabel, + workspaceName, + workspaceNav, +}: HomeVisualSidebarProps) { + const renderSidebarEntry = (entry: HeroSidebarEntry): ReactNode => { + if (isFolder(entry)) { + return ( + onToggleFolder(entry.id)} + selectedLabel={selectedLabel} + /> + ); + } + + return ( + + ); + }; + + return ( + + + + + + + {favoritesNav && favoritesNav.length > 0 ? ( + + Favorites + {favoritesNav.map((item) => ( + + ))} + + ) : null} + + Workspace + {workspaceNav.map(renderSidebarEntry)} + + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebarControls.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebarControls.tsx new file mode 100644 index 0000000000..1a8e8b0c2b --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebarControls.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { + IconHome2, + IconMessageCircle, + IconMessageCirclePlus, +} from '@tabler/icons-react'; + +import { APP_FONT, COLORS, TABLER_STROKE } from '../Shared/home-visual-theme'; + +const SidebarControls = styled.div` + align-items: center; + display: grid; + gap: 8px; + grid-auto-flow: column; + grid-template-columns: auto; + justify-content: center; + min-width: 0; + + @media (min-width: ${theme.breakpoints.md}px) { + display: flex; + gap: 12px; + grid-auto-flow: row; + justify-content: space-between; + } +`; + +const SegmentedRail = styled.div` + background: #fcfcfccc; + border: 1px solid ${COLORS.border}; + border-radius: 40px; + display: none; + gap: 2px; + grid-auto-flow: column; + padding: 3px; + + @media (min-width: ${theme.breakpoints.md}px) { + display: grid; + } +`; + +const Segment = styled.div<{ $selected?: boolean }>` + align-items: center; + background: ${({ $selected }) => ($selected ? '#0000000a' : 'transparent')}; + border-radius: 16px; + display: flex; + height: 22px; + justify-content: center; + width: 22px; + + @media (min-width: ${theme.breakpoints.md}px) { + padding: 0 8px; + width: 32px; + } +`; + +const NewChat = styled.div` + align-items: center; + background: ${COLORS.backgroundSecondary}; + border: 1px solid ${COLORS.border}; + border-radius: 40px; + color: ${COLORS.textSecondary}; + display: flex; + gap: 4px; + height: 28px; + justify-content: center; + min-width: 0; + padding: 3px; + width: 28px; + + @media (min-width: ${theme.breakpoints.md}px) { + width: 103px; + } +`; + +const NewChatLabel = styled.span` + display: none; + font-family: ${APP_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @media (min-width: ${theme.breakpoints.md}px) { + display: block; + } +`; + +type MiniIconProps = { + color?: string; + size?: number; +}; + +function HomeMini({ color = COLORS.textSecondary, size = 16 }: MiniIconProps) { + return ( + + ); +} + +function CommentMini({ + color = COLORS.textTertiary, + size = 16, +}: MiniIconProps) { + return ( + + ); +} + +function MessageCirclePlusMini({ + color = COLORS.textSecondary, + size = 16, +}: MiniIconProps) { + return ( + + ); +} + +export function HomeVisualSidebarControls() { + return ( + + + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebarHeader.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebarHeader.tsx new file mode 100644 index 0000000000..e80b93e99f --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebarHeader.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { + IconChevronDown, + IconLayoutSidebarLeftCollapse, + IconSearch, +} from '@tabler/icons-react'; + +import { APP_FONT, COLORS, TABLER_STROKE } from '../Shared/home-visual-theme'; + +const APPLE_WORKSPACE_LOGO_SRC = '/images/home/hero/apple-rainbow-logo.svg'; + +const SidebarTopBar = styled.div` + align-items: center; + display: grid; + grid-template-columns: minmax(0, 1fr); + min-height: 32px; + + @media (min-width: ${theme.breakpoints.md}px) { + gap: 8px; + grid-template-columns: minmax(0, 1fr) auto; + } +`; + +const WorkspaceMenu = styled.div` + align-items: center; + display: grid; + gap: 4px; + grid-auto-flow: column; + grid-template-columns: auto; + justify-content: center; + min-width: 0; + padding: 6px 4px; + + > svg:last-child { + display: none; + } + + @media (min-width: ${theme.breakpoints.md}px) { + gap: 8px; + grid-auto-flow: row; + grid-template-columns: auto 1fr auto; + justify-content: stretch; + + > svg:last-child { + display: block; + } + } +`; + +const WorkspaceIcon = styled.div` + align-items: center; + display: flex; + flex: 0 0 auto; + height: 16px; + justify-content: center; + width: 16px; +`; + +const WorkspaceIconImage = styled.img` + display: block; + height: 100%; + object-fit: contain; + object-position: center; + width: 100%; +`; + +const WorkspaceLabel = styled.span` + color: ${COLORS.text}; + display: none; + font-family: ${APP_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @media (min-width: ${theme.breakpoints.md}px) { + display: block; + } +`; + +const SidebarTopActions = styled.div` + align-items: center; + display: none; + gap: 2px; + grid-auto-flow: column; + + @media (min-width: ${theme.breakpoints.md}px) { + display: grid; + } +`; + +const SidebarIconButton = styled.div` + align-items: center; + border-radius: 4px; + display: flex; + height: 24px; + justify-content: center; + width: 24px; +`; + +type MiniIconProps = { + color?: string; + size?: number; +}; + +function ChevronDownMini({ + color = COLORS.textTertiary, + size = 14, +}: MiniIconProps) { + return ( + + ); +} + +function SearchMini({ color = COLORS.textTertiary, size = 16 }: MiniIconProps) { + return ( + + ); +} + +function CollapseSidebarMini({ + color = COLORS.textTertiary, + size = 16, +}: MiniIconProps) { + return ( + + ); +} + +export type HomeVisualSidebarHeaderProps = { + workspaceName: string; +}; + +export function HomeVisualSidebarHeader({ + workspaceName, +}: HomeVisualSidebarHeaderProps) { + return ( + + + + + {workspaceName} + + + + + + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebarItem.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebarItem.tsx new file mode 100644 index 0000000000..6069408f29 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualSidebarItem.tsx @@ -0,0 +1,369 @@ +'use client'; + +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { IconChevronDown } from '@tabler/icons-react'; + +import type { HeroSidebarItem } from '@/sections/Hero/types'; +import { + getSidebarIconToneRgb, + renderHomeVisualIcon, +} from '../Shared/home-visual-icon'; +import { APP_FONT, COLORS, TABLER_STROKE } from '../Shared/home-visual-theme'; +import { VISUAL_TOKENS } from '../Shared/homeVisualTokens'; + +const SidebarItemRow = styled.div<{ + $active?: boolean; + $depth?: number; + $interactive?: boolean; + $withBranch?: boolean; + $highlighted?: boolean; + $highlightRgb?: string; +}>` + --hero-highlight-rgb: ${({ $highlightRgb }) => $highlightRgb ?? '237, 95, 0'}; + align-items: center; + background: ${({ $active }) => + $active ? VISUAL_TOKENS.background.transparent.medium : 'transparent'}; + border-radius: 4px; + display: grid; + gap: 0; + grid-template-columns: auto; + justify-content: center; + height: 28px; + padding: 0; + position: relative; + text-decoration: none; + transition: background-color 0.14s ease; + animation: ${({ $highlighted }) => + $highlighted + ? 'heroObjectAppearRow 1800ms cubic-bezier(0.34, 1.56, 0.64, 1) both' + : 'none'}; + transform-origin: left center; + + @media (min-width: ${theme.breakpoints.md}px) { + grid-template-columns: ${({ $withBranch }) => + $withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'}; + justify-content: stretch; + padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`}; + } + + &:hover { + background: ${({ $active, $interactive }) => + $active || $interactive + ? VISUAL_TOKENS.background.transparent.medium + : 'transparent'}; + } + + @keyframes heroObjectAppearRow { + 0% { + background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0); + box-shadow: + 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0), + 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0); + opacity: 0; + transform: translateX(-32px) translateY(-6px) scale(0.6); + } + 16% { + background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.55); + box-shadow: + 0 0 0 6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.4), + 0 12px 28px -6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.55); + opacity: 1; + transform: translateX(0) translateY(0) scale(1.18); + } + 32% { + background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.42); + box-shadow: + 0 0 0 12px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.24), + 0 10px 22px -6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.38); + transform: translateX(0) scale(0.97); + } + 50% { + background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.28); + box-shadow: + 0 0 0 18px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.12), + 0 6px 16px -6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.22); + transform: translateX(0) scale(1.02); + } + 72% { + background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.16); + box-shadow: + 0 0 0 22px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0), + 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0); + transform: translateX(0) scale(1); + } + 100% { + background: ${VISUAL_TOKENS.background.transparent.medium}; + box-shadow: + 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0), + 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0); + transform: translateX(0) scale(1); + } + } +`; + +const SidebarItemRowLink = styled.a<{ + $active?: boolean; + $depth?: number; + $interactive?: boolean; + $withBranch?: boolean; +}>` + align-items: center; + background: ${({ $active }) => + $active ? VISUAL_TOKENS.background.transparent.medium : 'transparent'}; + border-radius: 4px; + display: grid; + gap: 0; + grid-template-columns: auto; + justify-content: center; + height: 28px; + padding: 0; + position: relative; + text-decoration: none; + transition: background-color 0.14s ease; + + @media (min-width: ${theme.breakpoints.md}px) { + grid-template-columns: ${({ $withBranch }) => + $withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'}; + justify-content: stretch; + padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`}; + } + + &:hover { + background: ${({ $active, $interactive }) => + $active || $interactive + ? VISUAL_TOKENS.background.transparent.medium + : 'transparent'}; + } +`; + +const SidebarItemText = styled.div` + display: none; + min-width: 0; + + @media (min-width: ${theme.breakpoints.md}px) { + align-items: center; + display: flex; + gap: 2px; + } +`; + +const SidebarItemLabel = styled.span<{ $active?: boolean }>` + color: ${({ $active }) => ($active ? COLORS.text : COLORS.textSecondary)}; + display: none; + font-family: ${APP_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @media (min-width: ${theme.breakpoints.md}px) { + display: block; + } +`; + +const SidebarItemMeta = styled.span` + color: ${COLORS.textLight}; + display: none; + font-family: ${APP_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @media (min-width: ${theme.breakpoints.md}px) { + display: block; + } +`; + +const SidebarChevron = styled.div<{ $expanded?: boolean }>` + color: ${COLORS.textTertiary}; + display: none; + transform: rotate(${({ $expanded }) => ($expanded ? '0deg' : '-90deg')}); + transition: transform 0.16s ease; + + @media (min-width: ${theme.breakpoints.md}px) { + display: flex; + } +`; + +const SidebarChildStack = styled.div` + display: grid; + gap: 2px; + position: relative; +`; + +const BranchLine = styled.div` + background: ${COLORS.borderStrong}; + bottom: 14px; + left: 11px; + position: absolute; + top: 0; + width: 1px; +`; + +const SidebarBranchCell = styled.div<{ $isLastChild?: boolean }>` + align-self: stretch; + position: relative; + width: 9px; + + &::before { + background: ${COLORS.borderStrong}; + content: ''; + inset: 0 88.89% 0 0; + opacity: ${({ $isLastChild }) => ($isLastChild ? 0 : 1)}; + position: absolute; + } + + &::after { + border-bottom: 1px solid ${COLORS.borderStrong}; + border-left: 1px solid ${COLORS.borderStrong}; + border-radius: 0 0 0 4px; + content: ''; + inset: 0 0 45.83% 0; + position: absolute; + } +`; + +const SidebarRowMain = styled.div<{ $withBranch?: boolean }>` + align-items: center; + display: flex; + gap: 8px; + min-width: 0; + padding-left: ${({ $withBranch }) => ($withBranch ? '4px' : '0')}; +`; + +type ChevronDownMiniProps = { + color?: string; + size?: number; +}; + +function ChevronDownMini({ + color = COLORS.textTertiary, + size = 14, +}: ChevronDownMiniProps) { + return ( + + ); +} + +export type HomeVisualSidebarItemProps = { + collapsible?: boolean; + expanded?: boolean; + depth?: number; + highlightedItemId?: string; + interactive?: boolean; + isLastChild?: boolean; + item: HeroSidebarItem; + onSelect?: (label: string) => void; + onToggleExpanded?: () => void; + selectedLabel?: string; +}; + +export function HomeVisualSidebarItem({ + collapsible = false, + expanded = false, + depth = 0, + highlightedItemId, + interactive = true, + isLastChild = false, + item, + onSelect, + onToggleExpanded, + selectedLabel, +}: HomeVisualSidebarItemProps) { + const showBranch = depth > 0; + const rowSelectable = interactive && item.href === undefined && !collapsible; + const rowInteractive = + rowSelectable || item.href !== undefined || (interactive && collapsible); + const rowActive = + rowSelectable && + selectedLabel !== undefined && + item.label === selectedLabel; + const rowHighlighted = highlightedItemId === item.id; + const childItems = item.children ?? []; + const highlightRgb = getSidebarIconToneRgb(item.icon); + const rowContent = ( + <> + {showBranch ? : null} + + {renderHomeVisualIcon(item.icon, rowHighlighted)} + + {item.label} + {item.meta ? · {item.meta} : null} + + + {item.showChevron || (item.children && item.children.length > 0) ? ( + + + + ) : null} + + ); + + return ( + <> + {item.href ? ( + + {rowContent} + + ) : ( + onSelect?.(item.label) + : undefined + } + style={{ cursor: rowInteractive ? 'pointer' : 'default' }} + > + {rowContent} + + )} + {childItems.length > 0 && (!collapsible || expanded) ? ( + + + {childItems.map((child, index) => ( + + ))} + + ) : null} + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualViewbar.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualViewbar.tsx new file mode 100644 index 0000000000..54042c469c --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/HomeVisualViewbar.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { + IconChevronDown, + IconLayoutKanban, + IconList, +} from '@tabler/icons-react'; + +import type { HeroPageType } from '@/sections/Hero/types'; +import { APP_FONT, COLORS, TABLER_STROKE } from '../Shared/home-visual-theme'; + +const ViewbarBar = styled.div` + align-items: center; + background: ${COLORS.background}; + border-bottom: 1px solid ${COLORS.borderLight}; + display: flex; + justify-content: space-between; + min-width: 0; + padding: 8px 8px 8px 12px; + width: 100%; +`; + +const ViewSwitcher = styled.div` + align-items: center; + display: flex; + flex: 1 1 auto; + gap: 4px; + height: 24px; + min-width: 0; + overflow: hidden; + padding: 0 4px; +`; + +const ViewName = styled.span` + color: ${COLORS.textSecondary}; + font-family: ${APP_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + line-height: 1.4; + white-space: nowrap; +`; + +const ViewCount = styled.span` + color: ${COLORS.textLight}; + font-family: ${APP_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.medium}; + line-height: 1.4; + white-space: nowrap; +`; + +const TinyDot = styled.div` + background: ${COLORS.borderStrong}; + border-radius: 999px; + height: 2px; + width: 2px; +`; + +const ViewActions = styled.div` + align-items: center; + display: none; + flex: 0 0 auto; + gap: 2px; + margin-left: auto; + position: relative; + z-index: 1; + + @media (min-width: ${theme.breakpoints.md}px) { + display: flex; + } +`; + +const ViewAction = styled.span` + align-items: center; + border-radius: 4px; + color: ${COLORS.textSecondary}; + display: flex; + font-family: ${APP_FONT}; + font-size: 13px; + font-weight: ${theme.font.weight.regular}; + height: 24px; + line-height: 1.4; + padding: 4px 8px; + white-space: nowrap; +`; + +type MiniIconProps = { + color?: string; + size?: number; +}; + +function ChevronDownMini({ + color = COLORS.textTertiary, + size = 14, +}: MiniIconProps) { + return ( + + ); +} + +function ListMini({ color = COLORS.textSecondary, size = 16 }: MiniIconProps) { + return ( + + ); +} + +function KanbanMini({ + color = COLORS.textSecondary, + size = 16, +}: MiniIconProps) { + return ( + + ); +} + +export type HomeVisualViewbarProps = { + actions: string[]; + count?: number; + pageType: HeroPageType; + showListIcon: boolean; + title: string; +}; + +export function HomeVisualViewbar({ + actions, + count, + pageType, + showListIcon, + title, +}: HomeVisualViewbarProps) { + const showPageCount = count !== undefined; + + return ( + + + {actions.length > 0 ? ( + + {actions.map((action) => ( + {action} + ))} + + ) : null} + + ); +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/home-visual-navigation.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/home-visual-navigation.ts new file mode 100644 index 0000000000..d19169c9a9 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/home-visual-navigation.ts @@ -0,0 +1,89 @@ +import type { + HeroSidebarEntry, + HeroSidebarFolder, + HeroSidebarItem, +} from '@/sections/Hero/types'; +import { + normalizeHeroPage, + type HeroPageDefaults, +} from '../Data/normalizeHeroPage'; + +export function isFolder(entry: HeroSidebarEntry): entry is HeroSidebarFolder { + return 'items' in entry; +} + +function hasRenderablePage( + item: HeroSidebarItem, + pageDefaults: HeroPageDefaults, +): boolean { + return normalizeHeroPage(item, pageDefaults) !== null; +} + +export function findActiveItem( + entries: HeroSidebarEntry[], + activeLabel: string, + pageDefaults: HeroPageDefaults, +): HeroSidebarItem | undefined { + for (const entry of entries) { + if (isFolder(entry)) { + for (const child of entry.items) { + if (child.label === activeLabel) { + return child; + } + } + + continue; + } + + if (entry.children) { + for (const child of entry.children) { + if (child.label === activeLabel) { + return child; + } + } + } + + if (entry.label === activeLabel) { + if ( + !hasRenderablePage(entry, pageDefaults) && + entry.children && + entry.children.length > 0 + ) { + const firstChildWithRenderablePage = entry.children.find((child) => + hasRenderablePage(child, pageDefaults), + ); + + if (firstChildWithRenderablePage) { + return firstChildWithRenderablePage; + } + } + + return entry; + } + } + + return undefined; +} + +export function findContainingFolderId( + entries: HeroSidebarEntry[], + label: string, +): string | undefined { + for (const entry of entries) { + if (!isFolder(entry)) { + continue; + } + + if ( + entry.items.some( + (item) => + item.label === label || + item.children?.some((child) => child.label === label) === true, + ) + ) { + return entry.id; + } + } + + return undefined; +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/home-visual-page-renderers.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/home-visual-page-renderers.tsx new file mode 100644 index 0000000000..58fb741a9d --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/home-visual-page-renderers.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { styled } from '@linaria/react'; +import dynamic from 'next/dynamic'; +import type { ReactNode } from 'react'; + +import type { + HeroDashboardPageDefinition, + HeroKanbanPageDefinition, + HeroPageDefinition, + HeroPageType, + HeroTablePageDefinition, + HeroWorkflowPageDefinition, +} from '@/sections/Hero/types'; +import { KanbanPage } from '../Pages/Kanban/KanbanPage'; +import { TablePage } from '../Pages/Table/TablePage'; +import { PagePreviewLoader } from '../Shared/PagePreviewLoader'; + +const DashboardViewport = styled.div` + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: auto; + scrollbar-width: none; + width: 100%; + + &::-webkit-scrollbar { + display: none; + } +`; + +const SalesDashboardPage = dynamic( + () => + import('../Pages/Dashboard/SalesDashboardPage').then((mod) => ({ + default: mod.SalesDashboardPage, + })), + { + loading: () => ( + + ), + ssr: false, + }, +); + +const WorkflowPage = dynamic( + () => + import('../Pages/Workflow/WorkflowPage').then((mod) => ({ + default: mod.WorkflowPage, + })), + { + loading: () => , + ssr: false, + }, +); + +const PAGE_RENDERERS = { + table: (page: HeroTablePageDefinition) => , + kanban: (page: HeroKanbanPageDefinition) => , + dashboard: (page: HeroDashboardPageDefinition) => ( + + + + ), + workflow: (page: HeroWorkflowPageDefinition) => , +} satisfies { + [K in HeroPageType]: ( + page: Extract, + ) => ReactNode; +}; + +export function renderPageDefinition( + page: HeroPageDefinition, + onNavigateToLabel?: (label: string) => void, + pageKey?: string, +) { + switch (page.type) { + case 'table': + return ( + + ); + case 'kanban': + return PAGE_RENDERERS.kanban(page); + case 'dashboard': + return PAGE_RENDERERS.dashboard(page); + case 'workflow': + return PAGE_RENDERERS.workflow(page); + } +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/use-home-visual-state.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/use-home-visual-state.ts new file mode 100644 index 0000000000..f816e0991a --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shell/use-home-visual-state.ts @@ -0,0 +1,188 @@ +import { useTimeoutRegistry } from '@/lib/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { HeroSidebarEntry, HeroVisualType } from '@/sections/Hero/types'; +import { normalizeHeroPage } from '../Data/normalizeHeroPage'; +import { + findActiveItem, + findContainingFolderId, + isFolder, +} from './home-visual-navigation'; +import { + COMPANIES_ITEM_ID, + COMPANIES_ITEM_LABEL, + CRM_OBJECT_SEQUENCE, +} from '../Data/rocketObject'; + +const DEFAULT_TABLE_WIDTH = 1700; +const COMPLETED_CREATED_OBJECT_IDS = CRM_OBJECT_SEQUENCE.map(({ id }) => id); +const COMPLETED_REVEALED_OBJECT_IDS = [ + ...COMPLETED_CREATED_OBJECT_IDS, + COMPANIES_ITEM_ID, +]; +const COMPLETED_ACTIVE_OBJECT_LABEL = + CRM_OBJECT_SEQUENCE.at(-1)?.label ?? COMPANIES_ITEM_LABEL; +const HIGHLIGHT_RESET_DELAY_MS = 2000; + +export function useHomeVisualState(visual: HeroVisualType) { + const timeoutRegistry = useTimeoutRegistry(); + const defaultActiveLabel = + visual.favoritesNav?.find((item) => item.active)?.label ?? + visual.workspaceNav.find((entry) => !isFolder(entry) && entry.active) + ?.label ?? + visual.workspaceNav[0]?.label ?? + ''; + + const [activeLabel, setActiveLabel] = useState(defaultActiveLabel); + const [createdObjectIds, setCreatedObjectIds] = useState([]); + const [revealedObjectIds, setRevealedObjectIds] = useState([]); + const [highlightedItemId, setHighlightedItemId] = useState( + null, + ); + const [openFolderIds, setOpenFolderIds] = useState(() => { + const activeFolderId = findContainingFolderId( + visual.workspaceNav, + defaultActiveLabel, + ); + + return visual.workspaceNav.flatMap((entry) => { + if (!isFolder(entry)) { + return []; + } + + if (entry.defaultOpen || entry.id === activeFolderId) { + return [entry.id]; + } + + return []; + }); + }); + + const pageDefaults = useMemo( + () => ({ + defaultActions: visual.actions ?? [], + defaultTableWidth: visual.tableWidth ?? DEFAULT_TABLE_WIDTH, + }), + [visual.actions, visual.tableWidth], + ); + + const workspaceNav = useMemo(() => { + if (createdObjectIds.length === 0) { + return visual.workspaceNav; + } + + const prepended = [...createdObjectIds] + .reverse() + .map( + (id) => + CRM_OBJECT_SEQUENCE.find((entry) => entry.id === id)?.sidebarItem, + ) + .filter((item): item is NonNullable => item !== undefined); + + return [...prepended, ...visual.workspaceNav]; + }, [createdObjectIds, visual.workspaceNav]); + + const handleObjectCreated = useCallback((id: string) => { + setRevealedObjectIds((current) => + current.includes(id) ? current : [...current, id], + ); + + if (id === COMPANIES_ITEM_ID) { + setActiveLabel(COMPANIES_ITEM_LABEL); + setHighlightedItemId(COMPANIES_ITEM_ID); + return; + } + + const entry = CRM_OBJECT_SEQUENCE.find((candidate) => candidate.id === id); + + if (!entry) { + return; + } + + setCreatedObjectIds((current) => + current.includes(id) ? current : [...current, id], + ); + setActiveLabel(entry.label); + setHighlightedItemId(entry.id); + }, []); + + const handleChatReset = useCallback(() => { + setCreatedObjectIds([]); + setRevealedObjectIds([]); + setHighlightedItemId(null); + setActiveLabel(defaultActiveLabel); + }, [defaultActiveLabel]); + + const handleJumpToConversationEnd = useCallback(() => { + setCreatedObjectIds(COMPLETED_CREATED_OBJECT_IDS); + setRevealedObjectIds(COMPLETED_REVEALED_OBJECT_IDS); + setHighlightedItemId(null); + setActiveLabel(COMPLETED_ACTIVE_OBJECT_LABEL); + }, []); + + useEffect(() => { + if (highlightedItemId === null) { + return undefined; + } + + return timeoutRegistry.schedule( + () => setHighlightedItemId(null), + HIGHLIGHT_RESET_DELAY_MS, + ); + }, [highlightedItemId, timeoutRegistry]); + + const activeItem = useMemo( + () => + (visual.favoritesNav + ? findActiveItem(visual.favoritesNav, activeLabel, pageDefaults) + : undefined) ?? findActiveItem(workspaceNav, activeLabel, pageDefaults), + [activeLabel, pageDefaults, visual.favoritesNav, workspaceNav], + ); + + const activePage = useMemo( + () => (activeItem ? normalizeHeroPage(activeItem, pageDefaults) : null), + [activeItem, pageDefaults], + ); + + const handleSelectLabel = useCallback( + (label: string) => { + setActiveLabel(label); + + const containingFolderId = findContainingFolderId(workspaceNav, label); + + if (!containingFolderId) { + return; + } + + setOpenFolderIds((current) => + current.includes(containingFolderId) + ? current + : [...current, containingFolderId], + ); + }, + [workspaceNav], + ); + + const handleToggleFolder = useCallback((folderId: string) => { + setOpenFolderIds((current) => + current.includes(folderId) + ? current.filter((id) => id !== folderId) + : [...current, folderId], + ); + }, []); + + return { + activeItem, + activeLabel, + activePage, + handleChatReset, + handleJumpToConversationEnd, + handleObjectCreated, + handleSelectLabel, + handleToggleFolder, + highlightedItemId, + openFolderIds, + revealedObjectIds, + workspaceNav, + }; +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/TablePage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/TablePage.tsx deleted file mode 100644 index 010ab7e655..0000000000 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/TablePage.tsx +++ /dev/null @@ -1,1055 +0,0 @@ -'use client'; - -import { getSharedCompanyLogoUrlFromDomainName } from '@/content/site/asset-paths'; -import { createBoundedFailureCache } from '@/lib/visual-runtime'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import { - IconBrandLinkedin, - IconBuildingFactory2, - IconCalendarEvent, - IconCheck, - IconChevronDown, - IconCopy, - IconCreativeCommonsSa, - IconLink, - IconMap2, - IconMoneybag, - IconPencil, - IconPlus, - IconTarget, - IconTargetArrow, - IconUser, - IconUserCircle, - IconUsers, - IconX, -} from '@tabler/icons-react'; -import { - useEffect, - useRef, - useState, - type PointerEvent as ReactPointerEvent, - type ReactNode, -} from 'react'; -import type { - HeroCellEntity, - HeroCellPerson, - HeroCellRelation, - HeroCellText, - HeroCellValue, - HeroTablePageDefinition, -} from '../../types/HeroHomeData'; -import { Chip, ChipVariant } from './homeVisualChip'; -import { VISUAL_TOKENS } from './homeVisualTokens'; - -const APP_FONT = VISUAL_TOKENS.font.family; -const TABLE_CELL_HORIZONTAL_PADDING = 8; -const HOVER_ACTION_EDGE_INSET = 4; -const TABLER_STROKE = 1.6; - -const COLORS = { - accentBorder: VISUAL_TOKENS.border.color.blue, - accentSurface: VISUAL_TOKENS.accent.primary, - accentSurfaceSoft: VISUAL_TOKENS.background.transparent.blue, - background: VISUAL_TOKENS.background.primary, - backgroundSecondary: VISUAL_TOKENS.background.secondary, - border: VISUAL_TOKENS.border.color.medium, - borderLight: VISUAL_TOKENS.border.color.light, - borderStrong: VISUAL_TOKENS.border.color.strong, - text: VISUAL_TOKENS.font.color.primary, - textSecondary: VISUAL_TOKENS.font.color.secondary, - textTertiary: VISUAL_TOKENS.font.color.tertiary, -}; - -const PERSON_TONES: Record = { - amber: { background: '#f6e6d7', color: '#7a4f2a' }, - blue: { background: '#dbeafe', color: '#1d4ed8' }, - gray: { background: '#e5e7eb', color: '#4b5563' }, - green: { background: '#dcfce7', color: '#15803d' }, - orange: { background: '#ffdcc3', color: '#ED5F00' }, - pink: { background: '#ffe4e6', color: '#be123c' }, - purple: { background: '#ede9fe', color: '#6d28d9' }, - red: { background: '#fee2e2', color: '#b91c1c' }, - teal: { background: '#ccfbf1', color: '#0f766e' }, -}; - -const ROW_HOVER_ACTION_DISABLED_COLUMNS = new Set([ - 'createdBy', - 'accountOwner', -]); - -const HEADER_ICON_MAP: Record = { - added: IconCalendarEvent, - accountOwner: IconUserCircle, - address: IconMap2, - arr: IconMoneybag, - createdBy: IconCreativeCommonsSa, - employees: IconUsers, - icp: IconTarget, - industry: IconBuildingFactory2, - linkedin: IconBrandLinkedin, - mainContact: IconUser, - opportunities: IconTargetArrow, - url: IconLink, -}; - -const failedAvatarUrls = createBoundedFailureCache(256); -const failedFaviconUrls = createBoundedFailureCache(256); - -type MiniIconProps = { - color?: string; - size?: number; -}; - -const TableShell = styled.div` - display: flex; - flex: 1 1 auto; - min-height: 0; - min-width: 0; - overflow: hidden; - width: 100%; -`; - -const GripRail = styled.div` - background: ${COLORS.background}; - display: grid; - flex: 0 0 12px; - grid-auto-rows: 32px; - width: 12px; -`; - -const GripCell = styled.div` - background: ${COLORS.background}; - border-bottom: 1px solid ${COLORS.borderLight}; -`; - -const TableViewport = styled.div<{ $dragging: boolean }>` - cursor: ${({ $dragging }) => ($dragging ? 'grabbing' : 'grab')}; - flex: 1 1 auto; - min-height: 0; - min-width: 0; - overflow-x: auto; - overflow-y: hidden; - overscroll-behavior-x: contain; - scrollbar-width: none; - width: 100%; - - &::-webkit-scrollbar { - display: none; - } -`; - -const TableCanvas = styled.div<{ $width: number }>` - display: flex; - flex-direction: column; - height: 100%; - min-height: 100%; - min-width: ${({ $width }) => `${$width}px`}; - width: ${({ $width }) => `${$width}px`}; -`; - -const HeaderRow = styled.div` - animation: heroTableHeaderAppear 260ms ease-out both; - display: flex; - - @keyframes heroTableHeaderAppear { - from { - opacity: 0; - transform: translateY(-2px); - } - to { - opacity: 1; - transform: translateY(0); - } - } -`; - -const DataRow = styled.div<{ $rowIndex: number }>` - animation: heroTableRowAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both; - animation-delay: ${({ $rowIndex }) => `${120 + $rowIndex * 70}ms`}; - display: flex; - - @keyframes heroTableRowAppear { - from { - opacity: 0; - transform: translateY(6px); - } - to { - opacity: 1; - transform: translateY(0); - } - } -`; - -const FooterRow = styled.div` - display: flex; -`; - -const TableCell = styled.div<{ - $align?: 'left' | 'right'; - $header?: boolean; - $hovered?: boolean; - $sticky?: boolean; - $width: number; -}>` - align-items: center; - background: ${({ $header, $hovered }) => { - if ($header) { - return COLORS.background; - } - - return $hovered ? COLORS.backgroundSecondary : COLORS.background; - }}; - border-bottom: 1px solid ${COLORS.borderLight}; - border-right: 1px solid ${COLORS.borderLight}; - box-sizing: border-box; - display: flex; - flex: 0 0 ${({ $width }) => `${$width}px`}; - height: 32px; - justify-content: ${({ $align }) => - $align === 'right' ? 'flex-end' : 'flex-start'}; - left: ${({ $sticky }) => ($sticky ? '0' : 'auto')}; - min-width: ${({ $width }) => `${$width}px`}; - padding: 0 ${TABLE_CELL_HORIZONTAL_PADDING}px; - position: ${({ $sticky }) => ($sticky ? 'sticky' : 'relative')}; - z-index: ${({ $header, $sticky }) => { - if ($sticky && $header) { - return 6; - } - - if ($sticky) { - return 4; - } - - return 1; - }}; -`; - -const EmptyFillCell = styled.div<{ - $footer?: boolean; - $header?: boolean; - $hovered?: boolean; - $width: number; -}>` - background: ${({ $header, $hovered, $footer }) => { - if ($header || $footer) { - return COLORS.background; - } - - return $hovered ? COLORS.backgroundSecondary : COLORS.background; - }}; - border-bottom: 1px solid ${COLORS.borderLight}; - flex: 0 0 ${({ $width }) => `${$width}px`}; - min-width: ${({ $width }) => `${$width}px`}; -`; - -const HeaderCellContent = styled.div` - align-items: center; - display: flex; - gap: 4px; - height: 100%; - min-width: 0; - width: 100%; -`; - -const HeaderLabel = styled.span` - color: ${COLORS.textTertiary}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const EdgePlus = styled.div` - margin-left: auto; -`; - -const CheckboxContainer = styled.div` - align-items: center; - display: flex; - flex: 0 0 24px; - height: 24px; - justify-content: center; - width: 24px; -`; - -const CheckboxBox = styled.div<{ $checked?: boolean }>` - align-items: center; - background: ${({ $checked }) => - $checked ? COLORS.accentSurfaceSoft : 'transparent'}; - border: 1px solid - ${({ $checked }) => ($checked ? COLORS.accentBorder : COLORS.borderStrong)}; - border-radius: 3px; - display: flex; - flex: 0 0 auto; - height: 14px; - justify-content: center; - width: 14px; -`; - -const EntityCellLayout = styled.div` - align-items: center; - display: flex; - gap: 4px; - height: 100%; - min-width: 0; - position: relative; - width: 100%; -`; - -const CellHoverAnchor = styled.div` - align-items: center; - display: flex; - height: 100%; - min-width: 0; - position: relative; - width: 100%; -`; - -const CellChip = styled(Chip)` - max-width: 100%; - min-width: 0; -`; - -const InlineText = styled.span` - color: ${COLORS.text}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - line-height: 1.4; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const MutedText = styled.span` - color: ${COLORS.textTertiary}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - line-height: 1.4; - white-space: nowrap; -`; - -const RightAlignedText = styled(InlineText)` - text-align: right; - width: 100%; -`; - -const PersonAvatarCircle = styled.div<{ - $background: string; - $color: string; - $square?: boolean; -}>` - align-items: center; - background: ${({ $background }) => $background}; - border-radius: ${({ $square }) => ($square ? '4px' : '999px')}; - color: ${({ $color }) => $color}; - display: flex; - flex: 0 0 auto; - font-family: ${APP_FONT}; - font-size: 10px; - font-weight: ${theme.font.weight.medium}; - height: 14px; - justify-content: center; - overflow: hidden; - width: 14px; -`; - -const AvatarImage = styled.img` - display: block; - height: 100%; - object-fit: cover; - width: 100%; -`; - -const BooleanRow = styled.div` - align-items: center; - display: inline-flex; - gap: 4px; -`; - -const HoverActions = styled.div<{ $rightInset?: number; $visible: boolean }>` - align-items: center; - background: ${VISUAL_TOKENS.background.transparent.primary}; - border: 1px solid ${VISUAL_TOKENS.background.transparent.light}; - border-radius: 4px; - bottom: 4px; - box-sizing: border-box; - box-shadow: ${VISUAL_TOKENS.boxShadow.light}; - display: flex; - gap: 0; - justify-content: center; - opacity: ${({ $visible }) => ($visible ? 1 : 0)}; - padding: 0 4px; - pointer-events: none; - position: absolute; - right: ${({ - $rightInset = HOVER_ACTION_EDGE_INSET - TABLE_CELL_HORIZONTAL_PADDING, - }) => `${$rightInset}px`}; - top: 4px; - transform: translateX(${({ $visible }) => ($visible ? '0' : '4px')}); - transition: - opacity 0.14s ease, - transform 0.14s ease; - width: 24px; -`; - -const MiniAction = styled.div` - align-items: center; - border-radius: 2px; - color: ${COLORS.textSecondary}; - display: flex; - height: 16px; - justify-content: center; - width: 16px; -`; - -const FooterFirstContent = styled.div` - align-items: center; - display: inline-flex; - gap: 4px; - padding-left: 28px; -`; - -const HeaderFillContent = styled.div` - align-items: center; - display: flex; - height: 100%; - padding: 0 8px; -`; - -const TagChip = styled.div` - align-items: center; - background: ${VISUAL_TOKENS.background.transparent.light}; - border-radius: 4px; - color: ${COLORS.textSecondary}; - display: inline-flex; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - height: 20px; - line-height: 1.4; - max-width: 100%; - min-width: 0; - overflow: hidden; - padding: 0 8px; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const MultiChipStack = styled.div` - align-items: center; - display: flex; - gap: 4px; - min-width: 0; - overflow: hidden; - width: 100%; -`; - -const FaviconImage = styled.img` - display: block; - height: 100%; - object-fit: contain; - width: 100%; -`; - -function getInitials(value: string) { - return value - .split(' ') - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0]) - .join('') - .toUpperCase(); -} - -function sanitizeURL(link: string | null | undefined) { - return link - ? link.replace(/(https?:\/\/)|(www\.)/g, '').replace(/\/$/, '') - : ''; -} - -function getLogoUrlFromDomainName(domainName?: string): string | undefined { - const sharedLogoUrl = getSharedCompanyLogoUrlFromDomainName(domainName); - - if (sharedLogoUrl) { - return sharedLogoUrl; - } - - const sanitizedDomain = sanitizeURL(domainName); - - return sanitizedDomain - ? `https://twenty-icons.com/${sanitizedDomain}` - : undefined; -} - -function PlusMini({ color = COLORS.textSecondary, size = 14 }: MiniIconProps) { - return ( - - ); -} - -function CheckMini({ color = COLORS.text, size = 12 }: MiniIconProps) { - return ( - - ); -} - -function CloseMini({ color = COLORS.text, size = 12 }: MiniIconProps) { - return ; -} - -function PencilMini({ - color = COLORS.textSecondary, - size = 14, -}: MiniIconProps) { - return ( - - ); -} - -function CopyMini({ color = COLORS.textSecondary, size = 14 }: MiniIconProps) { - return ( - - ); -} - -function ChevronDownMini({ - color = COLORS.textTertiary, - size = 14, -}: MiniIconProps) { - return ( - - ); -} - -function FaviconLogo({ - src, - domain, - label, - size = 14, -}: { - domain?: string; - label?: string; - size?: number; - src?: string; -}) { - const faviconUrl = src ?? getLogoUrlFromDomainName(domain); - const [localFailedUrl, setLocalFailedUrl] = useState(null); - const showFavicon = - faviconUrl !== undefined && - !failedFaviconUrls.has(faviconUrl) && - localFailedUrl !== faviconUrl; - - const baseStyle = { - width: `${size}px`, - height: `${size}px`, - borderRadius: '4px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flex: '0 0 auto', - overflow: 'hidden', - fontFamily: APP_FONT, - fontSize: size <= 14 ? '8px' : '9px', - fontWeight: 600, - lineHeight: 1, - } as const; - - if (showFavicon) { - return ( -
- { - failedFaviconUrls.add(faviconUrl); - setLocalFailedUrl(faviconUrl); - }} - /> -
- ); - } - - const initials = label ? getInitials(label) : '?'; - - return ( -
- {initials.slice(0, 1)} -
- ); -} - -function PersonAvatarContent({ token }: { token: HeroCellPerson }) { - const [localFailedUrl, setLocalFailedUrl] = useState(null); - const showAvatar = - token.avatarUrl !== undefined && - !failedAvatarUrls.has(token.avatarUrl) && - localFailedUrl !== token.avatarUrl; - - if (showAvatar) { - return ( - { - if (token.avatarUrl) { - failedAvatarUrls.add(token.avatarUrl); - setLocalFailedUrl(token.avatarUrl); - } - }} - /> - ); - } - - return token.shortLabel ?? getInitials(token.name); -} - -function PersonTokenCell({ - token, - hovered = false, - withCopyAction = true, -}: { - hovered?: boolean; - token: HeroCellPerson; - withCopyAction?: boolean; -}) { - const tone = PERSON_TONES[token.tone ?? 'gray'] ?? PERSON_TONES.gray; - const square = - token.kind === 'api' || - token.kind === 'system' || - token.kind === 'workflow'; - - return ( - - - - - } - /> - - {withCopyAction ? ( - - ) : null} - - - ); -} - -function EntityCellComponent({ - cell, - hovered, - isFirstColumn, -}: { - cell: HeroCellEntity; - hovered: boolean; - isFirstColumn: boolean; -}) { - if (isFirstColumn) { - return ( - - - - - } - variant={ChipVariant.Highlighted} - /> - - - - - ); - } - - return ( - } - /> - ); -} - -function RelationCellComponent({ - cell, - hovered, -}: { - cell: HeroCellRelation; - hovered: boolean; -}) { - return ( - - - {cell.items.map((item) => { - const tone = PERSON_TONES[item.tone ?? 'gray'] ?? PERSON_TONES.gray; - - return ( - - {item.shortLabel ?? getInitials(item.name)} - - } - /> - ); - })} - - - - - - ); -} - -function TextCellComponent({ - cell, - isFirstColumn, - onNavigateToLabel, -}: { - cell: HeroCellText; - isFirstColumn: boolean; - onNavigateToLabel?: (label: string) => void; -}) { - const targetLabel = cell.targetLabel; - const handleNavigate = - targetLabel && onNavigateToLabel - ? () => onNavigateToLabel(targetLabel) - : undefined; - - if (!isFirstColumn || !cell.shortLabel) { - return {cell.value}; - } - - const tone = PERSON_TONES[cell.tone ?? 'gray'] ?? PERSON_TONES.gray; - - return ( - - {cell.shortLabel} - - } - onClick={handleNavigate} - /> - ); -} - -function renderCellValue( - cell: HeroCellValue, - hovered: boolean, - isFirstColumn: boolean, - columnId: string, - onNavigateToLabel?: (label: string) => void, -): ReactNode { - const showHoverAction = !ROW_HOVER_ACTION_DISABLED_COLUMNS.has(columnId); - - switch (cell.type) { - case 'text': - return ( - - ); - case 'number': - return {cell.value}; - case 'link': - return ( -
- -
- ); - case 'boolean': - return ( - - {cell.value ? : } - {cell.value ? 'True' : 'False'} - - ); - case 'tag': - return {cell.value}; - case 'person': - return ( - - ); - case 'entity': - return ( - - ); - case 'relation': - return ( - - ); - } -} - -function renderHeaderIcon(columnId: string): ReactNode { - const Icon = HEADER_ICON_MAP[columnId]; - - if (Icon) { - return ( - - ); - } - - return ( - - ); -} - -export function TablePage({ - page, - onNavigateToLabel, -}: { - page: HeroTablePageDefinition; - onNavigateToLabel?: (label: string) => void; -}) { - const viewportRef = useRef(null); - const dragRef = useRef({ - active: false, - pointerId: -1, - startScrollLeft: 0, - startX: 0, - }); - const [dragging, setDragging] = useState(false); - const [hoveredRowId, setHoveredRowId] = useState(null); - - const columnWidth = page.columns.reduce( - (sum, column) => sum + column.width, - 0, - ); - const totalTableWidth = page.width ?? columnWidth; - const fillerWidth = Math.max(totalTableWidth - columnWidth, 0); - - const handlePointerDown = (event: ReactPointerEvent) => { - if ( - event.pointerType !== 'mouse' || - event.button !== 0 || - !viewportRef.current - ) { - return; - } - - dragRef.current = { - active: true, - pointerId: event.pointerId, - startScrollLeft: viewportRef.current.scrollLeft, - startX: event.clientX, - }; - - viewportRef.current.setPointerCapture(event.pointerId); - setDragging(true); - event.preventDefault(); - }; - - const handlePointerMove = (event: ReactPointerEvent) => { - if (!dragRef.current.active || !viewportRef.current) { - return; - } - - viewportRef.current.scrollLeft = - dragRef.current.startScrollLeft - - (event.clientX - dragRef.current.startX); - }; - - const endDragging = () => { - dragRef.current.active = false; - dragRef.current.pointerId = -1; - setDragging(false); - }; - - const handlePointerUp = (event: ReactPointerEvent) => { - if (!viewportRef.current || dragRef.current.pointerId !== event.pointerId) { - return; - } - - viewportRef.current.releasePointerCapture(event.pointerId); - endDragging(); - }; - - return ( - - - - - - - {page.columns.map((column) => ( - - - {column.isFirstColumn ? ( - <> - - - - {renderHeaderIcon(column.id)} - {column.label} - - - ) : ( - <> - {renderHeaderIcon(column.id)} - {column.label} - - )} - - - ))} - - {fillerWidth > 0 ? ( - - - - ) : null} - - - - {page.rows.map((row, rowIndex) => { - const hovered = hoveredRowId === row.id; - - return ( - setHoveredRowId(row.id)} - onMouseLeave={() => - setHoveredRowId((current) => - current === row.id ? null : current, - ) - } - > - {page.columns.map((column) => { - const cell = row.cells[column.id]; - - return ( - - {cell - ? renderCellValue( - cell, - hovered, - !!column.isFirstColumn, - column.id, - onNavigateToLabel, - ) - : null} - - ); - })} - - - ); - })} - - - {page.columns.length > 0 ? ( - - - Calculate - - - - ) : null} - {page.columns.slice(1).map((column) => ( - - ))} - - - - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowInteraction/use-window-pointer-interactions.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowInteraction/use-window-pointer-interactions.ts new file mode 100644 index 0000000000..c369e5639a --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowInteraction/use-window-pointer-interactions.ts @@ -0,0 +1,300 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type RefObject, + type PointerEvent as ReactPointerEvent, +} from 'react'; + +import { useLatestRef } from '@/lib/react'; + +import { + clampWindowPosition, + resizeWindowFromPointer, + type WindowBounds, + type WindowDragState, + type WindowPosition, + type WindowResizeHandle, + type WindowResizeState, + type WindowSize, +} from './window-geometry'; + +type UseWindowPointerInteractionsOptions = { + activate: () => void; + blockedDragTargetSelector?: string; + edgeGap: number; + getBounds: () => WindowBounds | null; + minSize: WindowSize; + position: WindowPosition | null; + setPosition: (position: WindowPosition) => void; + setSize: (size: WindowSize) => void; + shellRef: RefObject; + size: WindowSize | null; +}; + +type UseWindowPointerInteractionsResult = { + handleDragStart: (event: ReactPointerEvent) => void; + isDragging: boolean; + isResizing: boolean; + latestPositionRef: RefObject; + latestSizeRef: RefObject; + startResize: ( + handle: WindowResizeHandle, + ) => (event: ReactPointerEvent) => void; +}; + +const isPrimaryPointer = (event: ReactPointerEvent): boolean => + event.pointerType !== 'mouse' || event.button === 0; + +const isBlockedDragTarget = ( + target: EventTarget | null, + selector: string | undefined, +): boolean => + selector !== undefined && + target instanceof HTMLElement && + target.closest(selector) !== null; + +export const useWindowPointerInteractions = ({ + activate, + blockedDragTargetSelector, + edgeGap, + getBounds, + minSize, + position, + setPosition, + setSize, + shellRef, + size, +}: UseWindowPointerInteractionsOptions): UseWindowPointerInteractionsResult => { + const dragStateRef = useRef(null); + const resizeStateRef = useRef(null); + const latestPositionRef = useLatestRef(position); + const latestSizeRef = useLatestRef(size); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + + const handleDragStart = useCallback( + (event: ReactPointerEvent) => { + if (!isPrimaryPointer(event)) { + return; + } + + if (isBlockedDragTarget(event.target, blockedDragTargetSelector)) { + return; + } + + if (position === null) { + return; + } + + event.preventDefault(); + activate(); + + const shell = shellRef.current; + shell?.setPointerCapture?.(event.pointerId); + dragStateRef.current = { + pointerId: event.pointerId, + originX: event.clientX, + originY: event.clientY, + startLeft: position.left, + startTop: position.top, + }; + setIsDragging(true); + }, + [activate, blockedDragTargetSelector, position, shellRef], + ); + + useEffect(() => { + if (!isDragging) { + return undefined; + } + + const handleMove = (event: PointerEvent) => { + const state = dragStateRef.current; + const activeSize = latestSizeRef.current; + + if ( + !state || + state.pointerId !== event.pointerId || + activeSize === null + ) { + return; + } + + const bounds = getBounds(); + if (bounds === null) { + return; + } + + const nextPosition = clampWindowPosition({ + bounds, + candidate: { + left: state.startLeft + (event.clientX - state.originX), + top: state.startTop + (event.clientY - state.originY), + }, + edgeGap, + size: activeSize, + }); + + latestPositionRef.current = nextPosition; + const shell = shellRef.current; + if (shell !== null) { + shell.style.transform = `translate3d(${nextPosition.left}px, ${nextPosition.top}px, 0)`; + } + }; + + const stopDragging = (event: PointerEvent) => { + const state = dragStateRef.current; + + if (!state || state.pointerId !== event.pointerId) { + return; + } + + dragStateRef.current = null; + setIsDragging(false); + + const committed = latestPositionRef.current; + if (committed !== null) { + setPosition(committed); + } + + shellRef.current?.releasePointerCapture?.(event.pointerId); + }; + + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', stopDragging); + window.addEventListener('pointercancel', stopDragging); + + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', stopDragging); + window.removeEventListener('pointercancel', stopDragging); + }; + }, [ + edgeGap, + getBounds, + isDragging, + latestPositionRef, + latestSizeRef, + setPosition, + shellRef, + ]); + + const startResize = useCallback( + (handle: WindowResizeHandle) => (event: ReactPointerEvent) => { + if (!isPrimaryPointer(event) || position === null || size === null) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + activate(); + + const shell = shellRef.current; + shell?.setPointerCapture?.(event.pointerId); + resizeStateRef.current = { + pointerId: event.pointerId, + originX: event.clientX, + originY: event.clientY, + startWidth: size.width, + startHeight: size.height, + startLeft: position.left, + startTop: position.top, + handle, + }; + setIsResizing(true); + }, + [activate, position, shellRef, size], + ); + + useEffect(() => { + if (!isResizing) { + return undefined; + } + + const handleMove = (event: PointerEvent) => { + const state = resizeStateRef.current; + + if (!state || state.pointerId !== event.pointerId) { + return; + } + + const bounds = getBounds(); + if (bounds === null) { + return; + } + + const { position: nextPosition, size: nextSize } = + resizeWindowFromPointer({ + bounds, + edgeGap, + minSize, + pointerX: event.clientX, + pointerY: event.clientY, + state, + }); + + latestSizeRef.current = nextSize; + latestPositionRef.current = nextPosition; + const shell = shellRef.current; + if (shell !== null) { + shell.style.width = `${nextSize.width}px`; + shell.style.height = `${nextSize.height}px`; + shell.style.transform = `translate3d(${nextPosition.left}px, ${nextPosition.top}px, 0)`; + } + }; + + const stopResizing = (event: PointerEvent) => { + const state = resizeStateRef.current; + + if (!state || state.pointerId !== event.pointerId) { + return; + } + + resizeStateRef.current = null; + setIsResizing(false); + + const committedSize = latestSizeRef.current; + const committedPosition = latestPositionRef.current; + + if (committedSize !== null) { + setSize(committedSize); + } + if (committedPosition !== null) { + setPosition(committedPosition); + } + + shellRef.current?.releasePointerCapture?.(event.pointerId); + }; + + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', stopResizing); + window.addEventListener('pointercancel', stopResizing); + + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', stopResizing); + window.removeEventListener('pointercancel', stopResizing); + }; + }, [ + edgeGap, + getBounds, + isResizing, + latestPositionRef, + latestSizeRef, + minSize, + setPosition, + setSize, + shellRef, + ]); + + return { + handleDragStart, + isDragging, + isResizing, + latestPositionRef, + latestSizeRef, + startResize, + }; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowInteraction/window-geometry.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowInteraction/window-geometry.test.ts new file mode 100644 index 0000000000..8326831086 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowInteraction/window-geometry.test.ts @@ -0,0 +1,76 @@ +import { + clampWindowPosition, + resizeWindowFromPointer, + type WindowResizeState, +} from './window-geometry'; + +const baseResizeState = { + originX: 100, + originY: 100, + pointerId: 1, + startHeight: 200, + startLeft: 100, + startTop: 80, + startWidth: 300, +} satisfies Omit; + +describe('window geometry', () => { + it('clamps a window position inside its parent bounds', () => { + expect( + clampWindowPosition({ + bounds: { width: 500, height: 400 }, + candidate: { left: 450, top: -20 }, + edgeGap: 0, + size: { width: 200, height: 100 }, + }), + ).toEqual({ left: 300, top: 0 }); + }); + + it('resizes from the right edge without moving the origin', () => { + expect( + resizeWindowFromPointer({ + bounds: { width: 800, height: 600 }, + edgeGap: 0, + minSize: { width: 100, height: 100 }, + pointerX: 160, + pointerY: 100, + state: { ...baseResizeState, handle: 'right' }, + }), + ).toEqual({ + position: { left: 100, top: 80 }, + size: { width: 360, height: 200 }, + }); + }); + + it('resizes from the left edge while preserving the opposite edge', () => { + expect( + resizeWindowFromPointer({ + bounds: { width: 800, height: 600 }, + edgeGap: 0, + minSize: { width: 100, height: 100 }, + pointerX: 40, + pointerY: 100, + state: { ...baseResizeState, handle: 'left' }, + }), + ).toEqual({ + position: { left: 40, top: 80 }, + size: { width: 360, height: 200 }, + }); + }); + + it('respects parent bounds and minimum size for corner resize', () => { + expect( + resizeWindowFromPointer({ + bounds: { width: 360, height: 260 }, + edgeGap: 0, + minSize: { width: 240, height: 180 }, + pointerX: -200, + pointerY: -200, + state: { ...baseResizeState, handle: 'top-left' }, + }), + ).toEqual({ + position: { left: 0, top: 0 }, + size: { width: 400, height: 280 }, + }); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowInteraction/window-geometry.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowInteraction/window-geometry.ts new file mode 100644 index 0000000000..fdca83946a --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowInteraction/window-geometry.ts @@ -0,0 +1,169 @@ +export type WindowPosition = { + left: number; + top: number; +}; + +export type WindowSize = { + height: number; + width: number; +}; + +type ResizeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +type ResizeEdge = 'top' | 'right' | 'bottom' | 'left'; + +export type WindowResizeHandle = ResizeCorner | ResizeEdge; + +export type WindowDragState = { + originX: number; + originY: number; + pointerId: number; + startLeft: number; + startTop: number; +}; + +export type WindowResizeState = WindowDragState & { + handle: WindowResizeHandle; + startHeight: number; + startWidth: number; +}; + +export type WindowBounds = { + height: number; + width: number; +}; + +type ClampWindowPositionInput = { + bounds: WindowBounds; + candidate: WindowPosition; + edgeGap: number; + size: WindowSize; +}; + +type ResizeWindowInput = { + bounds: WindowBounds; + edgeGap: number; + minSize: WindowSize; + pointerX: number; + pointerY: number; + state: WindowResizeState; +}; + +const HORIZONTAL_HANDLES: ReadonlySet = new Set([ + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right', + 'left', + 'right', +]); + +const VERTICAL_HANDLES: ReadonlySet = new Set([ + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right', + 'top', + 'bottom', +]); + +const LEFT_HANDLES: ReadonlySet = new Set([ + 'top-left', + 'bottom-left', + 'left', +]); + +const TOP_HANDLES: ReadonlySet = new Set([ + 'top-left', + 'top-right', + 'top', +]); + +export const clampWindowPosition = ({ + bounds, + candidate, + edgeGap, + size, +}: ClampWindowPositionInput): WindowPosition => { + const maxLeft = bounds.width - size.width - edgeGap; + const maxTop = bounds.height - size.height - edgeGap; + + return { + left: Math.min(Math.max(candidate.left, edgeGap), maxLeft), + top: Math.min(Math.max(candidate.top, edgeGap), maxTop), + }; +}; + +export const resizeWindowFromPointer = ({ + bounds, + edgeGap, + minSize, + pointerX, + pointerY, + state, +}: ResizeWindowInput): { + position: WindowPosition; + size: WindowSize; +} => { + const deltaX = pointerX - state.originX; + const deltaY = pointerY - state.originY; + + const affectsWidth = HORIZONTAL_HANDLES.has(state.handle); + const affectsHeight = VERTICAL_HANDLES.has(state.handle); + const growsFromLeft = LEFT_HANDLES.has(state.handle); + const growsFromTop = TOP_HANDLES.has(state.handle); + + const effectiveMinWidth = Math.min( + minSize.width, + Math.max(bounds.width - edgeGap * 2, 0), + ); + const effectiveMinHeight = Math.min( + minSize.height, + Math.max(bounds.height - edgeGap * 2, 0), + ); + + let nextWidth = state.startWidth; + let nextLeft = state.startLeft; + + if (affectsWidth) { + if (growsFromLeft) { + const maxWidth = state.startWidth + state.startLeft - edgeGap; + nextWidth = Math.min( + Math.max(state.startWidth - deltaX, effectiveMinWidth), + Math.max(maxWidth, effectiveMinWidth), + ); + nextLeft = state.startLeft + state.startWidth - nextWidth; + } else { + const maxWidth = bounds.width - state.startLeft - edgeGap; + nextWidth = Math.min( + Math.max(state.startWidth + deltaX, effectiveMinWidth), + Math.max(maxWidth, effectiveMinWidth), + ); + } + } + + let nextHeight = state.startHeight; + let nextTop = state.startTop; + + if (affectsHeight) { + if (growsFromTop) { + const maxHeight = state.startHeight + state.startTop - edgeGap; + nextHeight = Math.min( + Math.max(state.startHeight - deltaY, effectiveMinHeight), + Math.max(maxHeight, effectiveMinHeight), + ); + nextTop = state.startTop + state.startHeight - nextHeight; + } else { + const maxHeight = bounds.height - state.startTop - edgeGap; + nextHeight = Math.min( + Math.max(state.startHeight + deltaY, effectiveMinHeight), + Math.max(maxHeight, effectiveMinHeight), + ); + } + } + + return { + position: { left: nextLeft, top: nextTop }, + size: { width: nextWidth, height: nextHeight }, + }; +}; diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WorkflowPage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WorkflowPage.tsx deleted file mode 100644 index 5ec1e00605..0000000000 --- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WorkflowPage.tsx +++ /dev/null @@ -1,495 +0,0 @@ -'use client'; - -import type { HeroWorkflowPageDefinition } from '@/sections/Hero/types'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import { - IconCode, - IconFilter, - IconPlug, - IconPlus, - IconRepeat, - IconSearch, - IconSitemap, -} from '@tabler/icons-react'; -import { VISUAL_TOKENS } from './homeVisualTokens'; - -const APP_FONT = VISUAL_TOKENS.font.family; -const TABLER_STROKE = 1.6; -const CANVAS_WIDTH = 1480; -const CANVAS_HEIGHT = 1260; -const NODE_HEIGHT = 48; -const CANVAS_TOP_OFFSET = 16; - -type WorkflowNodeDefinition = { - Icon: typeof IconPlug; - id: string; - iconColor: string; - label: 'Trigger' | 'Action'; - title: string; - width: number; - x: number; - y: number; -}; - -type WorkflowBranchLabel = { - text: string; - x: number; - y: number; -}; - -type WorkflowEdgeDefinition = { - from: string; - to: string; - type: 'branch' | 'curve' | 'vertical'; -}; - -const workflowNodes: WorkflowNodeDefinition[] = [ - { - id: 'trigger', - x: 370, - y: 80, - width: 238, - label: 'Trigger', - title: 'Record is created or updated', - Icon: IconPlug, - iconColor: '#4A67F6', - }, - { - id: 'is-personal-email', - x: 620, - y: 210, - width: 220, - label: 'Action', - title: 'Is this a personal email?', - Icon: IconCode, - iconColor: '#FF6B5F', - }, - { - id: 'if-business-email', - x: 640, - y: 340, - width: 180, - label: 'Action', - title: 'If business email', - Icon: IconFilter, - iconColor: VISUAL_TOKENS.font.color.secondary, - }, - { - id: 'extract-domain', - x: 620, - y: 470, - width: 220, - label: 'Action', - title: 'Extract domain from email', - Icon: IconCode, - iconColor: '#FF6B5F', - }, - { - id: 'search-company', - x: 640, - y: 600, - width: 180, - label: 'Action', - title: 'Search Company', - Icon: IconSearch, - iconColor: VISUAL_TOKENS.font.color.secondary, - }, - { - id: 'find-exact-match', - x: 610, - y: 730, - width: 240, - label: 'Action', - title: 'Find exact company match', - Icon: IconCode, - iconColor: '#FF6B5F', - }, - { - id: 'company-already-exists', - x: 600, - y: 860, - width: 260, - label: 'Action', - title: 'If a company already exists', - Icon: IconSitemap, - iconColor: VISUAL_TOKENS.font.color.secondary, - }, - { - id: 'attach-existing-company', - x: 370, - y: 990, - width: 240, - label: 'Action', - title: 'Attach person to existing company', - Icon: IconRepeat, - iconColor: VISUAL_TOKENS.font.color.secondary, - }, - { - id: 'create-company', - x: 840, - y: 990, - width: 220, - label: 'Action', - title: 'Create a new company', - Icon: IconPlus, - iconColor: VISUAL_TOKENS.font.color.secondary, - }, - { - id: 'attach-created-company', - x: 850, - y: 1120, - width: 240, - label: 'Action', - title: 'Attach person to this company', - Icon: IconRepeat, - iconColor: VISUAL_TOKENS.font.color.secondary, - }, -]; - -const workflowEdges: WorkflowEdgeDefinition[] = [ - { - from: 'trigger', - to: 'is-personal-email', - type: 'curve', - }, - { - from: 'is-personal-email', - to: 'if-business-email', - type: 'vertical', - }, - { - from: 'if-business-email', - to: 'extract-domain', - type: 'vertical', - }, - { - from: 'extract-domain', - to: 'search-company', - type: 'vertical', - }, - { - from: 'search-company', - to: 'find-exact-match', - type: 'vertical', - }, - { - from: 'find-exact-match', - to: 'company-already-exists', - type: 'vertical', - }, - { - from: 'company-already-exists', - to: 'attach-existing-company', - type: 'branch', - }, - { - from: 'company-already-exists', - to: 'create-company', - type: 'branch', - }, - { - from: 'create-company', - to: 'attach-created-company', - type: 'vertical', - }, -]; - -const workflowBranchLabels: WorkflowBranchLabel[] = [ - { x: 566, y: 944, text: 'if' }, - { x: 820, y: 944, text: 'else' }, -]; - -const PageShell = styled.div` - background: #ffffff; - display: flex; - flex: 1 1 auto; - flex-direction: column; - min-height: 100%; - min-width: 100%; -`; - -const CanvasViewportShell = styled.div` - background: #ffffff; - display: flex; - flex: 1 1 auto; - min-height: 0; - min-width: 0; - position: relative; -`; - -const CanvasViewport = styled.div` - display: flex; - flex: 1 1 auto; - min-height: 0; - min-width: 0; - overflow: auto; -`; - -const Canvas = styled.div` - background-color: #ffffff; - background-image: radial-gradient(circle, #ebebeb 1px, transparent 1.2px); - background-position: 10px 10px; - background-size: 20px 20px; - box-sizing: border-box; - height: ${CANVAS_HEIGHT + CANVAS_TOP_OFFSET}px; - min-height: 100%; - min-width: 100%; - overflow: hidden; - position: relative; - width: ${CANVAS_WIDTH}px; -`; - -const CanvasContent = styled.div` - height: ${CANVAS_HEIGHT}px; - left: calc((100% - ${CANVAS_WIDTH}px) / 2); - position: absolute; - top: ${CANVAS_TOP_OFFSET}px; - width: ${CANVAS_WIDTH}px; -`; - -const CanvasOverlay = styled.svg` - inset: 0; - overflow: visible; - pointer-events: none; - position: absolute; -`; - -const ActiveBadge = styled.div` - left: 8px; - position: absolute; - pointer-events: none; - top: 8px; - z-index: 3; -`; - -const ActiveBadgeLabel = styled.span` - align-items: center; - background: #dff3e6; - border-radius: 4px; - color: #228b52; - display: inline-flex; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.regular}; - height: 20px; - line-height: 1.4; - padding: 0 8px; -`; - -const Node = styled.div` - align-items: center; - background: ${VISUAL_TOKENS.background.secondary}; - border: 1px solid ${VISUAL_TOKENS.border.color.strong}; - border-radius: 8px; - box-sizing: border-box; - display: flex; - gap: 8px; - height: ${NODE_HEIGHT}px; - left: 0; - padding: 8px; - position: absolute; - top: 0; - z-index: 1; -`; - -const NodeIconContainer = styled.div` - align-items: center; - background: ${VISUAL_TOKENS.background.transparent.light}; - border-radius: 4px; - display: flex; - flex: 0 0 auto; - height: 32px; - justify-content: center; - width: 32px; -`; - -const NodeContent = styled.div` - align-items: stretch; - align-self: stretch; - display: flex; - flex: 1 1 auto; - flex-direction: column; - justify-content: space-between; - max-width: 184px; - min-width: 0; - padding-bottom: 2px; -`; - -const NodeLabel = styled.div` - color: ${VISUAL_TOKENS.font.color.tertiary}; - font-family: ${APP_FONT}; - font-size: 11px; - font-weight: 600; - line-height: 1; -`; - -const NodeTitle = styled.div` - color: ${VISUAL_TOKENS.font.color.primary}; - font-family: ${APP_FONT}; - font-size: 13px; - font-weight: ${theme.font.weight.medium}; - line-height: 1.4; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const BranchLabel = styled.div` - align-items: center; - background: ${VISUAL_TOKENS.background.secondary}; - border: 1px solid ${VISUAL_TOKENS.border.color.medium}; - border-radius: 6px; - color: ${VISUAL_TOKENS.font.color.tertiary}; - display: inline-flex; - font-family: ${APP_FONT}; - font-size: 11px; - font-weight: ${theme.font.weight.medium}; - height: 22px; - justify-content: center; - min-width: 24px; - padding: 0 6px; - position: absolute; - z-index: 2; -`; - -function getNodeById(nodeId: string) { - const node = workflowNodes.find((workflowNode) => workflowNode.id === nodeId); - - if (!node) { - throw new Error(`Unknown workflow node: ${nodeId}`); - } - - return node; -} - -function getNodeTopCenter(node: WorkflowNodeDefinition) { - return { - x: node.x + node.width / 2, - y: node.y, - }; -} - -function getNodeBottomCenter(node: WorkflowNodeDefinition) { - return { - x: node.x + node.width / 2, - y: node.y + NODE_HEIGHT + 1, - }; -} - -function getWorkflowEdgePath(edge: WorkflowEdgeDefinition) { - const fromNode = getNodeById(edge.from); - const toNode = getNodeById(edge.to); - const start = getNodeBottomCenter(fromNode); - const end = getNodeTopCenter(toNode); - - if (edge.type === 'vertical') { - return `M${start.x} ${start.y} L${end.x} ${end.y}`; - } - - if (edge.type === 'curve') { - const controlStartY = start.y + 28; - const controlEndY = end.y - 28; - - return `M${start.x} ${start.y} C${start.x} ${controlStartY} ${end.x} ${controlEndY} ${end.x} ${end.y}`; - } - - const controlStartY = start.y + 28; - const controlEndY = end.y - 28; - - return `M${start.x} ${start.y} C${start.x} ${controlStartY} ${end.x} ${controlEndY} ${end.x} ${end.y}`; -} - -function WorkflowNode({ - x, - y, - width, - label, - title, - Icon, - iconColor, -}: WorkflowNodeDefinition) { - return ( - - - - - - {label} - {title} - - - ); -} - -export function WorkflowPage({ page }: { page: HeroWorkflowPageDefinition }) { - return ( - - - - Active - - - - - - - - - - - {workflowEdges.map((edge) => ( - - ))} - - - {workflowNodes.map((node) => ( - - ))} - - {workflowBranchLabels.map((label) => ( - - {label.text} - - ))} - - - - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerHalftoneOverlay.tsx b/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerHalftoneOverlay.tsx index 00cb94ec8a..e3b22299ca 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerHalftoneOverlay.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerHalftoneOverlay.tsx @@ -5,7 +5,12 @@ import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { getPrefersReducedMotionSnapshot } from '@/lib/motion'; -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; +import { + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, +} from '@/lib/visual-runtime'; const PREVIEW_DISTANCE = 3.2; const SOURCE_PREVIEW_DISTANCE = 6.1; @@ -445,11 +450,20 @@ async function mountHalftoneOverlay({ 1, ); - const renderer = createSiteWebGlRenderer({ + let renderLoop: VisualRenderLoop | null = null; + const renderer = tryCreateSiteWebGlRenderer({ alpha: true, antialias: false, + onContextLost: () => { + renderLoop?.stop(); + }, powerPreference: 'high-performance', }); + + if (renderer === null) { + return () => {}; + } + renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setPixelRatio(1); renderer.setClearColor(0x000000, 0); @@ -597,8 +611,7 @@ async function mountHalftoneOverlay({ }); }; - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); + const stopObservingSize = observeElementSize(container, syncSize); const updatePointerPosition = ( event: PointerEvent, @@ -659,11 +672,9 @@ async function mountHalftoneOverlay({ canvas.addEventListener('pointermove', handlePointerMove); canvas.addEventListener('pointerleave', handlePointerLeave); - let animationFrameId = 0; let previousTimestamp = 0; const renderFrame = (timestamp: number) => { - animationFrameId = window.requestAnimationFrame(renderFrame); halftoneMaterial.uniforms.time.value = timestamp / 1000; const hoverScale = getHoverScale(); const deltaSeconds = @@ -719,11 +730,16 @@ async function mountHalftoneOverlay({ renderer.render(postScene, orthographicCamera); }; - renderFrame(0); + renderLoop = createVisualRenderLoop({ + renderFrame, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); return () => { - window.cancelAnimationFrame(animationFrameId); - resizeObserver.disconnect(); + renderLoop?.dispose(); + stopObservingSize(); canvas.removeEventListener('pointermove', handlePointerMove); canvas.removeEventListener('pointerleave', handlePointerLeave); halftoneMaterial.dispose(); diff --git a/packages/twenty-website-new/src/sections/Hero/visuals/HomeBackgroundHalftone.tsx b/packages/twenty-website-new/src/sections/Hero/visuals/HomeBackgroundHalftone.tsx index e25d4ac4cb..40ee54ae08 100644 --- a/packages/twenty-website-new/src/sections/Hero/visuals/HomeBackgroundHalftone.tsx +++ b/packages/twenty-website-new/src/sections/Hero/visuals/HomeBackgroundHalftone.tsx @@ -1,9 +1,13 @@ 'use client'; +import { createAnimationFrameLoop } from '@/lib/animation'; import { - createFrameTimer, - createSiteWebGlRenderer, + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoopFrame, + type VisualRenderLoop, } from '@/lib/visual-runtime'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; import { styled } from '@linaria/react'; import { useEffect, useRef, useState } from 'react'; import * as THREE from 'three'; @@ -438,11 +442,20 @@ async function mountHomeBackgroundCanvas({ 1, ); - const renderer = createSiteWebGlRenderer({ + let renderLoop: VisualRenderLoop | null = null; + const renderer = tryCreateSiteWebGlRenderer({ alpha: true, antialias: false, + onContextLost: () => { + renderLoop?.stop(); + }, powerPreference: 'high-performance', }); + + if (renderer === null) { + return () => {}; + } + renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setPixelRatio(1); renderer.setClearColor(0x000000, 0); @@ -554,8 +567,7 @@ async function mountHomeBackgroundCanvas({ ); }; - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); + const stopObservingSize = observeElementSize(container, syncSize); const pointer: PointerState = { hoverStrength: 0, @@ -593,9 +605,6 @@ async function mountHomeBackgroundCanvas({ window.addEventListener('pointerleave', handlePointerLeave); window.addEventListener('blur', handlePointerLeave); - const frameTimer = createFrameTimer(); - let animationFrameId = 0; - const getHalftoneFootprintScale = () => getImageFootprintScale({ imageHeight: image.height, @@ -605,10 +614,10 @@ async function mountHomeBackgroundCanvas({ viewportWidth: getVirtualWidth(), }); - const renderFrame = () => { - animationFrameId = window.requestAnimationFrame(renderFrame); - const deltaSeconds = frameTimer.tick(); - + const renderFrame = ( + _timestamp: DOMHighResTimeStamp, + { deltaSeconds }: VisualRenderLoopFrame, + ) => { const hoverEasing = 1 - Math.exp( @@ -652,11 +661,16 @@ async function mountHomeBackgroundCanvas({ void IMAGE_POINTER_VELOCITY_DAMPING; - renderFrame(); + renderLoop = createVisualRenderLoop({ + renderFrame, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); return () => { - window.cancelAnimationFrame(animationFrameId); - resizeObserver.disconnect(); + renderLoop?.dispose(); + stopObservingSize(); window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerleave', handlePointerLeave); window.removeEventListener('blur', handlePointerLeave); @@ -686,7 +700,12 @@ export function HomeBackgroundHalftone() { let disposed = false; let unmount: (() => void) | null = null; - let readyFrameId = 0; + const readyTask = createAnimationFrameLoop({ + onFrame: () => { + setIsReady(true); + return false; + }, + }); mountHomeBackgroundCanvas({ container, @@ -698,9 +717,7 @@ export function HomeBackgroundHalftone() { return; } unmount = dispose; - readyFrameId = window.requestAnimationFrame(() => { - setIsReady(true); - }); + readyTask.start(); }) .catch((error) => { console.error(error); @@ -708,7 +725,7 @@ export function HomeBackgroundHalftone() { return () => { disposed = true; - window.cancelAnimationFrame(readyFrameId); + readyTask.stop(); unmount?.(); }; }, []); diff --git a/packages/twenty-website-new/src/sections/Hero/visuals/Milestone.tsx b/packages/twenty-website-new/src/sections/Hero/visuals/Milestone.tsx index 72584783b7..f5e2272891 100644 --- a/packages/twenty-website-new/src/sections/Hero/visuals/Milestone.tsx +++ b/packages/twenty-website-new/src/sections/Hero/visuals/Milestone.tsx @@ -1,744 +1,20 @@ -// @ts-nocheck 'use client'; -import { useEffect, useRef, type CSSProperties } from 'react'; -import * as THREE from 'three'; +import { + HalftoneImageCanvas, + type HalftoneStudioSettings, +} from '@/lib/halftone'; import { styled } from '@linaria/react'; - -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; - -const DEFAULT_SETTINGS = { - sourceMode: 'image', - shapeKey: 'torusKnot', - lighting: { - intensity: 1.5, - fillIntensity: 0.15, - ambientIntensity: 0.08, - angleDegrees: 45, - height: 2, - }, - material: { - surface: 'solid', - color: '#d4d0c8', - roughness: 0.42, - metalness: 0.16, - thickness: 150, - refraction: 2, - environmentPower: 5, - }, - halftone: { - enabled: true, - scale: 22, - power: -0.07, - toneTarget: 'light', - width: 0.46, - imageContrast: 1, - dashColor: '#F3F3F3', - hoverDashColor: '#F3F3F3', - }, - background: { - transparent: false, - color: '#4A38F5', - }, - animation: { - autoRotateEnabled: true, - breatheEnabled: false, - cameraParallaxEnabled: false, - followHoverEnabled: false, - followDragEnabled: false, - floatEnabled: false, - hoverHalftoneEnabled: false, - hoverLightEnabled: true, - dragFlowEnabled: false, - lightSweepEnabled: false, - rotateEnabled: false, - autoSpeed: 0.2, - autoWobble: 0.3, - breatheAmount: 0.04, - breatheSpeed: 0.8, - cameraParallaxAmount: 0.3, - cameraParallaxEase: 0.08, - driftAmount: 8, - hoverRange: 25, - hoverEase: 0.08, - hoverReturn: true, - dragSens: 0.008, - dragFriction: 0.08, - dragMomentum: true, - rotateAxis: 'y', - rotatePreset: 'axis', - rotateSpeed: 0.2, - rotatePingPong: false, - floatAmplitude: 0.16, - floatSpeed: 0.8, - lightSweepHeightRange: 0.5, - lightSweepRange: 28, - lightSweepSpeed: 0.7, - springDamping: 0.72, - springReturnEnabled: false, - springStrength: 0.18, - hoverHalftonePowerShift: 0.42, - hoverHalftoneRadius: 0.2, - hoverHalftoneWidthShift: -0.18, - hoverLightIntensity: 1.2, - hoverLightRadius: 0.45, - dragFlowDecay: 0.08, - dragFlowRadius: 0.24, - dragFlowStrength: 1.8, - hoverWarpStrength: 3, - hoverWarpRadius: 0.15, - dragWarpStrength: 5, - waveEnabled: false, - waveSpeed: 1, - waveAmount: 2, - }, -}; -const initialPose = { - autoElapsed: 0, - rotateElapsed: 0, - rotationX: 0, - rotationY: 0, - rotationZ: 0, - targetRotationX: 0, - targetRotationY: 0, - timeElapsed: 33.42220000023842, -}; -const previewDistance = 4; -const VIRTUAL_RENDER_HEIGHT = 768; -const passThroughVertexShader = - '\n varying vec2 vUv;\n\n void main() {\n vUv = uv;\n gl_Position = vec4(position, 1.0);\n }\n'; -const blurFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tInput;\n uniform vec2 dir;\n uniform vec2 res;\n\n varying vec2 vUv;\n\n void main() {\n vec4 sum = vec4(0.0);\n vec2 px = dir / res;\n\n float w[5];\n w[0] = 0.227027;\n w[1] = 0.1945946;\n w[2] = 0.1216216;\n w[3] = 0.054054;\n w[4] = 0.016216;\n\n sum += texture2D(tInput, vUv) * w[0];\n\n for (int i = 1; i < 5; i++) {\n float fi = float(i) * 3.0;\n sum += texture2D(tInput, vUv + px * fi) * w[i];\n sum += texture2D(tInput, vUv - px * fi) * w[i];\n }\n\n gl_FragColor = sum;\n }\n'; -const halftoneFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tScene;\n uniform sampler2D tGlow;\n uniform vec2 effectResolution;\n uniform vec2 logicalResolution;\n uniform float tile;\n uniform float s_3;\n uniform float s_4;\n uniform float applyToDarkAreas;\n uniform vec3 dashColor;\n uniform vec3 hoverDashColor;\n uniform float time;\n uniform float waveAmount;\n uniform float waveSpeed;\n uniform float footprintScale;\n uniform vec2 interactionUv;\n uniform vec2 interactionVelocity;\n uniform vec2 dragOffset;\n uniform float hoverHalftoneActive;\n uniform float hoverHalftonePowerShift;\n uniform float hoverHalftoneRadius;\n uniform float hoverHalftoneWidthShift;\n uniform float hoverLightStrength;\n uniform float hoverLightRadius;\n uniform float hoverFlowStrength;\n uniform float hoverFlowRadius;\n uniform float dragFlowStrength;\n uniform float cropToBounds;\n\n varying vec2 vUv;\n\n float distSegment(in vec2 p, in vec2 a, in vec2 b) {\n vec2 pa = p - a;\n vec2 ba = b - a;\n float denom = max(dot(ba, ba), 0.000001);\n float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);\n return length(pa - ba * h);\n }\n\n float lineSimpleEt(in vec2 p, in float r, in float thickness) {\n vec2 a = vec2(0.5) + vec2(-r, 0.0);\n vec2 b = vec2(0.5) + vec2(r, 0.0);\n float distToSegment = distSegment(p, a, b);\n float halfThickness = thickness * r;\n return distToSegment - halfThickness;\n }\n\n void main() {\n if (cropToBounds > 0.5) {\n vec4 boundsCheck = texture2D(tScene, vUv);\n if (boundsCheck.a < 0.01) {\n gl_FragColor = vec4(0.0);\n return;\n }\n }\n\n vec2 fragCoord =\n (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;\n float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);\n vec2 pointerPx = interactionUv * logicalResolution;\n vec2 fragDelta = fragCoord - pointerPx;\n float fragDist = length(fragDelta);\n vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);\n float velocityMagnitude = length(interactionVelocity);\n vec2 motionDir = velocityMagnitude > 0.001\n ? interactionVelocity / velocityMagnitude\n : vec2(0.0, 0.0);\n float motionBias = velocityMagnitude > 0.001\n ? dot(-radialDir, motionDir) * 0.5 + 0.5\n : 0.5;\n\n float hoverLightMask = 0.0;\n if (hoverLightStrength > 0.0) {\n float lightRadiusPx = hoverLightRadius * logicalResolution.y;\n hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);\n }\n\n float hoverHalftoneMask = 0.0;\n if (hoverHalftoneActive > 0.0) {\n float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;\n hoverHalftoneMask =\n smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *\n clamp(hoverHalftoneActive, 0.0, 1.0);\n }\n\n float hoverFlowMask = 0.0;\n if (hoverFlowStrength > 0.0) {\n float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;\n hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);\n }\n\n vec2 hoverDisplacement =\n radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +\n motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;\n vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;\n vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;\n\n float bandRow = floor(effectCoord.y / halftoneSize);\n float waveOffset =\n waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;\n effectCoord.x += waveOffset;\n\n vec2 cellIndex = floor(effectCoord / halftoneSize);\n vec2 sampleUv = clamp(\n (cellIndex + 0.5) * halftoneSize / logicalResolution,\n vec2(0.0),\n vec2(1.0)\n );\n vec2 cellUv = fract(effectCoord / halftoneSize);\n\n vec4 sceneSample = texture2D(tScene, sampleUv);\n float mask = smoothstep(0.02, 0.08, sceneSample.a);\n float localPower = clamp(\n s_3 + hoverHalftonePowerShift * hoverHalftoneMask,\n -1.5,\n 1.5\n );\n float localWidth = clamp(\n s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,\n 0.05,\n 1.4\n );\n float lightLift =\n hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;\n float toneValue =\n (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);\n if (applyToDarkAreas > 0.5) {\n toneValue = 1.0 - toneValue;\n }\n float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0);\n float bandRadius = clamp(\n toneValue + powerBias + lightLift,\n 0.0,\n 1.0\n ) * 1.86 * 0.5;\n\n float alpha = 0.0;\n if (bandRadius > 0.0001) {\n float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);\n float edge = 0.02;\n alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;\n }\n\n vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);\n vec3 color = activeDashColor * alpha;\n gl_FragColor = vec4(color, alpha);\n\n #include \n #include \n }\n'; -const imagePassthroughFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tImage;\n uniform vec2 imageSize;\n uniform vec2 viewportSize;\n uniform float zoom;\n uniform float contrast;\n\n varying vec2 vUv;\n\n void main() {\n float imageAspect = imageSize.x / imageSize.y;\n float viewAspect = viewportSize.x / viewportSize.y;\n\n vec2 uv = vUv;\n\n if (imageAspect > viewAspect) {\n float scale = viewAspect / imageAspect;\n uv.x = (uv.x - 0.5) * scale + 0.5;\n } else {\n float scale = imageAspect / viewAspect;\n uv.y = (uv.y - 0.5) * scale + 0.5;\n }\n\n uv = (uv - 0.5) / zoom + 0.5;\n\n vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));\n vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);\n\n gl_FragColor = vec4(contrastColor, 1.0);\n }\n'; - -const REFERENCE_PREVIEW_DISTANCE = 4; -const MIN_FOOTPRINT_SCALE = 0.001; - -function clampRectToViewport(rect, viewportWidth, viewportHeight) { - const minX = Math.max(rect.x, 0); - const minY = Math.max(rect.y, 0); - const maxX = Math.min(rect.x + rect.width, viewportWidth); - const maxY = Math.min(rect.y + rect.height, viewportHeight); - - if (maxX <= minX || maxY <= minY) { - return null; - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; -} - -function getRectArea(rect) { - if (!rect) { - return 0; - } - - return Math.max(rect.width, 0) * Math.max(rect.height, 0); -} - -function getImagePreviewZoom(previewDistance) { - return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); -} - -function getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom, -}) { - if ( - imageWidth <= 0 || - imageHeight <= 0 || - viewportWidth <= 0 || - viewportHeight <= 0 - ) { - return null; - } - - const imageAspect = imageWidth / imageHeight; - const viewAspect = viewportWidth / viewportHeight; - - let fittedWidth = viewportWidth; - let fittedHeight = viewportHeight; - - if (imageAspect > viewAspect) { - fittedWidth = viewportHeight * imageAspect; - } else { - fittedHeight = viewportWidth / imageAspect; - } - - const scaledWidth = fittedWidth * zoom; - const scaledHeight = fittedHeight * zoom; - - return clampRectToViewport( - { - x: (viewportWidth - scaledWidth) * 0.5, - y: (viewportHeight - scaledHeight) * 0.5, - width: scaledWidth, - height: scaledHeight, - }, - viewportWidth, - viewportHeight, - ); -} - -function getFootprintScaleFromRects(currentRect, referenceRect) { - const currentArea = getRectArea(currentRect); - const referenceArea = getRectArea(referenceRect); - - if (currentArea <= 0 || referenceArea <= 0) { - return 1; - } - - return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE); -} - -function getImageFootprintScale({ - imageHeight, - imageWidth, - previewDistance, - viewportHeight, - viewportWidth, -}) { - const currentRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: getImagePreviewZoom(previewDistance), - }); - const referenceRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: 1, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function createRenderTarget(width, height) { - return new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); -} - -function createInteractionState() { - return { - autoElapsed: initialPose.autoElapsed, - activePointerId: null, - dragging: false, - hoverStrength: 0, - mouseX: 0.5, - mouseY: 0.5, - pointerInside: false, - pointerVelocityX: 0, - pointerVelocityY: 0, - pointerX: 0, - pointerY: 0, - rotateElapsed: initialPose.rotateElapsed, - rotationX: initialPose.rotationX, - rotationVelocityX: 0, - rotationY: initialPose.rotationY, - rotationVelocityY: 0, - rotationZ: initialPose.rotationZ, - rotationVelocityZ: 0, - smoothedMouseX: 0.5, - smoothedMouseY: 0.5, - targetRotationX: initialPose.targetRotationX, - targetRotationY: initialPose.targetRotationY, - velocityX: 0, - velocityY: 0, - }; -} - -function resolveMilestoneSettings(nextSettings = {}) { - return { - ...DEFAULT_SETTINGS, - ...nextSettings, - lighting: { - ...DEFAULT_SETTINGS.lighting, - ...nextSettings.lighting, - }, - material: { - ...DEFAULT_SETTINGS.material, - ...nextSettings.material, - }, - halftone: { - ...DEFAULT_SETTINGS.halftone, - ...nextSettings.halftone, - }, - background: { - ...DEFAULT_SETTINGS.background, - ...nextSettings.background, - }, - animation: { - ...DEFAULT_SETTINGS.animation, - ...nextSettings.animation, - }, - }; -} - -async function mountHalftoneCanvas(options) { - const { container, imageUrl, settingsRef } = options; - const getSettings = () => resolveMilestoneSettings(settingsRef?.current); - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))), - 1, - ); - - const image = await new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = () => reject(new Error('Failed to load image')); - img.src = imageUrl; - }); - - const settings = getSettings(); - const illustrationColor = settings.halftone.dashColor; - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor( - settings.background.color, - settings.background.transparent ? 0 : 1, - ); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.cursor = 'default'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const imageTexture = new THREE.Texture(image); - imageTexture.colorSpace = THREE.SRGBColorSpace; - imageTexture.needsUpdate = true; - - const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const imageMaterial = new THREE.ShaderMaterial({ - uniforms: { - tImage: { value: imageTexture }, - imageSize: { value: new THREE.Vector2(image.width, image.height) }, - viewportSize: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - zoom: { value: getImagePreviewZoom(previewDistance) }, - contrast: { value: settings.halftone.imageContrast }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: imagePassthroughFragmentShader, - }); - - const imageScene = new THREE.Scene(); - imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial)); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - effectResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - logicalResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - tile: { value: settings.halftone.scale }, - s_3: { value: settings.halftone.power }, - s_4: { value: settings.halftone.width }, - applyToDarkAreas: { - value: settings.halftone.toneTarget === 'dark' ? 1 : 0, - }, - dashColor: { value: new THREE.Color(illustrationColor) }, - hoverDashColor: { - value: new THREE.Color(illustrationColor), - }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: settings.animation.waveSpeed }, - footprintScale: { value: 1.0 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - interactionVelocity: { value: new THREE.Vector2(0, 0) }, - dragOffset: { value: new THREE.Vector2(0, 0) }, - hoverHalftoneActive: { value: 0 }, - hoverHalftonePowerShift: { value: 0 }, - hoverHalftoneRadius: { value: settings.animation.hoverHalftoneRadius }, - hoverHalftoneWidthShift: { value: 0 }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: settings.animation.hoverLightRadius }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - cropToBounds: { value: 1 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add( - new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), - ); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const updateViewportUniforms = ( - logicalWidth, - logicalHeight, - effectWidth, - effectHeight, - ) => { - blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - halftoneMaterial.uniforms.effectResolution.value.set( - effectWidth, - effectHeight, - ); - halftoneMaterial.uniforms.logicalResolution.value.set( - logicalWidth, - logicalHeight, - ); - imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight); - }; - - const getHalftoneScale = () => - getImageFootprintScale({ - imageHeight: image.height, - imageWidth: image.width, - previewDistance, - viewportHeight: getVirtualHeight(), - viewportWidth: getVirtualWidth(), - }); - - const interaction = createInteractionState(); - const imagePointerFollow = 0.38; - const imagePointerVelocityDamping = 0.82; - const imageHoverFadeIn = 18; - const imageHoverFadeOut = 7; - - const syncSize = () => { - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - updateViewportUniforms( - virtualWidth, - virtualHeight, - virtualWidth, - virtualHeight, - ); - }; - - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); - - const updatePointerPosition = (event, options = {}) => { - const rect = canvas.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - - const nextMouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, - 0, - 1, - ); - const nextMouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, - 0, - 1, - ); - - const deltaX = nextMouseX - interaction.mouseX; - const deltaY = nextMouseY - interaction.mouseY; - - interaction.mouseX = nextMouseX; - interaction.mouseY = nextMouseY; - interaction.pointerInside = - interaction.dragging || - (event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom); - - if (options.resetVelocity) { - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - interaction.smoothedMouseX = nextMouseX; - interaction.smoothedMouseY = nextMouseY; - } else { - interaction.pointerVelocityX = deltaX; - interaction.pointerVelocityY = deltaY; - } - - return { deltaX, deltaY }; - }; - - const releasePointerCapture = (pointerId) => { - if (pointerId === null) { - return; - } - - if (!canvas.hasPointerCapture(pointerId)) { - return; - } - - try { - canvas.releasePointerCapture(pointerId); - } catch (error) { - void error; - } - }; - - const handlePointerDown = (event) => { - updatePointerPosition(event, { resetVelocity: true }); - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - }; - - const handlePointerMove = (event) => { - const resetVelocity = !interaction.pointerInside && !interaction.dragging; - updatePointerPosition( - event, - resetVelocity ? { resetVelocity: true } : undefined, - ); - }; - - const handlePointerLeave = () => { - if (interaction.dragging) { - return; - } - - interaction.pointerInside = false; - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - }; - - const handlePointerUp = (event) => { - updatePointerPosition(event, { resetVelocity: true }); - releasePointerCapture(interaction.activePointerId); - interaction.activePointerId = null; - interaction.dragging = false; - const rect = canvas.getBoundingClientRect(); - interaction.pointerInside = - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom; - }; - - const handlePointerCancel = () => { - releasePointerCapture(interaction.activePointerId); - interaction.activePointerId = null; - interaction.dragging = false; - interaction.pointerInside = false; - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - }; - - const handleWindowBlur = () => { - handlePointerCancel(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointerup', handlePointerUp); - canvas.addEventListener('pointercancel', handlePointerCancel); - window.addEventListener('blur', handleWindowBlur); - canvas.addEventListener('pointerdown', handlePointerDown); - - const clock = new THREE.Timer(); - clock.connect(document); - let animationFrameId = 0; - - const renderFrame = (timestamp) => { - animationFrameId = window.requestAnimationFrame(renderFrame); - clock.update(timestamp); - - const currentSettings = getSettings(); - const deltaSeconds = clock.getDelta(); - const elapsedTime = clock.getElapsed(); - renderer.setClearColor( - currentSettings.background.color, - currentSettings.background.transparent ? 0 : 1, - ); - imageMaterial.uniforms.contrast.value = - currentSettings.halftone.imageContrast; - halftoneMaterial.uniforms.tile.value = currentSettings.halftone.scale; - halftoneMaterial.uniforms.s_3.value = currentSettings.halftone.power; - halftoneMaterial.uniforms.s_4.value = currentSettings.halftone.width; - halftoneMaterial.uniforms.applyToDarkAreas.value = - currentSettings.halftone.toneTarget === 'dark' ? 1 : 0; - halftoneMaterial.uniforms.dashColor.value.set( - currentSettings.halftone.dashColor, - ); - halftoneMaterial.uniforms.hoverDashColor.value.set( - currentSettings.halftone.hoverDashColor, - ); - halftoneMaterial.uniforms.time.value = elapsedTime; - halftoneMaterial.uniforms.waveAmount.value = currentSettings.animation - .waveEnabled - ? currentSettings.animation.waveAmount - : 0; - halftoneMaterial.uniforms.waveSpeed.value = - currentSettings.animation.waveSpeed; - const hoverEasing = - 1 - - Math.exp( - -deltaSeconds * - (interaction.pointerInside ? imageHoverFadeIn : imageHoverFadeOut), - ); - interaction.hoverStrength += - ((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) * - hoverEasing; - - interaction.smoothedMouseX += - (interaction.mouseX - interaction.smoothedMouseX) * imagePointerFollow; - interaction.smoothedMouseY += - (interaction.mouseY - interaction.smoothedMouseY) * imagePointerFollow; - interaction.pointerVelocityX *= imagePointerVelocityDamping; - interaction.pointerVelocityY *= imagePointerVelocityDamping; - - halftoneMaterial.uniforms.interactionUv.value.set( - interaction.smoothedMouseX, - 1 - interaction.smoothedMouseY, - ); - halftoneMaterial.uniforms.interactionVelocity.value.set( - interaction.pointerVelocityX * getVirtualWidth(), - -interaction.pointerVelocityY * getVirtualHeight(), - ); - halftoneMaterial.uniforms.dragOffset.value.set(0, 0); - halftoneMaterial.uniforms.hoverHalftoneActive.value = currentSettings - .animation.hoverHalftoneEnabled - ? interaction.hoverStrength - : 0; - halftoneMaterial.uniforms.hoverHalftonePowerShift.value = currentSettings - .animation.hoverHalftoneEnabled - ? currentSettings.animation.hoverHalftonePowerShift - : 0; - halftoneMaterial.uniforms.hoverHalftoneRadius.value = - currentSettings.animation.hoverHalftoneRadius; - halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = currentSettings - .animation.hoverHalftoneEnabled - ? currentSettings.animation.hoverHalftoneWidthShift - : 0; - halftoneMaterial.uniforms.hoverLightStrength.value = currentSettings - .animation.hoverLightEnabled - ? currentSettings.animation.hoverLightIntensity * - interaction.hoverStrength - : 0; - halftoneMaterial.uniforms.hoverLightRadius.value = - currentSettings.animation.hoverLightRadius; - halftoneMaterial.uniforms.hoverFlowStrength.value = 0; - halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18; - halftoneMaterial.uniforms.dragFlowStrength.value = 0; - imageMaterial.uniforms.zoom.value = getImagePreviewZoom(previewDistance); - halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale(); - - renderer.setRenderTarget(sceneTarget); - renderer.render(imageScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderFrame(); - - return () => { - window.cancelAnimationFrame(animationFrameId); - clock.dispose(); - resizeObserver.disconnect(); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - canvas.removeEventListener('pointerup', handlePointerUp); - canvas.removeEventListener('pointercancel', handlePointerCancel); - window.removeEventListener('blur', handleWindowBlur); - canvas.removeEventListener('pointerdown', handlePointerDown); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - imageMaterial.dispose(); - imageTexture.dispose(); - fullScreenGeometry.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; -} +import { type CSSProperties } from 'react'; + +import { + buildMilestoneSettings, + MILESTONE_IMAGE_FIT, + MILESTONE_IMAGE_URL, + MILESTONE_INITIAL_POSE, + MILESTONE_PREVIEW_DISTANCE, + type MilestoneSettingsOverrides, +} from './milestone-config'; const StyledVisualMount = styled.div` background: transparent; @@ -750,54 +26,37 @@ const StyledVisualMount = styled.div` type MilestoneProps = { imageUrl?: string; - settings?: unknown; + settings?: MilestoneSettingsOverrides; style?: CSSProperties; }; export function Milestone({ - imageUrl = '/illustrations/generated/milestone.jpg', + imageUrl = MILESTONE_IMAGE_URL, settings, style, }: MilestoneProps) { - const mountReference = useRef(null); - const resolvedSettings = resolveMilestoneSettings(settings); - const settingsReference = useRef(resolvedSettings); - - settingsReference.current = resolvedSettings; - - useEffect(() => { - const container = mountReference.current; - - if (!container) { - return; - } - - const unmountPromise = mountHalftoneCanvas({ - container, - imageUrl, - settingsRef: settingsReference, - }).catch((error) => { - if (process.env.NODE_ENV !== 'production') { - console.error(error); - } - return undefined; - }); - - return () => { - void unmountPromise.then((dispose) => dispose?.()); - }; - }, [imageUrl]); + const resolvedSettings: HalftoneStudioSettings = + buildMilestoneSettings(settings); return ( + > + + ); } +export type { MilestoneSettingsOverrides }; + export default Milestone; diff --git a/packages/twenty-website-new/src/sections/Hero/visuals/ProductEffect.tsx b/packages/twenty-website-new/src/sections/Hero/visuals/ProductEffect.tsx index 5513530961..41bf0f455a 100644 --- a/packages/twenty-website-new/src/sections/Hero/visuals/ProductEffect.tsx +++ b/packages/twenty-website-new/src/sections/Hero/visuals/ProductEffect.tsx @@ -1,2009 +1,16 @@ -// @ts-nocheck 'use client'; -import { useEffect, useRef, type CSSProperties } from 'react'; -import * as THREE from 'three'; +import { HalftoneModelCanvas } from '@/lib/halftone'; import { styled } from '@linaria/react'; -import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; -import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; -import { GLASS_ENVIRONMENT_TEXTURE_URL } from '@/lib/visual-runtime/textures/glass-environment'; -import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; - -const settings = { - sourceMode: 'shape', - shapeKey: 'userUpload_1776153228532', - lighting: { - intensity: 0.5, - fillIntensity: 0, - ambientIntensity: 0, - angleDegrees: 80, - height: -4, - }, - material: { - surface: 'solid', - color: '#F5F5F5', - roughness: 0.4, - metalness: 0.1, - thickness: 150, - refraction: 2, - environmentPower: 5, - }, - halftone: { - enabled: true, - scale: 12, - power: 0.1, - width: 0.6, - imageContrast: 1, - dashColor: '#4A38F5', - hoverDashColor: '#4A38F5', - }, - background: { - transparent: true, - color: '#F4F4F4', - }, - animation: { - autoRotateEnabled: true, - breatheEnabled: false, - cameraParallaxEnabled: false, - followHoverEnabled: false, - followDragEnabled: true, - floatEnabled: false, - hoverHalftoneEnabled: false, - hoverLightEnabled: false, - dragFlowEnabled: false, - lightSweepEnabled: false, - rotateEnabled: false, - autoSpeed: 0.01, - autoWobble: 0.2, - breatheAmount: 0.04, - breatheSpeed: 0.8, - cameraParallaxAmount: 0.3, - cameraParallaxEase: 0.08, - driftAmount: 8, - hoverRange: 25, - hoverEase: 0.08, - hoverReturn: true, - dragSens: 0.008, - dragFriction: 0.08, - dragMomentum: true, - rotateAxis: 'y', - rotatePreset: 'axis', - rotateSpeed: 0.1, - rotatePingPong: false, - floatAmplitude: 0.16, - floatSpeed: 0.8, - lightSweepHeightRange: 0.5, - lightSweepRange: 28, - lightSweepSpeed: 0.7, - springDamping: 0.52, - springReturnEnabled: true, - springStrength: 0.2, - hoverHalftonePowerShift: 0.42, - hoverHalftoneRadius: 0.2, - hoverHalftoneWidthShift: -0.18, - hoverLightIntensity: 0.8, - hoverLightRadius: 0.2, - dragFlowDecay: 0.08, - dragFlowRadius: 0.24, - dragFlowStrength: 1.8, - hoverWarpStrength: 3, - hoverWarpRadius: 0.15, - dragWarpStrength: 5, - waveEnabled: false, - waveSpeed: 1, - waveAmount: 2, - }, -}; -const shape = { - filename: 'hero.glb', - key: 'userUpload_1776153228532', - kind: 'imported', - label: 'hero.glb', - loader: 'glb', -}; -const initialPose = { - autoElapsed: 0, - rotateElapsed: 0, - rotationX: 0.4, - rotationY: 0, - rotationZ: 0.6, - targetRotationX: 0, - targetRotationY: 0, - timeElapsed: 0, -}; -const previewDistance = 2.5; -const VIRTUAL_RENDER_HEIGHT = 400; -const passThroughVertexShader = - '\n varying vec2 vUv;\n\n void main() {\n vUv = uv;\n gl_Position = vec4(position, 1.0);\n }\n'; -const blurFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tInput;\n uniform vec2 dir;\n uniform vec2 res;\n\n varying vec2 vUv;\n\n void main() {\n vec4 sum = vec4(0.0);\n vec2 px = dir / res;\n\n float w[5];\n w[0] = 0.227027;\n w[1] = 0.1945946;\n w[2] = 0.1216216;\n w[3] = 0.054054;\n w[4] = 0.016216;\n\n sum += texture2D(tInput, vUv) * w[0];\n\n for (int i = 1; i < 5; i++) {\n float fi = float(i) * 3.0;\n sum += texture2D(tInput, vUv + px * fi) * w[i];\n sum += texture2D(tInput, vUv - px * fi) * w[i];\n }\n\n gl_FragColor = sum;\n }\n'; -const halftoneFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tScene;\n uniform sampler2D tGlow;\n uniform vec2 effectResolution;\n uniform vec2 logicalResolution;\n uniform float tile;\n uniform float s_3;\n uniform float s_4;\n uniform vec3 dashColor;\n uniform vec3 hoverDashColor;\n uniform float time;\n uniform float waveAmount;\n uniform float waveSpeed;\n uniform float footprintScale;\n uniform vec2 interactionUv;\n uniform vec2 interactionVelocity;\n uniform vec2 dragOffset;\n uniform float hoverHalftoneActive;\n uniform float hoverHalftonePowerShift;\n uniform float hoverHalftoneRadius;\n uniform float hoverHalftoneWidthShift;\n uniform float hoverLightStrength;\n uniform float hoverLightRadius;\n uniform float hoverFlowStrength;\n uniform float hoverFlowRadius;\n uniform float dragFlowStrength;\n uniform float cropToBounds;\n\n varying vec2 vUv;\n\n float distSegment(in vec2 p, in vec2 a, in vec2 b) {\n vec2 pa = p - a;\n vec2 ba = b - a;\n float denom = max(dot(ba, ba), 0.000001);\n float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);\n return length(pa - ba * h);\n }\n\n float lineSimpleEt(in vec2 p, in float r, in float thickness) {\n vec2 a = vec2(0.5) + vec2(-r, 0.0);\n vec2 b = vec2(0.5) + vec2(r, 0.0);\n float distToSegment = distSegment(p, a, b);\n float halfThickness = thickness * r;\n return distToSegment - halfThickness;\n }\n\n void main() {\n if (cropToBounds > 0.5) {\n vec4 boundsCheck = texture2D(tScene, vUv);\n if (boundsCheck.a < 0.01) {\n gl_FragColor = vec4(0.0);\n return;\n }\n }\n\n vec2 fragCoord =\n (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;\n float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);\n vec2 pointerPx = interactionUv * logicalResolution;\n vec2 fragDelta = fragCoord - pointerPx;\n float fragDist = length(fragDelta);\n vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);\n float velocityMagnitude = length(interactionVelocity);\n vec2 motionDir = velocityMagnitude > 0.001\n ? interactionVelocity / velocityMagnitude\n : vec2(0.0, 0.0);\n float motionBias = velocityMagnitude > 0.001\n ? dot(-radialDir, motionDir) * 0.5 + 0.5\n : 0.5;\n\n float hoverLightMask = 0.0;\n if (hoverLightStrength > 0.0) {\n float lightRadiusPx = hoverLightRadius * logicalResolution.y;\n hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);\n }\n\n float hoverHalftoneMask = 0.0;\n if (hoverHalftoneActive > 0.0) {\n float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;\n hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist);\n }\n\n float hoverFlowMask = 0.0;\n if (hoverFlowStrength > 0.0) {\n float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;\n hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);\n }\n\n vec2 hoverDisplacement =\n radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +\n motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;\n vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;\n vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;\n\n float bandRow = floor(effectCoord.y / halftoneSize);\n float waveOffset =\n waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;\n effectCoord.x += waveOffset;\n\n vec2 cellIndex = floor(effectCoord / halftoneSize);\n vec2 sampleUv = clamp(\n (cellIndex + 0.5) * halftoneSize / logicalResolution,\n vec2(0.0),\n vec2(1.0)\n );\n vec2 cellUv = fract(effectCoord / halftoneSize);\n\n vec4 sceneSample = texture2D(tScene, sampleUv);\n float mask = smoothstep(0.02, 0.08, sceneSample.a);\n float localPower = clamp(\n s_3 + hoverHalftonePowerShift * hoverHalftoneMask,\n -1.5,\n 1.5\n );\n float localWidth = clamp(\n s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,\n 0.05,\n 1.4\n );\n float lightLift =\n hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;\n float bandRadius = clamp(\n (\n (\n sceneSample.r +\n sceneSample.g +\n sceneSample.b +\n localPower * length(vec2(0.5))\n ) *\n (1.0 / 3.0)\n ) + lightLift,\n 0.0,\n 1.0\n ) * 1.86 * 0.5;\n\n float alpha = 0.0;\n if (bandRadius > 0.0001) {\n float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);\n float edge = 0.02;\n alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;\n }\n\n vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);\n vec3 color = activeDashColor * alpha;\n gl_FragColor = vec4(color, alpha);\n\n #include \n #include \n }\n'; - -const REFERENCE_PREVIEW_DISTANCE = 4; -const MIN_FOOTPRINT_SCALE = 0.001; - -function clampRectToViewport(rect, viewportWidth, viewportHeight) { - const minX = Math.max(rect.x, 0); - const minY = Math.max(rect.y, 0); - const maxX = Math.min(rect.x + rect.width, viewportWidth); - const maxY = Math.min(rect.y + rect.height, viewportHeight); - - if (maxX <= minX || maxY <= minY) { - return null; - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; -} - -function getRectArea(rect) { - if (!rect) { - return 0; - } - - return Math.max(rect.width, 0) * Math.max(rect.height, 0); -} - -function createBox3Corners(bounds) { - const { min, max } = bounds; - - return [ - new THREE.Vector3(min.x, min.y, min.z), - new THREE.Vector3(min.x, min.y, max.z), - new THREE.Vector3(min.x, max.y, min.z), - new THREE.Vector3(min.x, max.y, max.z), - new THREE.Vector3(max.x, min.y, min.z), - new THREE.Vector3(max.x, min.y, max.z), - new THREE.Vector3(max.x, max.y, min.z), - new THREE.Vector3(max.x, max.y, max.z), - ]; -} - -function getImagePreviewZoom(previewDistance) { - return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); -} - -function getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom, -}) { - if ( - imageWidth <= 0 || - imageHeight <= 0 || - viewportWidth <= 0 || - viewportHeight <= 0 - ) { - return null; - } - - const imageAspect = imageWidth / imageHeight; - const viewAspect = viewportWidth / viewportHeight; - - let fittedWidth = viewportWidth; - let fittedHeight = viewportHeight; - - if (imageAspect > viewAspect) { - fittedHeight = viewportWidth / imageAspect; - } else { - fittedWidth = viewportHeight * imageAspect; - } - - const scaledWidth = fittedWidth * zoom; - const scaledHeight = fittedHeight * zoom; - - return clampRectToViewport( - { - x: (viewportWidth - scaledWidth) * 0.5, - y: (viewportHeight - scaledHeight) * 0.5, - width: scaledWidth, - height: scaledHeight, - }, - viewportWidth, - viewportHeight, - ); -} - -function getFootprintScaleFromRects(currentRect, referenceRect) { - const currentArea = getRectArea(currentRect); - const referenceArea = getRectArea(referenceRect); - - if (currentArea <= 0 || referenceArea <= 0) { - return 1; - } - - return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE); -} - -function getImageFootprintScale({ - imageHeight, - imageWidth, - previewDistance, - viewportHeight, - viewportWidth, -}) { - const currentRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: getImagePreviewZoom(previewDistance), - }); - const referenceRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: 1, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function projectBox3ToViewport({ - camera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, -}) { - if (localBounds.isEmpty() || viewportWidth <= 0 || viewportHeight <= 0) { - return null; - } - - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - let hasProjectedCorner = false; - - for (const corner of createBox3Corners(localBounds)) { - corner.applyMatrix4(meshMatrixWorld).project(camera); - - if ( - !Number.isFinite(corner.x) || - !Number.isFinite(corner.y) || - !Number.isFinite(corner.z) - ) { - continue; - } - - hasProjectedCorner = true; - - const x = (corner.x * 0.5 + 0.5) * viewportWidth; - const y = (1 - (corner.y * 0.5 + 0.5)) * viewportHeight; - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - - if (!hasProjectedCorner) { - return null; - } - - return clampRectToViewport( - { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }, - viewportWidth, - viewportHeight, - ); -} - -function getMeshFootprintScale({ - camera, - localBounds, - lookAtTarget, - meshMatrixWorld, - viewportHeight, - viewportWidth, -}) { - const currentRect = projectBox3ToViewport({ - camera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, - }); - const referenceCamera = camera.clone(); - const currentOffset = referenceCamera.position.clone().sub(lookAtTarget); - const referenceOffset = - currentOffset.lengthSq() > 0 - ? currentOffset.setLength(REFERENCE_PREVIEW_DISTANCE) - : new THREE.Vector3(0, 0, REFERENCE_PREVIEW_DISTANCE); - - referenceCamera.position.copy(lookAtTarget).add(referenceOffset); - referenceCamera.lookAt(lookAtTarget); - referenceCamera.updateProjectionMatrix(); - referenceCamera.updateMatrixWorld(true); - - const referenceRect = projectBox3ToViewport({ - camera: referenceCamera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function makePolarShape(radiusFunction, segments = 320) { - const shape = new THREE.Shape(); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * Math.PI * 2; - const radius = radiusFunction(angle); - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius; - - if (segmentIndex === 0) { - shape.moveTo(x, y); - } else { - shape.lineTo(x, y); - } - } - - return shape; -} - -function makeReliefGeometry(shape, options = {}) { - const { - bevelSegments = 8, - bevelSize = 0.08, - bevelThickness = 0.1, - depth = 0.58, - waveDepth = 0.016, - waves = 8, - } = options; - - const geometry = new THREE.ExtrudeGeometry(shape, { - depth, - steps: 2, - bevelEnabled: true, - bevelThickness, - bevelSize, - bevelSegments, - curveSegments: 96, - }); - - geometry.center(); - - const position = geometry.attributes.position; - let maxRadius = 0; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - maxRadius = Math.max( - maxRadius, - Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), - ); - } - - const fullDepth = depth + bevelThickness * 2; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - const x = position.getX(vertexIndex); - const y = position.getY(vertexIndex); - const z = position.getZ(vertexIndex); - const radius = Math.hypot(x, y) / maxRadius; - const angle = Math.atan2(y, x); - const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); - const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); - const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); - const wave = - Math.cos(angle * waves) * - Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * - waveDepth; - const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); - - position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); - } - - position.needsUpdate = true; - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function mergeGeometries(geometries) { - if (geometries.length === 1) { - return geometries[0]; - } - - let totalVertices = 0; - let totalIndices = 0; - let hasUv = false; - - const geometryInfos = geometries.map((geometry) => { - const position = geometry.attributes.position; - const normal = geometry.attributes.normal; - const uv = geometry.attributes.uv ?? null; - const index = geometry.index; - const indexCount = index ? index.count : position.count; - - totalVertices += position.count; - totalIndices += indexCount; - hasUv = hasUv || uv !== null; - - return { - index, - indexCount, - normal, - position, - uv, - vertexCount: position.count, - }; - }); - - const positions = new Float32Array(totalVertices * 3); - const normals = new Float32Array(totalVertices * 3); - const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; - const indices = new Uint32Array(totalIndices); - - let vertexOffset = 0; - let indexOffset = 0; - - for (const geometryInfo of geometryInfos) { - for ( - let vertexIndex = 0; - vertexIndex < geometryInfo.vertexCount; - vertexIndex += 1 - ) { - const positionOffset = (vertexOffset + vertexIndex) * 3; - positions[positionOffset] = geometryInfo.position.getX(vertexIndex); - positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); - positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); - normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); - normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); - normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); - - if (uvs !== null) { - const uvOffset = (vertexOffset + vertexIndex) * 2; - uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; - uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; - } - } - - if (geometryInfo.index) { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = - geometryInfo.index.getX(localIndex) + vertexOffset; - } - } else { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = localIndex + vertexOffset; - } - } - - vertexOffset += geometryInfo.vertexCount; - indexOffset += geometryInfo.indexCount; - } - - const merged = new THREE.BufferGeometry(); - merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); - - if (uvs !== null) { - merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); - } - - merged.setIndex(new THREE.BufferAttribute(indices, 1)); - - return merged; -} - -function makeArrowTarget() { - const targetParts = []; - const arrowParts = []; - const baseRadius = 1.35; - const baseDepth = 0.32; - const bevel = 0.12; - const points = []; - const segments = 16; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - -baseDepth / 2 + bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - baseDepth / 2 - bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - targetParts.push(disc); - - for (const radius of [0.45, 0.85, 1.22]) { - const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); - ring.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), - ); - targetParts.push(ring); - } - - const bump = new THREE.SphereGeometry( - 0.32, - 32, - 24, - 0, - Math.PI * 2, - 0, - Math.PI / 2, - ); - bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); - bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); - targetParts.push(bump); - - const shaftLength = 1.5; - const shaftRadius = 0.05; - const shaft = new THREE.CylinderGeometry( - shaftRadius, - shaftRadius, - shaftLength, - 10, - 1, - ); - shaft.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0), - ); - arrowParts.push(shaft); - - const head = new THREE.ConeGeometry(0.12, 0.35, 10); - head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); - arrowParts.push(head); - - for (let finIndex = 0; finIndex < 3; finIndex += 1) { - const finShape = new THREE.Shape(); - finShape.moveTo(0, 0); - finShape.lineTo(0.22, 0.25); - finShape.lineTo(0, 0.5); - finShape.lineTo(0, 0); - - const finGeometry = new THREE.ExtrudeGeometry(finShape, { - depth: 0.012, - bevelEnabled: false, - }); - - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, 0, -0.006), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), - ); - arrowParts.push(finGeometry); - } - - const nock = new THREE.SphereGeometry(0.065, 8, 8); - nock.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0), - ); - arrowParts.push(nock); - - const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); - const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); - const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); - - for (const geometry of arrowParts) { - geometry.applyMatrix4(aim); - geometry.applyMatrix4(tilt); - geometry.applyMatrix4(shift); - } - - const merged = mergeGeometries([...targetParts, ...arrowParts]); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function makeDollarCoin() { - const parts = []; - const baseRadius = 1.3; - const baseDepth = 0.45; - const bevel = 0.18; - const points = []; - const segments = 20; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - Math.sin(angle) * (baseDepth / 2), - ), - ); - } - - points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - parts.push(disc); - - const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - frontRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), - ); - parts.push(frontRim); - - const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - backRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), - ); - parts.push(backRim); - - const createDollarSign = () => { - const geometries = []; - const tubeRadius = 0.1; - const curveRadius = 0.28; - const verticalOffset = 0.22; - - const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); - geometries.push(bar); - - const topArc = new THREE.TorusGeometry( - curveRadius, - tubeRadius, - 16, - 32, - Math.PI, - ); - topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), - ); - geometries.push(topArc); - - const bottomArc = new THREE.TorusGeometry( - curveRadius, - tubeRadius, - 16, - 32, - Math.PI, - ); - bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); - bottomArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), - ); - geometries.push(bottomArc); - - const topSerif = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - 0.22, - 12, - ); - topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation( - 0.16, - verticalOffset + curveRadius, - 0, - ), - ); - geometries.push(topSerif); - - const bottomSerif = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - 0.22, - 12, - ); - bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - bottomSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation( - -0.16, - -verticalOffset - curveRadius, - 0, - ), - ); - geometries.push(bottomSerif); - - const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); - const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); - const diagonal = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - diagonalLength + 0.12, - 12, - ); - diagonal.applyMatrix4( - new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), - ); - geometries.push(diagonal); - - return geometries; - }; - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), - ); - parts.push(geometry); - } - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), - ); - parts.push(geometry); - } - - const merged = mergeGeometries(parts); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function createBuiltinGeometry(shapeKey) { - switch (shapeKey) { - case 'torusKnot': - return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); - case 'sphere': - return new THREE.SphereGeometry(1.4, 64, 64); - case 'torus': - return new THREE.TorusGeometry(1, 0.45, 64, 100); - case 'icosahedron': - return new THREE.IcosahedronGeometry(1.4, 4); - case 'box': - return new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6); - case 'cone': - return new THREE.ConeGeometry(1.2, 2.4, 64, 10); - case 'cylinder': - return new THREE.CylinderGeometry(1, 1, 2.3, 64, 10); - case 'octahedron': - return new THREE.OctahedronGeometry(1.5, 2); - case 'dodecahedron': - return new THREE.DodecahedronGeometry(1.35, 1); - case 'tetrahedron': - return new THREE.TetrahedronGeometry(1.7, 1); - case 'sunCoin': - return makeReliefGeometry( - makePolarShape( - (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), - ), - { depth: 0.62, waves: 12, waveDepth: 0.018 }, - ); - case 'lotusCoin': - return makeReliefGeometry( - makePolarShape( - (angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2), - ), - { depth: 0.64, waves: 8, waveDepth: 0.014 }, - ); - case 'arrowTarget': - return makeArrowTarget(); - case 'dollarCoin': - return makeDollarCoin(); - default: - return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); - } -} - -const EMPTY_TEXTURE_DATA_URL = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; - -function createLoadingManager() { - const loadingManager = new THREE.LoadingManager(); - loadingManager.setURLModifier((url) => - /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, - ); - return loadingManager; -} - -function normalizeImportedGeometry(geometry) { - geometry.computeBoundingBox(); - - let boundingBox = geometry.boundingBox; - let center = new THREE.Vector3(); - let size = new THREE.Vector3(); - - boundingBox?.getCenter(center); - boundingBox?.getSize(size); - geometry.translate(-center.x, -center.y, -center.z); - - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - const radius = geometry.boundingSphere?.radius || 1; - const scale = 1.6 / radius; - geometry.scale(scale, scale, scale); - - geometry.computeBoundingBox(); - boundingBox = geometry.boundingBox; - center = new THREE.Vector3(); - boundingBox?.getCenter(center); - geometry.translate(-center.x, -center.y, -center.z); - - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function extractMergedGeometry(root, emptyMessage) { - root.updateMatrixWorld(true); - const geometries = []; - - root.traverse((object) => { - if (!(object instanceof THREE.Mesh) || !object.geometry) { - return; - } - - const geometry = object.geometry.clone(); - - if (!geometry.attributes.normal) { - geometry.computeVertexNormals(); - } - - geometry.applyMatrix4(object.matrixWorld); - geometries.push(geometry); - }); - - if (geometries.length === 0) { - throw new Error(emptyMessage); - } - - return normalizeImportedGeometry(mergeGeometries(geometries)); -} - -function parseGlbGeometry(buffer, label) { - return new Promise((resolve, reject) => { - const loadingManager = createLoadingManager(); - const dracoLoader = new DRACOLoader(loadingManager); - dracoLoader.setDecoderPath(DRACO_DECODER_PATH); - - const loader = new GLTFLoader(loadingManager); - loader.setDRACOLoader(dracoLoader); - - const cleanup = () => { - dracoLoader.dispose(); - }; - - loader.parse( - buffer, - '', - (gltf) => { - try { - resolve( - extractMergedGeometry( - gltf.scene, - label + ' did not contain any mesh geometry.', - ), - ); - } catch (error) { - reject(error); - } finally { - cleanup(); - } - }, - (error) => { - cleanup(); - reject(error); - }, - ); - }); -} - -async function loadImportedGeometryFromUrl(modelUrl, label) { - const response = await fetch(modelUrl); - - if (!response.ok) { - throw new Error('Unable to load ' + label + ' from ' + modelUrl + '.'); - } - - const buffer = await response.arrayBuffer(); - - return parseGlbGeometry(buffer, label); -} - -const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320; -const GLASS_ATTENUATION_DISTANCE_MIN = 0.12; -const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18; -const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12; -const GLASS_ENVIRONMENT_ZOOM = 1.55; -const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303); -const MAX_TEXTURE_ANISOTROPY = 8; -const HALFTONE_TRANSMISSION_SHADER_PREFIX = - '\nuniform float chromaticAberration;\nuniform float anisotropicBlur;\nuniform float time;\nuniform float distortion;\nuniform float distortionScale;\nuniform float temporalDistortion;\nuniform sampler2D buffer;\n\nvec3 random3(vec3 c) {\n float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0)));\n vec3 r;\n r.z = fract(512.0 * j);\n j *= 0.125;\n r.x = fract(512.0 * j);\n j *= 0.125;\n r.y = fract(512.0 * j);\n return r - 0.5;\n}\n\nuint hash(uint x) {\n x += (x << 10u);\n x ^= (x >> 6u);\n x += (x << 3u);\n x ^= (x >> 11u);\n x += (x << 15u);\n return x;\n}\n\nuint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); }\nuint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); }\nuint hash(uvec4 v) {\n return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w));\n}\n\nfloat floatConstruct(uint m) {\n const uint ieeeMantissa = 0x007FFFFFu;\n const uint ieeeOne = 0x3F800000u;\n m &= ieeeMantissa;\n m |= ieeeOne;\n float f = uintBitsToFloat(m);\n return f - 1.0;\n}\n\nfloat randomBase(float x) {\n return floatConstruct(hash(floatBitsToUint(x)));\n}\nfloat randomBase(vec2 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\nfloat randomBase(vec3 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\nfloat randomBase(vec4 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\n\nfloat rand(float seed) {\n return randomBase(vec3(gl_FragCoord.xy, seed));\n}\n\nconst float F3 = 0.3333333;\nconst float G3 = 0.1666667;\n\nfloat snoise(vec3 p) {\n vec3 s = floor(p + dot(p, vec3(F3)));\n vec3 x = p - s + dot(s, vec3(G3));\n vec3 e = step(vec3(0.0), x - x.yzx);\n vec3 i1 = e * (1.0 - e.zxy);\n vec3 i2 = 1.0 - e.zxy * (1.0 - e);\n vec3 x1 = x - i1 + G3;\n vec3 x2 = x - i2 + 2.0 * G3;\n vec3 x3 = x - 1.0 + 3.0 * G3;\n vec4 w;\n vec4 d;\n w.x = dot(x, x);\n w.y = dot(x1, x1);\n w.z = dot(x2, x2);\n w.w = dot(x3, x3);\n w = max(0.6 - w, 0.0);\n d.x = dot(random3(s), x);\n d.y = dot(random3(s + i1), x1);\n d.z = dot(random3(s + i2), x2);\n d.w = dot(random3(s + 1.0), x3);\n w *= w;\n w *= w;\n d *= w;\n return dot(d, vec4(52.0));\n}\n\nfloat snoiseFractal(vec3 m) {\n return 0.5333333 * snoise(m)\n + 0.2666667 * snoise(2.0 * m)\n + 0.1333333 * snoise(4.0 * m)\n + 0.0666667 * snoise(8.0 * m);\n}\n'; -const HALFTONE_TRANSMISSION_PARS_FRAGMENT = - '\n#ifdef USE_TRANSMISSION\n uniform float _transmission;\n uniform float thickness;\n uniform float attenuationDistance;\n uniform vec3 attenuationColor;\n uniform sampler2D refractionEnvMap;\n uniform float useEnvMapRefraction;\n #ifdef USE_TRANSMISSIONMAP\n uniform sampler2D transmissionMap;\n #endif\n #ifdef USE_THICKNESSMAP\n uniform sampler2D thicknessMap;\n #endif\n uniform vec2 transmissionSamplerSize;\n uniform sampler2D transmissionSamplerMap;\n uniform mat4 modelMatrix;\n uniform mat4 projectionMatrix;\n varying vec3 vWorldPosition;\n\n vec3 getVolumeTransmissionRay(\n const in vec3 n,\n const in vec3 v,\n const in float thicknessValue,\n const in float ior,\n const in mat4 modelMatrix\n ) {\n vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior);\n vec3 modelScale;\n modelScale.x = length(vec3(modelMatrix[0].xyz));\n modelScale.y = length(vec3(modelMatrix[1].xyz));\n modelScale.z = length(vec3(modelMatrix[2].xyz));\n return normalize(refractionVector) * thicknessValue * modelScale;\n }\n\n float applyIorToRoughness(\n const in float roughnessValue,\n const in float ior\n ) {\n return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0);\n }\n\n vec2 directionToEquirectUv(const in vec3 direction) {\n vec3 dir = normalize(direction);\n vec2 uv = vec2(\n atan(dir.z, dir.x) * 0.15915494309189535 + 0.5,\n asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5\n );\n\n return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0));\n }\n\n vec4 getTransmissionSample(\n const in vec2 fragCoord,\n const in vec3 transmissionDirection,\n const in float roughnessValue,\n const in float ior\n ) {\n if (useEnvMapRefraction > 0.5) {\n return texture2D(\n refractionEnvMap,\n directionToEquirectUv(transmissionDirection)\n );\n }\n\n float framebufferLod =\n log2(transmissionSamplerSize.x) *\n applyIorToRoughness(roughnessValue, ior);\n return texture2D(buffer, fragCoord.xy);\n }\n\n vec3 applyVolumeAttenuation(\n const in vec3 radiance,\n const in float transmissionDistance,\n const in vec3 attenuationColorValue,\n const in float attenuationDistanceValue\n ) {\n if (isinf(attenuationDistanceValue)) {\n return radiance;\n }\n\n vec3 attenuationCoefficient =\n -log(attenuationColorValue) / attenuationDistanceValue;\n vec3 transmittance =\n exp(-attenuationCoefficient * transmissionDistance);\n\n return transmittance * radiance;\n }\n\n vec4 getIBLVolumeRefraction(\n const in vec3 n,\n const in vec3 v,\n const in float roughnessValue,\n const in vec3 diffuseColor,\n const in vec3 specularColor,\n const in float specularF90,\n const in vec3 position,\n const in mat4 modelMatrix,\n const in mat4 viewMatrix,\n const in mat4 projMatrix,\n const in float ior,\n const in float thicknessValue,\n const in vec3 attenuationColorValue,\n const in float attenuationDistanceValue\n ) {\n vec3 transmissionRay = getVolumeTransmissionRay(\n n,\n v,\n thicknessValue,\n ior,\n modelMatrix\n );\n vec3 refractedRayExit = position + transmissionRay;\n vec4 ndcPos =\n projMatrix * viewMatrix * vec4(refractedRayExit, 1.0);\n vec2 refractionCoords = ndcPos.xy / ndcPos.w;\n refractionCoords += 1.0;\n refractionCoords /= 2.0;\n vec3 transmissionDirection = normalize(transmissionRay);\n vec4 transmittedLight = getTransmissionSample(\n refractionCoords,\n transmissionDirection,\n roughnessValue,\n ior\n );\n vec3 attenuatedColor = applyVolumeAttenuation(\n transmittedLight.rgb,\n length(transmissionRay),\n attenuationColorValue,\n attenuationDistanceValue\n );\n vec3 F = EnvironmentBRDF(\n n,\n v,\n specularColor,\n specularF90,\n roughnessValue\n );\n return vec4(\n (1.0 - F) * attenuatedColor * diffuseColor,\n transmittedLight.a\n );\n }\n#endif\n'; -const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = - '\nmaterial.transmission = _transmission;\nmaterial.transmissionAlpha = 1.0;\nmaterial.thickness = thickness;\nmaterial.attenuationDistance = attenuationDistance;\nmaterial.attenuationColor = attenuationColor;\n#ifdef USE_TRANSMISSIONMAP\n material.transmission *= texture2D(transmissionMap, vUv).r;\n#endif\n#ifdef USE_THICKNESSMAP\n material.thickness *= texture2D(thicknessMap, vUv).g;\n#endif\n\nvec3 pos = vWorldPosition;\nfloat runningSeed = 0.0;\nvec3 v = normalize(cameraPosition - pos);\nvec3 n = inverseTransformDirection(normal, viewMatrix);\nvec3 transmission = vec3(0.0);\nfloat transmissionR;\nfloat transmissionG;\nfloat transmissionB;\nfloat randomCoords = rand(runningSeed++);\nfloat thicknessSmear =\n thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur);\nvec3 distortionNormal = vec3(0.0);\nvec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion;\n\nif (distortion > 0.0) {\n distortionNormal = distortion * vec3(\n snoiseFractal(vec3(pos * distortionScale + temporalOffset)),\n snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)),\n snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset))\n );\n}\n\nfor (float i = 0.0; i < __SAMPLES__.0; i++) {\n vec3 sampleNorm = normalize(\n n +\n roughnessFactor * roughnessFactor * 2.0 *\n normalize(\n vec3(\n rand(runningSeed++) - 0.5,\n rand(runningSeed++) - 0.5,\n rand(runningSeed++) - 0.5\n )\n ) *\n pow(rand(runningSeed++), 0.33) +\n distortionNormal\n );\n\n transmissionR = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior,\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).r;\n\n transmissionG = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).g;\n\n transmissionB = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).b;\n\n transmission.r += transmissionR;\n transmission.g += transmissionG;\n transmission.b += transmissionB;\n}\n\ntransmission /= __SAMPLES__.0;\ntotalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission);\n'; - -class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial { - constructor(samples = 10) { - super(); - - this.halftoneUniforms = { - chromaticAberration: { value: 0.05 }, - transmission: { value: 0 }, - _transmission: { value: 1 }, - transmissionMap: { value: null }, - refractionEnvMap: { value: null }, - useEnvMapRefraction: { value: 0 }, - roughness: { value: 0 }, - thickness: { value: 0 }, - thicknessMap: { value: null }, - attenuationDistance: { value: Infinity }, - attenuationColor: { value: new THREE.Color('white') }, - anisotropicBlur: { value: 0.1 }, - time: { value: 0 }, - distortion: { value: 0 }, - distortionScale: { value: 0.5 }, - temporalDistortion: { value: 0 }, - buffer: { value: null }, - }; - - this.customProgramCacheKey = () => 'halftone-transmission-' + samples; - - this.onBeforeCompile = (shader) => { - shader.uniforms = { - ...shader.uniforms, - ...this.halftoneUniforms, - }; - shader.defines ??= {}; - - if (this.anisotropy > 0) { - shader.defines.USE_ANISOTROPY = ''; - } - - shader.defines.USE_TRANSMISSION = ''; - shader.fragmentShader = - HALFTONE_TRANSMISSION_SHADER_PREFIX + shader.fragmentShader; - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - HALFTONE_TRANSMISSION_PARS_FRAGMENT, - ); - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE.replaceAll( - '__SAMPLES__', - String(samples), - ), - ); - }; - - Object.keys(this.halftoneUniforms).forEach((key) => { - Object.defineProperty(this, key, { - configurable: true, - enumerable: true, - get: () => this.halftoneUniforms[key]?.value, - set: (value) => { - this.halftoneUniforms[key].value = value; - }, - }); - }); - } -} - -function setTextureSampling(texture, renderer) { - texture.generateMipmaps = true; - texture.magFilter = THREE.LinearFilter; - texture.minFilter = THREE.LinearMipmapLinearFilter; - texture.anisotropy = Math.min( - renderer.capabilities.getMaxAnisotropy(), - MAX_TEXTURE_ANISOTROPY, - ); -} - -function disposeEnvironmentScene(scene) { - scene.traverse((object) => { - if (object.geometry) { - object.geometry.dispose(); - } - - if (Array.isArray(object.material)) { - object.material.forEach((material) => material.dispose()); - return; - } - - object.material?.dispose?.(); - }); -} - -function createSolidEnvironmentTexture(renderer) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = pmremGenerator.fromScene( - new RoomEnvironment(), - 0.04, - ).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function getTextureImageSize(texture) { - const image = texture.image; - - return { - height: - image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined, - width: - image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined, - }; -} - -function createZoomedGlassTexture(sourceTexture, renderer, zoom) { - if (zoom <= 1) { - return sourceTexture; - } - - const { width, height } = getTextureImageSize(sourceTexture); - - if (!width || !height) { - return sourceTexture; - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext('2d'); - - if (!context) { - return sourceTexture; - } - - const cropWidth = width / zoom; - const cropHeight = height / zoom; - const sourceX = (width - cropWidth) / 2; - const sourceY = (height - cropHeight) / 2; - - context.drawImage( - sourceTexture.image, - sourceX, - sourceY, - cropWidth, - cropHeight, - 0, - 0, - width, - height, - ); - - const zoomedTexture = new THREE.CanvasTexture(canvas); - zoomedTexture.colorSpace = sourceTexture.colorSpace; - zoomedTexture.wrapS = THREE.ClampToEdgeWrapping; - zoomedTexture.wrapT = THREE.ClampToEdgeWrapping; - setTextureSampling(zoomedTexture, renderer); - zoomedTexture.needsUpdate = true; - - return zoomedTexture; -} - -function createStudioGlassEnvironmentTexture(renderer, backdropTexture) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = backdropTexture - ? pmremGenerator.fromEquirectangular(backdropTexture).texture - : pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function createFallbackGlassBackdropTexture(renderer) { - const texture = new THREE.DataTexture( - new Uint8Array([3, 3, 3, 255]), - 1, - 1, - THREE.RGBAFormat, - ); - texture.colorSpace = THREE.SRGBColorSpace; - texture.wrapS = THREE.ClampToEdgeWrapping; - texture.wrapT = THREE.ClampToEdgeWrapping; - texture.mapping = THREE.EquirectangularReflectionMapping; - setTextureSampling(texture, renderer); - texture.needsUpdate = true; - - return texture; -} - -function loadTexture(url, renderer, colorSpace) { - const loader = new THREE.TextureLoader(); - - return new Promise((resolve, reject) => { - loader.load( - url, - (texture) => { - texture.colorSpace = colorSpace; - setTextureSampling(texture, renderer); - resolve(texture); - }, - undefined, - reject, - ); - }); -} - -async function loadGlassEnvironmentTexture(renderer) { - const sourceBackgroundTexture = await loadTexture( - GLASS_ENVIRONMENT_TEXTURE_URL, - renderer, - THREE.SRGBColorSpace, - ); - const backgroundTexture = createZoomedGlassTexture( - sourceBackgroundTexture, - renderer, - GLASS_ENVIRONMENT_ZOOM, - ); - if (backgroundTexture !== sourceBackgroundTexture) { - sourceBackgroundTexture.dispose(); - } - backgroundTexture.mapping = THREE.EquirectangularReflectionMapping; - backgroundTexture.wrapS = THREE.ClampToEdgeWrapping; - backgroundTexture.wrapT = THREE.ClampToEdgeWrapping; - backgroundTexture.needsUpdate = true; - const environmentTexture = createStudioGlassEnvironmentTexture( - renderer, - backgroundTexture, - ); - - return { - backgroundTexture, - environmentTexture, - }; -} - -async function createHalftoneMaterialAssets(renderer) { - const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer); - - try { - const glassEnvironmentAssets = await loadGlassEnvironmentTexture(renderer); - - return { - glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture, - glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture, - solidEnvironmentTexture, - }; - } catch { - const fallbackGlassBackdropTexture = - createFallbackGlassBackdropTexture(renderer); - const fallbackGlassEnvironmentTexture = - createStudioGlassEnvironmentTexture(renderer); - - return { - glassBackgroundTexture: fallbackGlassBackdropTexture, - glassEnvironmentTexture: fallbackGlassEnvironmentTexture, - solidEnvironmentTexture, - }; - } -} - -function createHalftoneMaterial() { - return new HalftoneTransmissionMaterial(); -} - -function applyHalftoneMaterialSettings( - material, - materialSettings, - materialAssets, -) { - const isGlass = materialSettings.surface === 'glass'; - const glassThickness = - materialSettings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS; - const glassEnvironmentIntensity = - GLASS_ENVIRONMENT_INTENSITY_BASE + - materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER; - const glassAttenuationDistance = Math.max( - glassThickness * 4, - GLASS_ATTENUATION_DISTANCE_MIN, - ); - - material.color.set(isGlass ? '#ffffff' : materialSettings.color); - material.roughness = materialSettings.roughness; - material.metalness = materialSettings.metalness; - material.envMap = isGlass - ? materialAssets.glassEnvironmentTexture - : materialAssets.solidEnvironmentTexture; - material.envMapIntensity = isGlass - ? GLASS_ENVIRONMENT_INTENSITY_BASE + - materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER - : 0.25; - material.clearcoat = isGlass ? 1 : 0; - material.clearcoatRoughness = isGlass - ? Math.max(materialSettings.roughness * 0.25, 0.01) - : 0.08; - material.reflectivity = isGlass ? 0.98 : 0.5; - material.transmission = 0; - material._transmission = isGlass ? 1 : 0; - material.refractionEnvMap = isGlass - ? materialAssets.glassBackgroundTexture - : null; - material.useEnvMapRefraction = isGlass ? 1 : 0; - material.thickness = isGlass ? glassThickness : 0; - material.ior = isGlass ? materialSettings.refraction : 1.5; - material.buffer = null; - material.bumpMap = null; - material.bumpScale = 0; - material.roughnessMap = null; - material.side = THREE.FrontSide; - material.transparent = false; - material.opacity = 1; - material.depthWrite = true; - material.attenuationColor.set(isGlass ? materialSettings.color : 'white'); - material.attenuationDistance = isGlass ? glassAttenuationDistance : Infinity; - material.anisotropicBlur = isGlass - ? THREE.MathUtils.lerp(0.03, 0.12, materialSettings.roughness) - : 0.1; - material.chromaticAberration = isGlass ? 0 : 0.05; - material.distortion = 0; - material.distortionScale = 0.5; - material.temporalDistortion = 0; - material.userData.halftoneIsGlass = isGlass; - material.userData.halftoneGlassBacksideThickness = isGlass - ? glassThickness * 2 - : 0; - material.userData.halftoneGlassBacksideEnvIntensity = isGlass - ? glassEnvironmentIntensity * 2.8 - : 0; - material.userData.halftoneUseEnvironmentRefraction = isGlass; - material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25; - - material.needsUpdate = true; -} - -function disposeHalftoneMaterialAssets(materialAssets) { - materialAssets.glassBackgroundTexture.dispose(); - - if ( - materialAssets.glassEnvironmentTexture !== - materialAssets.glassBackgroundTexture - ) { - materialAssets.glassEnvironmentTexture.dispose(); - } - - materialAssets.solidEnvironmentTexture.dispose(); -} - -function createRenderTarget(width, height) { - return new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); -} - -function createInteractionState() { - return { - autoElapsed: initialPose.autoElapsed, - activePointerId: null, - dragging: false, - mouseX: 0.5, - mouseY: 0.5, - pointerInside: false, - pointerVelocityX: 0, - pointerVelocityY: 0, - pointerX: 0, - pointerY: 0, - rotateElapsed: initialPose.rotateElapsed, - rotationX: initialPose.rotationX, - rotationVelocityX: 0, - rotationY: initialPose.rotationY, - rotationVelocityY: 0, - rotationZ: initialPose.rotationZ, - rotationVelocityZ: 0, - smoothedMouseX: 0.5, - smoothedMouseY: 0.5, - targetRotationX: initialPose.targetRotationX, - targetRotationY: initialPose.targetRotationY, - velocityX: 0, - velocityY: 0, - }; -} - -function setPrimaryLightPosition(light, angleDegrees, height) { - const lightAngle = (angleDegrees * Math.PI) / 180; - light.position.set( - Math.cos(lightAngle) * 5, - height, - Math.sin(lightAngle) * 5, - ); -} - -function applySpringStep(current, target, velocity, strength, damping) { - const nextVelocity = (velocity + (target - current) * strength) * damping; - const nextValue = current + nextVelocity; - - return { - value: nextValue, - velocity: nextVelocity, - }; -} - -function resetInteractionState(interactionState) { - interactionState.dragging = false; - interactionState.mouseX = 0.5; - interactionState.mouseY = 0.5; - interactionState.targetRotationX = 0; - interactionState.targetRotationY = 0; - interactionState.velocityX = 0; - interactionState.velocityY = 0; - interactionState.rotationVelocityX = 0; - interactionState.rotationVelocityY = 0; - interactionState.rotationVelocityZ = 0; - interactionState.autoElapsed = 0; -} - -async function createGeometry(modelUrl) { - if (shape.kind === 'imported') { - if (!modelUrl) { - throw new Error('No model URL was provided for ' + shape.label + '.'); - } - - return loadImportedGeometryFromUrl(modelUrl, shape.label); - } - - return createBuiltinGeometry(shape.key); -} - -async function mountHalftoneCanvas(options) { - const { container, modelUrl, onError } = options; - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))), - 1, - ); - - let geometry; - - try { - geometry = await createGeometry(modelUrl); - } catch (error) { - onError?.(error); - return () => {}; - } - - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor(0x000000, 0); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.cursor = settings.animation.followDragEnabled - ? 'grab' - : 'default'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const materialAssets = await createHalftoneMaterialAssets(renderer); - - const scene3d = new THREE.Scene(); - scene3d.background = null; - - const baseCameraDistance = previewDistance; - const camera = new THREE.PerspectiveCamera( - 45, - getWidth() / getHeight(), - 0.1, - 100, - ); - camera.position.z = baseCameraDistance; - - const primaryLight = new THREE.DirectionalLight( - 0xffffff, - settings.lighting.intensity, - ); - setPrimaryLightPosition( - primaryLight, - settings.lighting.angleDegrees, - settings.lighting.height, - ); - scene3d.add(primaryLight); - - const fillLight = new THREE.DirectionalLight( - 0xffffff, - settings.lighting.fillIntensity, - ); - fillLight.position.set(-3, -1, 1); - scene3d.add(fillLight); - - const ambientLight = new THREE.AmbientLight( - 0xffffff, - settings.lighting.ambientIntensity, - ); - scene3d.add(ambientLight); - - const material = createHalftoneMaterial(); - applyHalftoneMaterialSettings(material, settings.material, materialAssets); - - const mesh = new THREE.Mesh(geometry, material); - scene3d.add(mesh); - - const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - effectResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - logicalResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - tile: { value: settings.halftone.scale }, - s_3: { value: settings.halftone.power }, - s_4: { value: settings.halftone.width }, - dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, - hoverDashColor: { - value: new THREE.Color(settings.halftone.hoverDashColor), - }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: 1 }, - footprintScale: { value: 1.0 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - interactionVelocity: { value: new THREE.Vector2(0, 0) }, - dragOffset: { value: new THREE.Vector2(0, 0) }, - hoverHalftoneActive: { value: 0 }, - hoverHalftonePowerShift: { value: 0 }, - hoverHalftoneRadius: { value: 0.2 }, - hoverHalftoneWidthShift: { value: 0 }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: 0.2 }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - cropToBounds: { value: 0 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add( - new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), - ); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const updateViewportUniforms = ( - logicalWidth, - logicalHeight, - effectWidth, - effectHeight, - ) => { - blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - halftoneMaterial.uniforms.effectResolution.value.set( - effectWidth, - effectHeight, - ); - halftoneMaterial.uniforms.logicalResolution.value.set( - logicalWidth, - logicalHeight, - ); - }; - - const getHalftoneScale = (viewportWidth, viewportHeight, lookAtTarget) => { - if (!mesh.geometry.boundingBox) { - mesh.geometry.computeBoundingBox(); - } - - if (!mesh.geometry.boundingBox) { - return 1; - } - - mesh.updateMatrixWorld(); - camera.updateMatrixWorld(); - - return getMeshFootprintScale({ - camera, - localBounds: mesh.geometry.boundingBox, - lookAtTarget, - meshMatrixWorld: mesh.matrixWorld, - viewportHeight, - viewportWidth, - }); - }; - - const interaction = createInteractionState(); - const autoRotateEnabled = settings.animation.autoRotateEnabled; - const followHoverEnabled = settings.animation.followHoverEnabled; - const followDragEnabled = settings.animation.followDragEnabled; - const rotateEnabled = settings.animation.rotateEnabled; - - const syncSize = () => { - const width = getWidth(); - const height = getHeight(); - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - updateViewportUniforms( - virtualWidth, - virtualHeight, - virtualWidth, - virtualHeight, - ); - }; - - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); - - const updatePointerPosition = (event) => { - const rect = canvas.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - - interaction.mouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, - 0, - 1, - ); - interaction.mouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, - 0, - 1, - ); - }; - - const handlePointerDown = (event) => { - updatePointerPosition(event); - if (!followDragEnabled) { - return; - } - - interaction.dragging = true; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - interaction.velocityX = 0; - interaction.velocityY = 0; - canvas.style.cursor = 'grabbing'; - }; - - const handlePointerMove = (event) => { - updatePointerPosition(event); - }; - - const handleWindowPointerMove = (event) => { - updatePointerPosition(event); - - if (!interaction.dragging || !followDragEnabled) { - return; - } - - const deltaX = - (event.clientX - interaction.pointerX) * settings.animation.dragSens; - const deltaY = - (event.clientY - interaction.pointerY) * settings.animation.dragSens; - interaction.velocityX = deltaY; - interaction.velocityY = deltaX; - interaction.targetRotationY += deltaX; - interaction.targetRotationX += deltaY; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - }; - - const handlePointerLeave = () => { - if (interaction.dragging) { - return; - } - - interaction.mouseX = 0.5; - interaction.mouseY = 0.5; - }; - - const handlePointerUp = () => { - interaction.dragging = false; - canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; - - if (!settings.animation.springReturnEnabled) { - return; - } - - const springImpulse = Math.max(settings.animation.springStrength * 10, 1.2); - interaction.rotationVelocityX += interaction.velocityX * springImpulse; - interaction.rotationVelocityY += interaction.velocityY * springImpulse; - interaction.rotationVelocityZ += - interaction.velocityY * springImpulse * 0.12; - interaction.targetRotationX = 0; - interaction.targetRotationY = 0; - interaction.velocityX = 0; - interaction.velocityY = 0; - }; - - const handlePointerCancel = () => { - interaction.dragging = false; - interaction.velocityX = 0; - interaction.velocityY = 0; - canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; - handlePointerLeave(); - }; - - const handleWindowBlur = () => { - handlePointerUp(); - handlePointerLeave(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointercancel', handlePointerCancel); - window.addEventListener('pointerup', handlePointerUp); - window.addEventListener('pointermove', handleWindowPointerMove); - window.addEventListener('blur', handleWindowBlur); - canvas.addEventListener('pointerdown', handlePointerDown); - - const clock = new THREE.Timer(); - clock.connect(document); - let animationFrameId = 0; - - const renderFrame = (timestamp) => { - animationFrameId = window.requestAnimationFrame(renderFrame); - clock.update(timestamp); - - const delta = 1 / 60; - const elapsedTime = initialPose.timeElapsed + clock.getElapsed(); - halftoneMaterial.uniforms.time.value = elapsedTime; - - let baseRotationX = initialPose.rotationX; - let baseRotationY = initialPose.rotationY; - let baseRotationZ = initialPose.rotationZ; - let meshOffsetY = 0; - let meshScale = 1; - let lightAngle = settings.lighting.angleDegrees; - let lightHeight = settings.lighting.height; - - if (autoRotateEnabled) { - interaction.autoElapsed += delta; - baseRotationY += interaction.autoElapsed * settings.animation.autoSpeed; - baseRotationX += - Math.sin(interaction.autoElapsed * 0.2) * settings.animation.autoWobble; - } - - if (settings.animation.floatEnabled) { - const floatPhase = elapsedTime * settings.animation.floatSpeed; - const driftAmount = (settings.animation.driftAmount * Math.PI) / 180; - - meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude; - baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45; - baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3; - } - - if (settings.animation.breatheEnabled) { - meshScale *= - 1 + - Math.sin(elapsedTime * settings.animation.breatheSpeed) * - settings.animation.breatheAmount; - } - - if (rotateEnabled) { - interaction.rotateElapsed += delta; - const rotateProgress = settings.animation.rotatePingPong - ? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) * - Math.PI - : interaction.rotateElapsed * settings.animation.rotateSpeed; - - if (settings.animation.rotatePreset === 'axis') { - const axisDirection = settings.animation.rotateAxis.startsWith('-') - ? -1 - : 1; - const axisProgress = rotateProgress * axisDirection; - - if ( - settings.animation.rotateAxis === 'x' || - settings.animation.rotateAxis === 'xy' || - settings.animation.rotateAxis === '-x' || - settings.animation.rotateAxis === '-xy' - ) { - baseRotationX += axisProgress; - } - - if ( - settings.animation.rotateAxis === 'y' || - settings.animation.rotateAxis === 'xy' || - settings.animation.rotateAxis === '-y' || - settings.animation.rotateAxis === '-xy' - ) { - baseRotationY += axisProgress; - } - - if ( - settings.animation.rotateAxis === 'z' || - settings.animation.rotateAxis === '-z' - ) { - baseRotationZ += axisProgress; - } - } else if (settings.animation.rotatePreset === 'lissajous') { - baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65; - baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05; - baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32; - } else if (settings.animation.rotatePreset === 'orbit') { - baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42; - baseRotationY += Math.cos(rotateProgress) * 1.2; - baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24; - } else if (settings.animation.rotatePreset === 'tumble') { - baseRotationX += rotateProgress * 0.55; - baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9; - baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38; - } - } - - if (settings.animation.lightSweepEnabled) { - const lightPhase = elapsedTime * settings.animation.lightSweepSpeed; - lightAngle += Math.sin(lightPhase) * settings.animation.lightSweepRange; - lightHeight += - Math.cos(lightPhase * 0.85) * settings.animation.lightSweepHeightRange; - } - - let targetX = baseRotationX; - let targetY = baseRotationY; - let easing = 0.12; - - if (followHoverEnabled) { - const rangeRadians = (settings.animation.hoverRange * Math.PI) / 180; - - if ( - settings.animation.hoverReturn || - interaction.mouseX !== 0.5 || - interaction.mouseY !== 0.5 - ) { - targetX += (interaction.mouseY - 0.5) * rangeRadians; - targetY += (interaction.mouseX - 0.5) * rangeRadians; - } - - easing = settings.animation.hoverEase; - } - - if (followDragEnabled) { - if (!interaction.dragging && settings.animation.dragMomentum) { - interaction.targetRotationX += interaction.velocityX; - interaction.targetRotationY += interaction.velocityY; - interaction.velocityX *= 1 - settings.animation.dragFriction; - interaction.velocityY *= 1 - settings.animation.dragFriction; - } - - targetX += interaction.targetRotationX; - targetY += interaction.targetRotationY; - easing = settings.animation.dragFriction; - } - - if (autoRotateEnabled && !followHoverEnabled && !followDragEnabled) { - targetX = baseRotationX + interaction.targetRotationX; - targetY = baseRotationY + interaction.targetRotationY; - - if (interaction.dragging) { - targetX = interaction.targetRotationX; - targetY = interaction.targetRotationY; - } - - easing = 0.08; - } - - if (settings.animation.springReturnEnabled) { - const springX = applySpringStep( - interaction.rotationX, - targetX, - interaction.rotationVelocityX, - settings.animation.springStrength, - settings.animation.springDamping, - ); - const springY = applySpringStep( - interaction.rotationY, - targetY, - interaction.rotationVelocityY, - settings.animation.springStrength, - settings.animation.springDamping, - ); - const springZ = applySpringStep( - interaction.rotationZ, - baseRotationZ, - interaction.rotationVelocityZ, - settings.animation.springStrength, - settings.animation.springDamping, - ); - - interaction.rotationX = springX.value; - interaction.rotationY = springY.value; - interaction.rotationZ = springZ.value; - interaction.rotationVelocityX = springX.velocity; - interaction.rotationVelocityY = springY.velocity; - interaction.rotationVelocityZ = springZ.velocity; - } else { - interaction.rotationX += (targetX - interaction.rotationX) * easing; - interaction.rotationY += (targetY - interaction.rotationY) * easing; - interaction.rotationZ += - (baseRotationZ - interaction.rotationZ) * - (settings.animation.rotatePingPong ? 0.18 : 0.12); - } - - mesh.rotation.set( - interaction.rotationX, - interaction.rotationY, - interaction.rotationZ, - ); - mesh.position.y = meshOffsetY; - mesh.scale.setScalar(meshScale); - - if (settings.animation.cameraParallaxEnabled) { - const cameraRange = settings.animation.cameraParallaxAmount; - const cameraEase = settings.animation.cameraParallaxEase; - const centeredX = (interaction.mouseX - 0.5) * 2; - const centeredY = (0.5 - interaction.mouseY) * 2; - const orbitYaw = centeredX * cameraRange; - const orbitPitch = centeredY * cameraRange * 0.7; - const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance; - const targetCameraX = Math.sin(orbitYaw) * horizontalRadius; - const targetCameraY = Math.sin(orbitPitch) * baseCameraDistance * 0.85; - const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius; - - camera.position.x += (targetCameraX - camera.position.x) * cameraEase; - camera.position.y += (targetCameraY - camera.position.y) * cameraEase; - camera.position.z += (targetCameraZ - camera.position.z) * cameraEase; - } else { - camera.position.x += (0 - camera.position.x) * 0.12; - camera.position.y += (0 - camera.position.y) * 0.12; - camera.position.z += (baseCameraDistance - camera.position.z) * 0.12; - } - - const lookAtTarget = new THREE.Vector3(0, meshOffsetY * 0.2, 0); - - camera.lookAt(lookAtTarget); - setPrimaryLightPosition(primaryLight, lightAngle, lightHeight); - halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale( - getVirtualWidth(), - getVirtualHeight(), - lookAtTarget, - ); - - if (!settings.halftone.enabled) { - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(scene3d, camera); - return; - } - - renderer.setRenderTarget(sceneTarget); - renderer.render(scene3d, camera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderFrame(); - - return () => { - window.cancelAnimationFrame(animationFrameId); - clock.dispose(); - resizeObserver.disconnect(); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - window.removeEventListener('pointerup', handlePointerUp); - window.removeEventListener('pointermove', handleWindowPointerMove); - canvas.removeEventListener('pointercancel', handlePointerCancel); - window.removeEventListener('blur', handleWindowBlur); - canvas.removeEventListener('pointerdown', handlePointerDown); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - fullScreenGeometry.dispose(); - material.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - disposeHalftoneMaterialAssets(materialAssets); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; -} +import { type CSSProperties } from 'react'; + +import { + PRODUCT_EFFECT_INITIAL_POSE, + PRODUCT_EFFECT_MODEL_URL, + PRODUCT_EFFECT_PREVIEW_DISTANCE, + PRODUCT_EFFECT_SETTINGS, + PRODUCT_EFFECT_VIRTUAL_RENDER_HEIGHT, +} from './product-effect-config'; const StyledVisualMount = styled.div` background: transparent; @@ -2019,32 +26,21 @@ type ProductEffectProps = { }; export function ProductEffect({ - modelUrl = '/illustrations/product/hero/hero.glb', + modelUrl = PRODUCT_EFFECT_MODEL_URL, style, }: ProductEffectProps) { - const mountReference = useRef(null); - - useEffect(() => { - const container = mountReference.current; - - if (!container) { - return; - } - - const unmount = mountHalftoneCanvas({ - container, - modelUrl, - onError: (error) => { - console.error(error); - }, - }); - - return () => { - void Promise.resolve(unmount).then((dispose) => dispose?.()); - }; - }, [modelUrl]); - - return ; + return ( + + + + ); } export default ProductEffect; diff --git a/packages/twenty-website-new/src/sections/Hero/visuals/ReleaseNotes.tsx b/packages/twenty-website-new/src/sections/Hero/visuals/ReleaseNotes.tsx index a4d5da66a1..a320c2dc15 100644 --- a/packages/twenty-website-new/src/sections/Hero/visuals/ReleaseNotes.tsx +++ b/packages/twenty-website-new/src/sections/Hero/visuals/ReleaseNotes.tsx @@ -2,7 +2,7 @@ import { theme } from '@/theme'; import { styled } from '@linaria/react'; -import { Milestone } from './Milestone'; +import { Milestone, type MilestoneSettingsOverrides } from './Milestone'; const RELEASE_NOTES_SETTINGS = { animation: { @@ -23,7 +23,7 @@ const RELEASE_NOTES_SETTINGS = { toneTarget: 'light' as const, width: 0.46, }, -}; +} satisfies MilestoneSettingsOverrides; const VisualContainer = styled.div` height: 462px; diff --git a/packages/twenty-website-new/src/sections/Hero/visuals/WhyTwenty.tsx b/packages/twenty-website-new/src/sections/Hero/visuals/WhyTwenty.tsx index a3520f9a54..293215f9cc 100644 --- a/packages/twenty-website-new/src/sections/Hero/visuals/WhyTwenty.tsx +++ b/packages/twenty-website-new/src/sections/Hero/visuals/WhyTwenty.tsx @@ -6,8 +6,10 @@ import * as THREE from 'three'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { - createFrameTimer, - createSiteWebGlRenderer, + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, + type VisualRenderLoopFrame, } from '@/lib/visual-runtime'; import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; @@ -195,7 +197,7 @@ export function WhyTwenty() { if (!container) return; let cancelled = false; - let animationFrameId = 0; + let renderLoop: VisualRenderLoop | null = null; const scene = new THREE.Scene(); const width = container.clientWidth; @@ -204,7 +206,18 @@ export function WhyTwenty() { const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); camera.position.set(0, 0, 5.2); - const renderer = createSiteWebGlRenderer({ alpha: true, antialias: true }); + const renderer = tryCreateSiteWebGlRenderer({ + alpha: true, + antialias: true, + onContextLost: () => { + renderLoop?.stop(); + }, + }); + + if (renderer === null) { + return; + } + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(width, height); renderer.setClearColor(0x000000, 0); @@ -216,7 +229,6 @@ export function WhyTwenty() { canvas.style.width = '100%'; container.appendChild(canvas); - const frameTimer = createFrameTimer(); const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize(); const cameraWorldPosition = new THREE.Vector3(); @@ -257,34 +269,40 @@ export function WhyTwenty() { pivot.position.x = 0.25; - const renderFrame = () => { - if (cancelled) return; + renderLoop = createVisualRenderLoop({ + renderFrame: ( + _timestamp: DOMHighResTimeStamp, + { elapsedSeconds }: VisualRenderLoopFrame, + ) => { + if (cancelled) return; - animationFrameId = window.requestAnimationFrame(renderFrame); - const time = frameTimer.getElapsed(); + const time = elapsedSeconds; - pivot.position.y = Math.sin(time * 1.5) * 0.06; + pivot.position.y = Math.sin(time * 1.5) * 0.06; - pivot.rotation.x += (targetRotationX - pivot.rotation.x) * 0.05; - pivot.rotation.y += (targetRotationY - pivot.rotation.y) * 0.05; + pivot.rotation.x += (targetRotationX - pivot.rotation.x) * 0.05; + pivot.rotation.y += (targetRotationY - pivot.rotation.y) * 0.05; - camera.getWorldPosition(cameraWorldPosition); - modelRoot.traverse((sceneObject) => { - if ( - sceneObject instanceof THREE.Mesh && - sceneObject.material instanceof THREE.ShaderMaterial && - sceneObject.material.uniforms.uCameraPosition - ) { - sceneObject.material.uniforms.uCameraPosition.value.copy( - cameraWorldPosition, - ); - } - }); + camera.getWorldPosition(cameraWorldPosition); + modelRoot.traverse((sceneObject) => { + if ( + sceneObject instanceof THREE.Mesh && + sceneObject.material instanceof THREE.ShaderMaterial && + sceneObject.material.uniforms.uCameraPosition + ) { + sceneObject.material.uniforms.uCameraPosition.value.copy( + cameraWorldPosition, + ); + } + }); - renderer.render(scene, camera); - }; + renderer.render(scene, camera); + }, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); - renderFrame(); + renderLoop.start(); }); const handlePointerMove = (e: PointerEvent) => { @@ -319,7 +337,7 @@ export function WhyTwenty() { window.removeEventListener('resize', handleResize); canvas.removeEventListener('pointermove', handlePointerMove); canvas.removeEventListener('pointerleave', handlePointerLeave); - window.cancelAnimationFrame(animationFrameId); + renderLoop?.dispose(); disposeObjectSubtree(scene); renderer.dispose(); dracoLoader.dispose(); diff --git a/packages/twenty-website-new/src/sections/Hero/visuals/WhyTwentyHeroHalftone.tsx b/packages/twenty-website-new/src/sections/Hero/visuals/WhyTwentyHeroHalftone.tsx deleted file mode 100644 index 35928c7a87..0000000000 --- a/packages/twenty-website-new/src/sections/Hero/visuals/WhyTwentyHeroHalftone.tsx +++ /dev/null @@ -1,2062 +0,0 @@ -// @ts-nocheck -'use client'; - -import { useEffect, useRef, type CSSProperties } from 'react'; -import * as THREE from 'three'; -import { styled } from '@linaria/react'; -import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; -import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import { GLASS_ENVIRONMENT_TEXTURE_URL } from '@/lib/visual-runtime/textures/glass-environment'; -import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; - -const settings = { - sourceMode: 'shape', - shapeKey: 'userUpload_1776601895865', - lighting: { - intensity: 1.5, - fillIntensity: 0.15, - ambientIntensity: 0.08, - angleDegrees: 45, - height: 2, - }, - material: { - surface: 'solid', - color: '#d4d0c8', - roughness: 0.42, - metalness: 0.16, - thickness: 150, - refraction: 2, - environmentPower: 5, - }, - halftone: { - enabled: true, - scale: 24.72, - power: -0.07, - toneTarget: 'light', - width: 0.46, - imageContrast: 1, - dashColor: '#FFF', - hoverDashColor: '#4A38F5', - }, - background: { - transparent: true, - color: '#000000', - }, - animation: { - autoRotateEnabled: true, - breatheEnabled: false, - cameraParallaxEnabled: false, - followHoverEnabled: false, - followDragEnabled: true, - floatEnabled: false, - hoverHalftoneEnabled: false, - hoverLightEnabled: false, - dragFlowEnabled: false, - lightSweepEnabled: false, - rotateEnabled: false, - autoSpeed: 0.1, - autoWobble: 0.3, - breatheAmount: 0.04, - breatheSpeed: 0.8, - cameraParallaxAmount: 0.3, - cameraParallaxEase: 0.08, - driftAmount: 8, - hoverRange: 25, - hoverEase: 0.08, - hoverReturn: true, - dragSens: 0.008, - dragFriction: 0.08, - dragMomentum: true, - rotateAxis: 'y', - rotatePreset: 'axis', - rotateSpeed: 0.2, - rotatePingPong: false, - floatAmplitude: 0.16, - floatSpeed: 0.8, - lightSweepHeightRange: 0.5, - lightSweepRange: 28, - lightSweepSpeed: 0.7, - springDamping: 0.72, - springReturnEnabled: false, - springStrength: 0.18, - hoverHalftonePowerShift: 0.42, - hoverHalftoneRadius: 0.2, - hoverHalftoneWidthShift: -0.18, - hoverLightIntensity: 0.8, - hoverLightRadius: 0.2, - dragFlowDecay: 0.08, - dragFlowRadius: 0.24, - dragFlowStrength: 1.8, - hoverWarpStrength: 3, - hoverWarpRadius: 0.15, - dragWarpStrength: 5, - waveEnabled: false, - waveSpeed: 1, - waveAmount: 2, - }, -}; -const shape = { - filename: 'hero.glb', - key: 'userUpload_1776601895865', - kind: 'imported', - label: 'hero.glb', - loader: 'glb', -}; -const initialPose = { - autoElapsed: 11.408300000000029, - rotateElapsed: 0, - rotationX: -0.30530352681215056, - rotationY: 4.763043016616201, - rotationZ: 0, - targetRotationX: -0.5363124999999984, - targetRotationY: 3.6318124999999988, - timeElapsed: 45.72360000009537, -}; -const previewDistance = 6; -const VIRTUAL_RENDER_HEIGHT = 768; -const passThroughVertexShader = - '\n varying vec2 vUv;\n\n void main() {\n vUv = uv;\n gl_Position = vec4(position, 1.0);\n }\n'; -const blurFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tInput;\n uniform vec2 dir;\n uniform vec2 res;\n\n varying vec2 vUv;\n\n void main() {\n vec4 sum = vec4(0.0);\n vec2 px = dir / res;\n\n float w[5];\n w[0] = 0.227027;\n w[1] = 0.1945946;\n w[2] = 0.1216216;\n w[3] = 0.054054;\n w[4] = 0.016216;\n\n sum += texture2D(tInput, vUv) * w[0];\n\n for (int i = 1; i < 5; i++) {\n float fi = float(i) * 3.0;\n sum += texture2D(tInput, vUv + px * fi) * w[i];\n sum += texture2D(tInput, vUv - px * fi) * w[i];\n }\n\n gl_FragColor = sum;\n }\n'; -const halftoneFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tScene;\n uniform sampler2D tGlow;\n uniform vec2 effectResolution;\n uniform vec2 logicalResolution;\n uniform float tile;\n uniform float s_3;\n uniform float s_4;\n uniform float applyToDarkAreas;\n uniform vec3 dashColor;\n uniform vec3 hoverDashColor;\n uniform float time;\n uniform float waveAmount;\n uniform float waveSpeed;\n uniform float footprintScale;\n uniform vec2 interactionUv;\n uniform vec2 interactionVelocity;\n uniform vec2 dragOffset;\n uniform float hoverHalftoneActive;\n uniform float hoverHalftonePowerShift;\n uniform float hoverHalftoneRadius;\n uniform float hoverHalftoneWidthShift;\n uniform float hoverLightStrength;\n uniform float hoverLightRadius;\n uniform float hoverFlowStrength;\n uniform float hoverFlowRadius;\n uniform float dragFlowStrength;\n uniform float cropToBounds;\n\n varying vec2 vUv;\n\n float distSegment(in vec2 p, in vec2 a, in vec2 b) {\n vec2 pa = p - a;\n vec2 ba = b - a;\n float denom = max(dot(ba, ba), 0.000001);\n float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);\n return length(pa - ba * h);\n }\n\n float lineSimpleEt(in vec2 p, in float r, in float thickness) {\n vec2 a = vec2(0.5) + vec2(-r, 0.0);\n vec2 b = vec2(0.5) + vec2(r, 0.0);\n float distToSegment = distSegment(p, a, b);\n float halfThickness = thickness * r;\n return distToSegment - halfThickness;\n }\n\n void main() {\n if (cropToBounds > 0.5) {\n vec4 boundsCheck = texture2D(tScene, vUv);\n if (boundsCheck.a < 0.01) {\n gl_FragColor = vec4(0.0);\n return;\n }\n }\n\n vec2 fragCoord =\n (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;\n float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);\n vec2 pointerPx = interactionUv * logicalResolution;\n vec2 fragDelta = fragCoord - pointerPx;\n float fragDist = length(fragDelta);\n vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);\n float velocityMagnitude = length(interactionVelocity);\n vec2 motionDir = velocityMagnitude > 0.001\n ? interactionVelocity / velocityMagnitude\n : vec2(0.0, 0.0);\n float motionBias = velocityMagnitude > 0.001\n ? dot(-radialDir, motionDir) * 0.5 + 0.5\n : 0.5;\n\n float hoverLightMask = 0.0;\n if (hoverLightStrength > 0.0) {\n float lightRadiusPx = hoverLightRadius * logicalResolution.y;\n hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);\n }\n\n float hoverHalftoneMask = 0.0;\n if (hoverHalftoneActive > 0.0) {\n float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;\n hoverHalftoneMask =\n smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *\n clamp(hoverHalftoneActive, 0.0, 1.0);\n }\n\n float hoverFlowMask = 0.0;\n if (hoverFlowStrength > 0.0) {\n float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;\n hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);\n }\n\n vec2 hoverDisplacement =\n radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +\n motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;\n vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;\n vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;\n\n float bandRow = floor(effectCoord.y / halftoneSize);\n float waveOffset =\n waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;\n effectCoord.x += waveOffset;\n\n vec2 cellIndex = floor(effectCoord / halftoneSize);\n vec2 sampleUv = clamp(\n (cellIndex + 0.5) * halftoneSize / logicalResolution,\n vec2(0.0),\n vec2(1.0)\n );\n vec2 cellUv = fract(effectCoord / halftoneSize);\n\n vec4 sceneSample = texture2D(tScene, sampleUv);\n float mask = smoothstep(0.02, 0.08, sceneSample.a);\n float localPower = clamp(\n s_3 + hoverHalftonePowerShift * hoverHalftoneMask,\n -1.5,\n 1.5\n );\n float localWidth = clamp(\n s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,\n 0.05,\n 1.4\n );\n float lightLift =\n hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;\n float toneValue =\n (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);\n if (applyToDarkAreas > 0.5) {\n toneValue = 1.0 - toneValue;\n }\n // Preserve the pre-toneTarget light-mode response by keeping the power\n // bias inside the averaged tone calculation.\n float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0);\n float bandRadius = clamp(\n toneValue + powerBias + lightLift,\n 0.0,\n 1.0\n ) * 1.86 * 0.5;\n\n float alpha = 0.0;\n if (bandRadius > 0.0001) {\n float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);\n float edge = 0.02;\n alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;\n }\n\n vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);\n vec3 color = activeDashColor * alpha;\n gl_FragColor = vec4(color, alpha);\n\n #include \n #include \n }\n'; - -const REFERENCE_PREVIEW_DISTANCE = 4; -const MIN_FOOTPRINT_SCALE = 0.001; - -function clampRectToViewport(rect, viewportWidth, viewportHeight) { - const minX = Math.max(rect.x, 0); - const minY = Math.max(rect.y, 0); - const maxX = Math.min(rect.x + rect.width, viewportWidth); - const maxY = Math.min(rect.y + rect.height, viewportHeight); - - if (maxX <= minX || maxY <= minY) { - return null; - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; -} - -function getRectArea(rect) { - if (!rect) { - return 0; - } - - return Math.max(rect.width, 0) * Math.max(rect.height, 0); -} - -function createBox3Corners(bounds) { - const { min, max } = bounds; - - return [ - new THREE.Vector3(min.x, min.y, min.z), - new THREE.Vector3(min.x, min.y, max.z), - new THREE.Vector3(min.x, max.y, min.z), - new THREE.Vector3(min.x, max.y, max.z), - new THREE.Vector3(max.x, min.y, min.z), - new THREE.Vector3(max.x, min.y, max.z), - new THREE.Vector3(max.x, max.y, min.z), - new THREE.Vector3(max.x, max.y, max.z), - ]; -} - -function getImagePreviewZoom(previewDistance) { - return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); -} - -function getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom, -}) { - if ( - imageWidth <= 0 || - imageHeight <= 0 || - viewportWidth <= 0 || - viewportHeight <= 0 - ) { - return null; - } - - const imageAspect = imageWidth / imageHeight; - const viewAspect = viewportWidth / viewportHeight; - - let fittedWidth = viewportWidth; - let fittedHeight = viewportHeight; - - if (imageAspect > viewAspect) { - fittedHeight = viewportWidth / imageAspect; - } else { - fittedWidth = viewportHeight * imageAspect; - } - - const scaledWidth = fittedWidth * zoom; - const scaledHeight = fittedHeight * zoom; - - return clampRectToViewport( - { - x: (viewportWidth - scaledWidth) * 0.5, - y: (viewportHeight - scaledHeight) * 0.5, - width: scaledWidth, - height: scaledHeight, - }, - viewportWidth, - viewportHeight, - ); -} - -function getFootprintScaleFromRects(currentRect, referenceRect) { - const currentArea = getRectArea(currentRect); - const referenceArea = getRectArea(referenceRect); - - if (currentArea <= 0 || referenceArea <= 0) { - return 1; - } - - return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE); -} - -function getImageFootprintScale({ - imageHeight, - imageWidth, - previewDistance, - viewportHeight, - viewportWidth, -}) { - const currentRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: getImagePreviewZoom(previewDistance), - }); - const referenceRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: 1, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function projectBox3ToViewport({ - camera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, -}) { - if (localBounds.isEmpty() || viewportWidth <= 0 || viewportHeight <= 0) { - return null; - } - - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - let hasProjectedCorner = false; - - for (const corner of createBox3Corners(localBounds)) { - corner.applyMatrix4(meshMatrixWorld).project(camera); - - if ( - !Number.isFinite(corner.x) || - !Number.isFinite(corner.y) || - !Number.isFinite(corner.z) - ) { - continue; - } - - hasProjectedCorner = true; - - const x = (corner.x * 0.5 + 0.5) * viewportWidth; - const y = (1 - (corner.y * 0.5 + 0.5)) * viewportHeight; - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - - if (!hasProjectedCorner) { - return null; - } - - return clampRectToViewport( - { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }, - viewportWidth, - viewportHeight, - ); -} - -function getMeshFootprintScale({ - camera, - localBounds, - lookAtTarget, - meshMatrixWorld, - viewportHeight, - viewportWidth, -}) { - const currentRect = projectBox3ToViewport({ - camera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, - }); - const referenceCamera = camera.clone(); - const currentOffset = referenceCamera.position.clone().sub(lookAtTarget); - const referenceOffset = - currentOffset.lengthSq() > 0 - ? currentOffset.setLength(REFERENCE_PREVIEW_DISTANCE) - : new THREE.Vector3(0, 0, REFERENCE_PREVIEW_DISTANCE); - - referenceCamera.position.copy(lookAtTarget).add(referenceOffset); - referenceCamera.lookAt(lookAtTarget); - referenceCamera.updateProjectionMatrix(); - referenceCamera.updateMatrixWorld(true); - - const referenceRect = projectBox3ToViewport({ - camera: referenceCamera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function makePolarShape(radiusFunction, segments = 320) { - const shape = new THREE.Shape(); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * Math.PI * 2; - const radius = radiusFunction(angle); - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius; - - if (segmentIndex === 0) { - shape.moveTo(x, y); - } else { - shape.lineTo(x, y); - } - } - - return shape; -} - -function makeReliefGeometry(shape, options = {}) { - const { - bevelSegments = 8, - bevelSize = 0.08, - bevelThickness = 0.1, - depth = 0.58, - waveDepth = 0.016, - waves = 8, - } = options; - - const geometry = new THREE.ExtrudeGeometry(shape, { - depth, - steps: 2, - bevelEnabled: true, - bevelThickness, - bevelSize, - bevelSegments, - curveSegments: 96, - }); - - geometry.center(); - - const position = geometry.attributes.position; - let maxRadius = 0; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - maxRadius = Math.max( - maxRadius, - Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), - ); - } - - const fullDepth = depth + bevelThickness * 2; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - const x = position.getX(vertexIndex); - const y = position.getY(vertexIndex); - const z = position.getZ(vertexIndex); - const radius = Math.hypot(x, y) / maxRadius; - const angle = Math.atan2(y, x); - const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); - const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); - const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); - const wave = - Math.cos(angle * waves) * - Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * - waveDepth; - const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); - - position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); - } - - position.needsUpdate = true; - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function mergeGeometries(geometries) { - if (geometries.length === 1) { - return geometries[0]; - } - - let totalVertices = 0; - let totalIndices = 0; - let hasUv = false; - - const geometryInfos = geometries.map((geometry) => { - const position = geometry.attributes.position; - const normal = geometry.attributes.normal; - const uv = geometry.attributes.uv ?? null; - const index = geometry.index; - const indexCount = index ? index.count : position.count; - - totalVertices += position.count; - totalIndices += indexCount; - hasUv = hasUv || uv !== null; - - return { - index, - indexCount, - normal, - position, - uv, - vertexCount: position.count, - }; - }); - - const positions = new Float32Array(totalVertices * 3); - const normals = new Float32Array(totalVertices * 3); - const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; - const indices = new Uint32Array(totalIndices); - - let vertexOffset = 0; - let indexOffset = 0; - - for (const geometryInfo of geometryInfos) { - for ( - let vertexIndex = 0; - vertexIndex < geometryInfo.vertexCount; - vertexIndex += 1 - ) { - const positionOffset = (vertexOffset + vertexIndex) * 3; - positions[positionOffset] = geometryInfo.position.getX(vertexIndex); - positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); - positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); - normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); - normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); - normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); - - if (uvs !== null) { - const uvOffset = (vertexOffset + vertexIndex) * 2; - uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; - uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; - } - } - - if (geometryInfo.index) { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = - geometryInfo.index.getX(localIndex) + vertexOffset; - } - } else { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = localIndex + vertexOffset; - } - } - - vertexOffset += geometryInfo.vertexCount; - indexOffset += geometryInfo.indexCount; - } - - const merged = new THREE.BufferGeometry(); - merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); - - if (uvs !== null) { - merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); - } - - merged.setIndex(new THREE.BufferAttribute(indices, 1)); - - return merged; -} - -function makeArrowTarget() { - const targetParts = []; - const arrowParts = []; - const baseRadius = 1.35; - const baseDepth = 0.32; - const bevel = 0.12; - const points = []; - const segments = 16; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - -baseDepth / 2 + bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - baseDepth / 2 - bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - targetParts.push(disc); - - for (const radius of [0.45, 0.85, 1.22]) { - const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); - ring.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), - ); - targetParts.push(ring); - } - - const bump = new THREE.SphereGeometry( - 0.32, - 32, - 24, - 0, - Math.PI * 2, - 0, - Math.PI / 2, - ); - bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); - bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); - targetParts.push(bump); - - const shaftLength = 1.5; - const shaftRadius = 0.05; - const shaft = new THREE.CylinderGeometry( - shaftRadius, - shaftRadius, - shaftLength, - 10, - 1, - ); - shaft.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0), - ); - arrowParts.push(shaft); - - const head = new THREE.ConeGeometry(0.12, 0.35, 10); - head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); - arrowParts.push(head); - - for (let finIndex = 0; finIndex < 3; finIndex += 1) { - const finShape = new THREE.Shape(); - finShape.moveTo(0, 0); - finShape.lineTo(0.22, 0.25); - finShape.lineTo(0, 0.5); - finShape.lineTo(0, 0); - - const finGeometry = new THREE.ExtrudeGeometry(finShape, { - depth: 0.012, - bevelEnabled: false, - }); - - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, 0, -0.006), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), - ); - arrowParts.push(finGeometry); - } - - const nock = new THREE.SphereGeometry(0.065, 8, 8); - nock.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0), - ); - arrowParts.push(nock); - - const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); - const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); - const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); - - for (const geometry of arrowParts) { - geometry.applyMatrix4(aim); - geometry.applyMatrix4(tilt); - geometry.applyMatrix4(shift); - } - - const merged = mergeGeometries([...targetParts, ...arrowParts]); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function makeDollarCoin() { - const parts = []; - const baseRadius = 1.3; - const baseDepth = 0.45; - const bevel = 0.18; - const points = []; - const segments = 20; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - Math.sin(angle) * (baseDepth / 2), - ), - ); - } - - points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - parts.push(disc); - - const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - frontRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), - ); - parts.push(frontRim); - - const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - backRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), - ); - parts.push(backRim); - - const createDollarSign = () => { - const geometries = []; - const tubeRadius = 0.1; - const curveRadius = 0.28; - const verticalOffset = 0.22; - - const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); - geometries.push(bar); - - const topArc = new THREE.TorusGeometry( - curveRadius, - tubeRadius, - 16, - 32, - Math.PI, - ); - topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), - ); - geometries.push(topArc); - - const bottomArc = new THREE.TorusGeometry( - curveRadius, - tubeRadius, - 16, - 32, - Math.PI, - ); - bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); - bottomArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), - ); - geometries.push(bottomArc); - - const topSerif = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - 0.22, - 12, - ); - topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation( - 0.16, - verticalOffset + curveRadius, - 0, - ), - ); - geometries.push(topSerif); - - const bottomSerif = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - 0.22, - 12, - ); - bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - bottomSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation( - -0.16, - -verticalOffset - curveRadius, - 0, - ), - ); - geometries.push(bottomSerif); - - const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); - const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); - const diagonal = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - diagonalLength + 0.12, - 12, - ); - diagonal.applyMatrix4( - new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), - ); - geometries.push(diagonal); - - return geometries; - }; - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), - ); - parts.push(geometry); - } - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), - ); - parts.push(geometry); - } - - const merged = mergeGeometries(parts); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function createBuiltinGeometry(shapeKey) { - switch (shapeKey) { - case 'torusKnot': - return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); - case 'sphere': - return new THREE.SphereGeometry(1.4, 64, 64); - case 'torus': - return new THREE.TorusGeometry(1, 0.45, 64, 100); - case 'icosahedron': - return new THREE.IcosahedronGeometry(1.4, 4); - case 'box': - return new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6); - case 'cone': - return new THREE.ConeGeometry(1.2, 2.4, 64, 10); - case 'cylinder': - return new THREE.CylinderGeometry(1, 1, 2.3, 64, 10); - case 'octahedron': - return new THREE.OctahedronGeometry(1.5, 2); - case 'dodecahedron': - return new THREE.DodecahedronGeometry(1.35, 1); - case 'tetrahedron': - return new THREE.TetrahedronGeometry(1.7, 1); - case 'sunCoin': - return makeReliefGeometry( - makePolarShape( - (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), - ), - { depth: 0.62, waves: 12, waveDepth: 0.018 }, - ); - case 'lotusCoin': - return makeReliefGeometry( - makePolarShape( - (angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2), - ), - { depth: 0.64, waves: 8, waveDepth: 0.014 }, - ); - case 'arrowTarget': - return makeArrowTarget(); - case 'dollarCoin': - return makeDollarCoin(); - default: - return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); - } -} - -const EMPTY_TEXTURE_DATA_URL = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; - -function createLoadingManager() { - const loadingManager = new THREE.LoadingManager(); - loadingManager.setURLModifier((url) => - /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, - ); - return loadingManager; -} - -function normalizeImportedGeometry(geometry) { - geometry.computeBoundingBox(); - - let boundingBox = geometry.boundingBox; - let center = new THREE.Vector3(); - let size = new THREE.Vector3(); - - boundingBox?.getCenter(center); - boundingBox?.getSize(size); - geometry.translate(-center.x, -center.y, -center.z); - - const dimensions = [size.x, size.y, size.z]; - const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); - - if (thinnestAxis === 0) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); - } else if (thinnestAxis === 1) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - } - - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - const radius = geometry.boundingSphere?.radius || 1; - const scale = 1.6 / radius; - geometry.scale(scale, scale, scale); - - geometry.computeBoundingBox(); - boundingBox = geometry.boundingBox; - center = new THREE.Vector3(); - boundingBox?.getCenter(center); - geometry.translate(-center.x, -center.y, -center.z); - - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function extractMergedGeometry(root, emptyMessage) { - root.updateMatrixWorld(true); - const geometries = []; - - root.traverse((object) => { - if (!(object instanceof THREE.Mesh) || !object.geometry) { - return; - } - - const geometry = object.geometry.clone(); - - if (!geometry.attributes.normal) { - geometry.computeVertexNormals(); - } - - geometry.applyMatrix4(object.matrixWorld); - geometries.push(geometry); - }); - - if (geometries.length === 0) { - throw new Error(emptyMessage); - } - - return normalizeImportedGeometry(mergeGeometries(geometries)); -} - -function parseGlbGeometry(buffer, label) { - return new Promise((resolve, reject) => { - const loadingManager = createLoadingManager(); - const dracoLoader = new DRACOLoader(loadingManager); - dracoLoader.setDecoderPath(DRACO_DECODER_PATH); - - const loader = new GLTFLoader(loadingManager); - loader.setDRACOLoader(dracoLoader); - - const cleanup = () => { - dracoLoader.dispose(); - }; - - loader.parse( - buffer, - '', - (gltf) => { - try { - resolve( - extractMergedGeometry( - gltf.scene, - label + ' did not contain any mesh geometry.', - ), - ); - } catch (error) { - reject(error); - } finally { - cleanup(); - } - }, - (error) => { - cleanup(); - reject(error); - }, - ); - }); -} - -async function loadImportedGeometryFromUrl(modelUrl, label) { - const response = await fetch(modelUrl); - - if (!response.ok) { - throw new Error('Unable to load ' + label + ' from ' + modelUrl + '.'); - } - - const buffer = await response.arrayBuffer(); - - return parseGlbGeometry(buffer, label); -} - -const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320; -const GLASS_ATTENUATION_DISTANCE_MIN = 0.12; -const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18; -const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12; -const GLASS_ENVIRONMENT_ZOOM = 1.55; -const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303); -const MAX_TEXTURE_ANISOTROPY = 8; -const HALFTONE_TRANSMISSION_SHADER_PREFIX = - '\nuniform float chromaticAberration;\nuniform float anisotropicBlur;\nuniform float time;\nuniform float distortion;\nuniform float distortionScale;\nuniform float temporalDistortion;\nuniform sampler2D buffer;\n\nvec3 random3(vec3 c) {\n float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0)));\n vec3 r;\n r.z = fract(512.0 * j);\n j *= 0.125;\n r.x = fract(512.0 * j);\n j *= 0.125;\n r.y = fract(512.0 * j);\n return r - 0.5;\n}\n\nuint hash(uint x) {\n x += (x << 10u);\n x ^= (x >> 6u);\n x += (x << 3u);\n x ^= (x >> 11u);\n x += (x << 15u);\n return x;\n}\n\nuint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); }\nuint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); }\nuint hash(uvec4 v) {\n return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w));\n}\n\nfloat floatConstruct(uint m) {\n const uint ieeeMantissa = 0x007FFFFFu;\n const uint ieeeOne = 0x3F800000u;\n m &= ieeeMantissa;\n m |= ieeeOne;\n float f = uintBitsToFloat(m);\n return f - 1.0;\n}\n\nfloat randomBase(float x) {\n return floatConstruct(hash(floatBitsToUint(x)));\n}\nfloat randomBase(vec2 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\nfloat randomBase(vec3 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\nfloat randomBase(vec4 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\n\nfloat rand(float seed) {\n return randomBase(vec3(gl_FragCoord.xy, seed));\n}\n\nconst float F3 = 0.3333333;\nconst float G3 = 0.1666667;\n\nfloat snoise(vec3 p) {\n vec3 s = floor(p + dot(p, vec3(F3)));\n vec3 x = p - s + dot(s, vec3(G3));\n vec3 e = step(vec3(0.0), x - x.yzx);\n vec3 i1 = e * (1.0 - e.zxy);\n vec3 i2 = 1.0 - e.zxy * (1.0 - e);\n vec3 x1 = x - i1 + G3;\n vec3 x2 = x - i2 + 2.0 * G3;\n vec3 x3 = x - 1.0 + 3.0 * G3;\n vec4 w;\n vec4 d;\n w.x = dot(x, x);\n w.y = dot(x1, x1);\n w.z = dot(x2, x2);\n w.w = dot(x3, x3);\n w = max(0.6 - w, 0.0);\n d.x = dot(random3(s), x);\n d.y = dot(random3(s + i1), x1);\n d.z = dot(random3(s + i2), x2);\n d.w = dot(random3(s + 1.0), x3);\n w *= w;\n w *= w;\n d *= w;\n return dot(d, vec4(52.0));\n}\n\nfloat snoiseFractal(vec3 m) {\n return 0.5333333 * snoise(m)\n + 0.2666667 * snoise(2.0 * m)\n + 0.1333333 * snoise(4.0 * m)\n + 0.0666667 * snoise(8.0 * m);\n}\n'; -const HALFTONE_TRANSMISSION_PARS_FRAGMENT = - '\n#ifdef USE_TRANSMISSION\n uniform float _transmission;\n uniform float thickness;\n uniform float attenuationDistance;\n uniform vec3 attenuationColor;\n uniform sampler2D refractionEnvMap;\n uniform float useEnvMapRefraction;\n #ifdef USE_TRANSMISSIONMAP\n uniform sampler2D transmissionMap;\n #endif\n #ifdef USE_THICKNESSMAP\n uniform sampler2D thicknessMap;\n #endif\n uniform vec2 transmissionSamplerSize;\n uniform sampler2D transmissionSamplerMap;\n uniform mat4 modelMatrix;\n uniform mat4 projectionMatrix;\n varying vec3 vWorldPosition;\n\n vec3 getVolumeTransmissionRay(\n const in vec3 n,\n const in vec3 v,\n const in float thicknessValue,\n const in float ior,\n const in mat4 modelMatrix\n ) {\n vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior);\n vec3 modelScale;\n modelScale.x = length(vec3(modelMatrix[0].xyz));\n modelScale.y = length(vec3(modelMatrix[1].xyz));\n modelScale.z = length(vec3(modelMatrix[2].xyz));\n return normalize(refractionVector) * thicknessValue * modelScale;\n }\n\n float applyIorToRoughness(\n const in float roughnessValue,\n const in float ior\n ) {\n return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0);\n }\n\n vec2 directionToEquirectUv(const in vec3 direction) {\n vec3 dir = normalize(direction);\n vec2 uv = vec2(\n atan(dir.z, dir.x) * 0.15915494309189535 + 0.5,\n asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5\n );\n\n return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0));\n }\n\n vec4 getTransmissionSample(\n const in vec2 fragCoord,\n const in vec3 transmissionDirection,\n const in float roughnessValue,\n const in float ior\n ) {\n if (useEnvMapRefraction > 0.5) {\n return texture2D(\n refractionEnvMap,\n directionToEquirectUv(transmissionDirection)\n );\n }\n\n float framebufferLod =\n log2(transmissionSamplerSize.x) *\n applyIorToRoughness(roughnessValue, ior);\n return texture2D(buffer, fragCoord.xy);\n }\n\n vec3 applyVolumeAttenuation(\n const in vec3 radiance,\n const in float transmissionDistance,\n const in vec3 attenuationColorValue,\n const in float attenuationDistanceValue\n ) {\n if (isinf(attenuationDistanceValue)) {\n return radiance;\n }\n\n vec3 attenuationCoefficient =\n -log(attenuationColorValue) / attenuationDistanceValue;\n vec3 transmittance =\n exp(-attenuationCoefficient * transmissionDistance);\n\n return transmittance * radiance;\n }\n\n vec4 getIBLVolumeRefraction(\n const in vec3 n,\n const in vec3 v,\n const in float roughnessValue,\n const in vec3 diffuseColor,\n const in vec3 specularColor,\n const in float specularF90,\n const in vec3 position,\n const in mat4 modelMatrix,\n const in mat4 viewMatrix,\n const in mat4 projMatrix,\n const in float ior,\n const in float thicknessValue,\n const in vec3 attenuationColorValue,\n const in float attenuationDistanceValue\n ) {\n vec3 transmissionRay = getVolumeTransmissionRay(\n n,\n v,\n thicknessValue,\n ior,\n modelMatrix\n );\n vec3 refractedRayExit = position + transmissionRay;\n vec4 ndcPos =\n projMatrix * viewMatrix * vec4(refractedRayExit, 1.0);\n vec2 refractionCoords = ndcPos.xy / ndcPos.w;\n refractionCoords += 1.0;\n refractionCoords /= 2.0;\n vec3 transmissionDirection = normalize(transmissionRay);\n vec4 transmittedLight = getTransmissionSample(\n refractionCoords,\n transmissionDirection,\n roughnessValue,\n ior\n );\n vec3 attenuatedColor = applyVolumeAttenuation(\n transmittedLight.rgb,\n length(transmissionRay),\n attenuationColorValue,\n attenuationDistanceValue\n );\n vec3 F = EnvironmentBRDF(\n n,\n v,\n specularColor,\n specularF90,\n roughnessValue\n );\n return vec4(\n (1.0 - F) * attenuatedColor * diffuseColor,\n transmittedLight.a\n );\n }\n#endif\n'; -const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = - '\nmaterial.transmission = _transmission;\nmaterial.transmissionAlpha = 1.0;\nmaterial.thickness = thickness;\nmaterial.attenuationDistance = attenuationDistance;\nmaterial.attenuationColor = attenuationColor;\n#ifdef USE_TRANSMISSIONMAP\n material.transmission *= texture2D(transmissionMap, vUv).r;\n#endif\n#ifdef USE_THICKNESSMAP\n material.thickness *= texture2D(thicknessMap, vUv).g;\n#endif\n\nvec3 pos = vWorldPosition;\nfloat runningSeed = 0.0;\nvec3 v = normalize(cameraPosition - pos);\nvec3 n = inverseTransformDirection(normal, viewMatrix);\nvec3 transmission = vec3(0.0);\nfloat transmissionR;\nfloat transmissionG;\nfloat transmissionB;\nfloat randomCoords = rand(runningSeed++);\nfloat thicknessSmear =\n thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur);\nvec3 distortionNormal = vec3(0.0);\nvec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion;\n\nif (distortion > 0.0) {\n distortionNormal = distortion * vec3(\n snoiseFractal(vec3(pos * distortionScale + temporalOffset)),\n snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)),\n snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset))\n );\n}\n\nfor (float i = 0.0; i < __SAMPLES__.0; i++) {\n vec3 sampleNorm = normalize(\n n +\n roughnessFactor * roughnessFactor * 2.0 *\n normalize(\n vec3(\n rand(runningSeed++) - 0.5,\n rand(runningSeed++) - 0.5,\n rand(runningSeed++) - 0.5\n )\n ) *\n pow(rand(runningSeed++), 0.33) +\n distortionNormal\n );\n\n transmissionR = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior,\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).r;\n\n transmissionG = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).g;\n\n transmissionB = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).b;\n\n transmission.r += transmissionR;\n transmission.g += transmissionG;\n transmission.b += transmissionB;\n}\n\ntransmission /= __SAMPLES__.0;\ntotalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission);\n'; - -class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial { - constructor(samples = 10) { - super(); - - this.halftoneUniforms = { - chromaticAberration: { value: 0.05 }, - transmission: { value: 0 }, - _transmission: { value: 1 }, - transmissionMap: { value: null }, - refractionEnvMap: { value: null }, - useEnvMapRefraction: { value: 0 }, - roughness: { value: 0 }, - thickness: { value: 0 }, - thicknessMap: { value: null }, - attenuationDistance: { value: Infinity }, - attenuationColor: { value: new THREE.Color('white') }, - anisotropicBlur: { value: 0.1 }, - time: { value: 0 }, - distortion: { value: 0 }, - distortionScale: { value: 0.5 }, - temporalDistortion: { value: 0 }, - buffer: { value: null }, - }; - - this.customProgramCacheKey = () => 'halftone-transmission-' + samples; - - this.onBeforeCompile = (shader) => { - shader.uniforms = { - ...shader.uniforms, - ...this.halftoneUniforms, - }; - shader.defines ??= {}; - - if (this.anisotropy > 0) { - shader.defines.USE_ANISOTROPY = ''; - } - - shader.defines.USE_TRANSMISSION = ''; - shader.fragmentShader = - HALFTONE_TRANSMISSION_SHADER_PREFIX + shader.fragmentShader; - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - HALFTONE_TRANSMISSION_PARS_FRAGMENT, - ); - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE.replaceAll( - '__SAMPLES__', - String(samples), - ), - ); - }; - - Object.keys(this.halftoneUniforms).forEach((key) => { - Object.defineProperty(this, key, { - configurable: true, - enumerable: true, - get: () => this.halftoneUniforms[key]?.value, - set: (value) => { - this.halftoneUniforms[key].value = value; - }, - }); - }); - } -} - -function setTextureSampling(texture, renderer) { - texture.generateMipmaps = true; - texture.magFilter = THREE.LinearFilter; - texture.minFilter = THREE.LinearMipmapLinearFilter; - texture.anisotropy = Math.min( - renderer.capabilities.getMaxAnisotropy(), - MAX_TEXTURE_ANISOTROPY, - ); -} - -function disposeEnvironmentScene(scene) { - scene.traverse((object) => { - if (object.geometry) { - object.geometry.dispose(); - } - - if (Array.isArray(object.material)) { - object.material.forEach((material) => material.dispose()); - return; - } - - object.material?.dispose?.(); - }); -} - -function createSolidEnvironmentTexture(renderer) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = pmremGenerator.fromScene( - new RoomEnvironment(), - 0.04, - ).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function getTextureImageSize(texture) { - const image = texture.image; - - return { - height: - image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined, - width: - image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined, - }; -} - -function createZoomedGlassTexture(sourceTexture, renderer, zoom) { - if (zoom <= 1) { - return sourceTexture; - } - - const { width, height } = getTextureImageSize(sourceTexture); - - if (!width || !height) { - return sourceTexture; - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext('2d'); - - if (!context) { - return sourceTexture; - } - - const cropWidth = width / zoom; - const cropHeight = height / zoom; - const sourceX = (width - cropWidth) / 2; - const sourceY = (height - cropHeight) / 2; - - context.drawImage( - sourceTexture.image, - sourceX, - sourceY, - cropWidth, - cropHeight, - 0, - 0, - width, - height, - ); - - const zoomedTexture = new THREE.CanvasTexture(canvas); - zoomedTexture.colorSpace = sourceTexture.colorSpace; - zoomedTexture.wrapS = THREE.ClampToEdgeWrapping; - zoomedTexture.wrapT = THREE.ClampToEdgeWrapping; - setTextureSampling(zoomedTexture, renderer); - zoomedTexture.needsUpdate = true; - - return zoomedTexture; -} - -function createStudioGlassEnvironmentTexture(renderer, backdropTexture) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = backdropTexture - ? pmremGenerator.fromEquirectangular(backdropTexture).texture - : pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function createFallbackGlassBackdropTexture(renderer) { - const texture = new THREE.DataTexture( - new Uint8Array([3, 3, 3, 255]), - 1, - 1, - THREE.RGBAFormat, - ); - texture.colorSpace = THREE.SRGBColorSpace; - texture.wrapS = THREE.ClampToEdgeWrapping; - texture.wrapT = THREE.ClampToEdgeWrapping; - texture.mapping = THREE.EquirectangularReflectionMapping; - setTextureSampling(texture, renderer); - texture.needsUpdate = true; - - return texture; -} - -function loadTexture(url, renderer, colorSpace) { - const loader = new THREE.TextureLoader(); - - return new Promise((resolve, reject) => { - loader.load( - url, - (texture) => { - texture.colorSpace = colorSpace; - setTextureSampling(texture, renderer); - resolve(texture); - }, - undefined, - reject, - ); - }); -} - -async function loadGlassEnvironmentTexture(renderer) { - const sourceBackgroundTexture = await loadTexture( - GLASS_ENVIRONMENT_TEXTURE_URL, - renderer, - THREE.SRGBColorSpace, - ); - const backgroundTexture = createZoomedGlassTexture( - sourceBackgroundTexture, - renderer, - GLASS_ENVIRONMENT_ZOOM, - ); - if (backgroundTexture !== sourceBackgroundTexture) { - sourceBackgroundTexture.dispose(); - } - backgroundTexture.mapping = THREE.EquirectangularReflectionMapping; - backgroundTexture.wrapS = THREE.ClampToEdgeWrapping; - backgroundTexture.wrapT = THREE.ClampToEdgeWrapping; - backgroundTexture.needsUpdate = true; - const environmentTexture = createStudioGlassEnvironmentTexture( - renderer, - backgroundTexture, - ); - - return { - backgroundTexture, - environmentTexture, - }; -} - -async function createHalftoneMaterialAssets(renderer) { - const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer); - - try { - const glassEnvironmentAssets = await loadGlassEnvironmentTexture(renderer); - - return { - glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture, - glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture, - solidEnvironmentTexture, - }; - } catch { - const fallbackGlassBackdropTexture = - createFallbackGlassBackdropTexture(renderer); - const fallbackGlassEnvironmentTexture = - createStudioGlassEnvironmentTexture(renderer); - - return { - glassBackgroundTexture: fallbackGlassBackdropTexture, - glassEnvironmentTexture: fallbackGlassEnvironmentTexture, - solidEnvironmentTexture, - }; - } -} - -function createHalftoneMaterial() { - return new HalftoneTransmissionMaterial(); -} - -function applyHalftoneMaterialSettings( - material, - materialSettings, - materialAssets, -) { - const isGlass = materialSettings.surface === 'glass'; - const glassThickness = - materialSettings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS; - const glassEnvironmentIntensity = - GLASS_ENVIRONMENT_INTENSITY_BASE + - materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER; - const glassAttenuationDistance = Math.max( - glassThickness * 4, - GLASS_ATTENUATION_DISTANCE_MIN, - ); - - material.color.set(isGlass ? '#ffffff' : materialSettings.color); - material.roughness = materialSettings.roughness; - material.metalness = materialSettings.metalness; - material.envMap = isGlass - ? materialAssets.glassEnvironmentTexture - : materialAssets.solidEnvironmentTexture; - material.envMapIntensity = isGlass - ? GLASS_ENVIRONMENT_INTENSITY_BASE + - materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER - : 0.25; - material.clearcoat = isGlass ? 1 : 0; - material.clearcoatRoughness = isGlass - ? Math.max(materialSettings.roughness * 0.25, 0.01) - : 0.08; - material.reflectivity = isGlass ? 0.98 : 0.5; - material.transmission = 0; - material._transmission = isGlass ? 1 : 0; - material.refractionEnvMap = isGlass - ? materialAssets.glassBackgroundTexture - : null; - material.useEnvMapRefraction = isGlass ? 1 : 0; - material.thickness = isGlass ? glassThickness : 0; - material.ior = isGlass ? materialSettings.refraction : 1.5; - material.buffer = null; - material.bumpMap = null; - material.bumpScale = 0; - material.roughnessMap = null; - material.side = THREE.FrontSide; - material.transparent = false; - material.opacity = 1; - material.depthWrite = true; - material.attenuationColor.set(isGlass ? materialSettings.color : 'white'); - material.attenuationDistance = isGlass ? glassAttenuationDistance : Infinity; - material.anisotropicBlur = isGlass - ? THREE.MathUtils.lerp(0.03, 0.12, materialSettings.roughness) - : 0.1; - material.chromaticAberration = isGlass ? 0 : 0.05; - material.distortion = 0; - material.distortionScale = 0.5; - material.temporalDistortion = 0; - material.userData.halftoneIsGlass = isGlass; - material.userData.halftoneGlassBacksideThickness = isGlass - ? glassThickness * 2 - : 0; - material.userData.halftoneGlassBacksideEnvIntensity = isGlass - ? glassEnvironmentIntensity * 2.8 - : 0; - material.userData.halftoneUseEnvironmentRefraction = isGlass; - material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25; - - material.needsUpdate = true; -} - -function disposeHalftoneMaterialAssets(materialAssets) { - materialAssets.glassBackgroundTexture.dispose(); - - if ( - materialAssets.glassEnvironmentTexture !== - materialAssets.glassBackgroundTexture - ) { - materialAssets.glassEnvironmentTexture.dispose(); - } - - materialAssets.solidEnvironmentTexture.dispose(); -} - -function createRenderTarget(width, height) { - return new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); -} - -function createInteractionState() { - return { - autoElapsed: initialPose.autoElapsed, - activePointerId: null, - dragging: false, - hoverStrength: 0, - mouseX: 0.5, - mouseY: 0.5, - pointerInside: false, - pointerVelocityX: 0, - pointerVelocityY: 0, - pointerX: 0, - pointerY: 0, - rotateElapsed: initialPose.rotateElapsed, - rotationX: initialPose.rotationX, - rotationVelocityX: 0, - rotationY: initialPose.rotationY, - rotationVelocityY: 0, - rotationZ: initialPose.rotationZ, - rotationVelocityZ: 0, - smoothedMouseX: 0.5, - smoothedMouseY: 0.5, - targetRotationX: initialPose.targetRotationX, - targetRotationY: initialPose.targetRotationY, - velocityX: 0, - velocityY: 0, - }; -} - -function setPrimaryLightPosition(light, angleDegrees, height) { - const lightAngle = (angleDegrees * Math.PI) / 180; - light.position.set( - Math.cos(lightAngle) * 5, - height, - Math.sin(lightAngle) * 5, - ); -} - -function applySpringStep(current, target, velocity, strength, damping) { - const nextVelocity = (velocity + (target - current) * strength) * damping; - const nextValue = current + nextVelocity; - - return { - value: nextValue, - velocity: nextVelocity, - }; -} - -function resetInteractionState(interactionState) { - interactionState.dragging = false; - interactionState.mouseX = 0.5; - interactionState.mouseY = 0.5; - interactionState.targetRotationX = 0; - interactionState.targetRotationY = 0; - interactionState.velocityX = 0; - interactionState.velocityY = 0; - interactionState.rotationVelocityX = 0; - interactionState.rotationVelocityY = 0; - interactionState.rotationVelocityZ = 0; - interactionState.autoElapsed = 0; -} - -async function createGeometry(modelUrl) { - if (shape.kind === 'imported') { - if (!modelUrl) { - throw new Error('No model URL was provided for ' + shape.label + '.'); - } - - return loadImportedGeometryFromUrl(modelUrl, shape.label); - } - - return createBuiltinGeometry(shape.key); -} - -async function mountHalftoneCanvas(options) { - const { container, modelUrl, onError } = options; - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))), - 1, - ); - - let geometry; - - try { - geometry = await createGeometry(modelUrl); - } catch (error) { - onError?.(error); - return () => {}; - } - - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor(0x000000, 0); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.cursor = settings.animation.followDragEnabled - ? 'grab' - : 'default'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const materialAssets = await createHalftoneMaterialAssets(renderer); - - const scene3d = new THREE.Scene(); - scene3d.background = null; - - const baseCameraDistance = previewDistance; - const camera = new THREE.PerspectiveCamera( - 45, - getWidth() / getHeight(), - 0.1, - 100, - ); - camera.position.z = baseCameraDistance; - - const primaryLight = new THREE.DirectionalLight( - 0xffffff, - settings.lighting.intensity, - ); - setPrimaryLightPosition( - primaryLight, - settings.lighting.angleDegrees, - settings.lighting.height, - ); - scene3d.add(primaryLight); - - const fillLight = new THREE.DirectionalLight( - 0xffffff, - settings.lighting.fillIntensity, - ); - fillLight.position.set(-3, -1, 1); - scene3d.add(fillLight); - - const ambientLight = new THREE.AmbientLight( - 0xffffff, - settings.lighting.ambientIntensity, - ); - scene3d.add(ambientLight); - - const material = createHalftoneMaterial(); - applyHalftoneMaterialSettings(material, settings.material, materialAssets); - - const mesh = new THREE.Mesh(geometry, material); - scene3d.add(mesh); - - const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - effectResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - logicalResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - tile: { value: settings.halftone.scale }, - s_3: { value: settings.halftone.power }, - s_4: { value: settings.halftone.width }, - applyToDarkAreas: { - value: settings.halftone.toneTarget === 'dark' ? 1 : 0, - }, - dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, - hoverDashColor: { - value: new THREE.Color(settings.halftone.hoverDashColor), - }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: 1 }, - footprintScale: { value: 1.0 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - interactionVelocity: { value: new THREE.Vector2(0, 0) }, - dragOffset: { value: new THREE.Vector2(0, 0) }, - hoverHalftoneActive: { value: 0 }, - hoverHalftonePowerShift: { value: 0 }, - hoverHalftoneRadius: { value: 0.2 }, - hoverHalftoneWidthShift: { value: 0 }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: 0.2 }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - cropToBounds: { value: 0 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add( - new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), - ); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const updateViewportUniforms = ( - logicalWidth, - logicalHeight, - effectWidth, - effectHeight, - ) => { - blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - halftoneMaterial.uniforms.effectResolution.value.set( - effectWidth, - effectHeight, - ); - halftoneMaterial.uniforms.logicalResolution.value.set( - logicalWidth, - logicalHeight, - ); - }; - - const getHalftoneScale = (viewportWidth, viewportHeight, lookAtTarget) => { - if (!mesh.geometry.boundingBox) { - mesh.geometry.computeBoundingBox(); - } - - if (!mesh.geometry.boundingBox) { - return 1; - } - - mesh.updateMatrixWorld(); - camera.updateMatrixWorld(); - - return getMeshFootprintScale({ - camera, - localBounds: mesh.geometry.boundingBox, - lookAtTarget, - meshMatrixWorld: mesh.matrixWorld, - viewportHeight, - viewportWidth, - }); - }; - - const interaction = createInteractionState(); - const autoRotateEnabled = settings.animation.autoRotateEnabled; - const followHoverEnabled = settings.animation.followHoverEnabled; - const followDragEnabled = settings.animation.followDragEnabled; - const rotateEnabled = settings.animation.rotateEnabled; - - const syncSize = () => { - const width = getWidth(); - const height = getHeight(); - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - updateViewportUniforms( - virtualWidth, - virtualHeight, - virtualWidth, - virtualHeight, - ); - }; - - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); - - const updatePointerPosition = (event) => { - const rect = canvas.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - - interaction.mouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, - 0, - 1, - ); - interaction.mouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, - 0, - 1, - ); - }; - - const handlePointerDown = (event) => { - updatePointerPosition(event); - if (!followDragEnabled) { - return; - } - - interaction.dragging = true; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - interaction.velocityX = 0; - interaction.velocityY = 0; - canvas.style.cursor = 'grabbing'; - }; - - const handlePointerMove = (event) => { - updatePointerPosition(event); - }; - - const handleWindowPointerMove = (event) => { - updatePointerPosition(event); - - if (!interaction.dragging || !followDragEnabled) { - return; - } - - const deltaX = - (event.clientX - interaction.pointerX) * settings.animation.dragSens; - const deltaY = - (event.clientY - interaction.pointerY) * settings.animation.dragSens; - interaction.velocityX = deltaY; - interaction.velocityY = deltaX; - interaction.targetRotationY += deltaX; - interaction.targetRotationX += deltaY; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - }; - - const handlePointerLeave = () => { - if (interaction.dragging) { - return; - } - - interaction.mouseX = 0.5; - interaction.mouseY = 0.5; - }; - - const handlePointerUp = () => { - interaction.dragging = false; - canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; - - if (!settings.animation.springReturnEnabled) { - return; - } - - const springImpulse = Math.max(settings.animation.springStrength * 10, 1.2); - interaction.rotationVelocityX += interaction.velocityX * springImpulse; - interaction.rotationVelocityY += interaction.velocityY * springImpulse; - interaction.rotationVelocityZ += - interaction.velocityY * springImpulse * 0.12; - interaction.targetRotationX = 0; - interaction.targetRotationY = 0; - interaction.velocityX = 0; - interaction.velocityY = 0; - }; - - const handlePointerCancel = () => { - interaction.dragging = false; - interaction.velocityX = 0; - interaction.velocityY = 0; - canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; - handlePointerLeave(); - }; - - const handleWindowBlur = () => { - handlePointerUp(); - handlePointerLeave(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointercancel', handlePointerCancel); - window.addEventListener('pointerup', handlePointerUp); - window.addEventListener('pointermove', handleWindowPointerMove); - window.addEventListener('blur', handleWindowBlur); - canvas.addEventListener('pointerdown', handlePointerDown); - - const clock = new THREE.Timer(); - clock.connect(document); - let animationFrameId = 0; - - const renderFrame = (timestamp) => { - animationFrameId = window.requestAnimationFrame(renderFrame); - clock.update(timestamp); - - const delta = 1 / 60; - const elapsedTime = initialPose.timeElapsed + clock.getElapsed(); - halftoneMaterial.uniforms.time.value = elapsedTime; - - let baseRotationX = initialPose.rotationX; - let baseRotationY = initialPose.rotationY; - let baseRotationZ = initialPose.rotationZ; - let meshOffsetY = 0; - let meshScale = 1; - let lightAngle = settings.lighting.angleDegrees; - let lightHeight = settings.lighting.height; - - if (autoRotateEnabled) { - interaction.autoElapsed += delta; - baseRotationY += interaction.autoElapsed * settings.animation.autoSpeed; - baseRotationX += - Math.sin(interaction.autoElapsed * 0.2) * settings.animation.autoWobble; - } - - if (settings.animation.floatEnabled) { - const floatPhase = elapsedTime * settings.animation.floatSpeed; - const driftAmount = (settings.animation.driftAmount * Math.PI) / 180; - - meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude; - baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45; - baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3; - } - - if (settings.animation.breatheEnabled) { - meshScale *= - 1 + - Math.sin(elapsedTime * settings.animation.breatheSpeed) * - settings.animation.breatheAmount; - } - - if (rotateEnabled) { - interaction.rotateElapsed += delta; - const rotateProgress = settings.animation.rotatePingPong - ? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) * - Math.PI - : interaction.rotateElapsed * settings.animation.rotateSpeed; - - if (settings.animation.rotatePreset === 'axis') { - const axisDirection = settings.animation.rotateAxis.startsWith('-') - ? -1 - : 1; - const axisProgress = rotateProgress * axisDirection; - - if ( - settings.animation.rotateAxis === 'x' || - settings.animation.rotateAxis === 'xy' || - settings.animation.rotateAxis === '-x' || - settings.animation.rotateAxis === '-xy' - ) { - baseRotationX += axisProgress; - } - - if ( - settings.animation.rotateAxis === 'y' || - settings.animation.rotateAxis === 'xy' || - settings.animation.rotateAxis === '-y' || - settings.animation.rotateAxis === '-xy' - ) { - baseRotationY += axisProgress; - } - - if ( - settings.animation.rotateAxis === 'z' || - settings.animation.rotateAxis === '-z' - ) { - baseRotationZ += axisProgress; - } - } else if (settings.animation.rotatePreset === 'lissajous') { - baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65; - baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05; - baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32; - } else if (settings.animation.rotatePreset === 'orbit') { - baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42; - baseRotationY += Math.cos(rotateProgress) * 1.2; - baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24; - } else if (settings.animation.rotatePreset === 'tumble') { - baseRotationX += rotateProgress * 0.55; - baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9; - baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38; - } - } - - if (settings.animation.lightSweepEnabled) { - const lightPhase = elapsedTime * settings.animation.lightSweepSpeed; - lightAngle += Math.sin(lightPhase) * settings.animation.lightSweepRange; - lightHeight += - Math.cos(lightPhase * 0.85) * settings.animation.lightSweepHeightRange; - } - - let targetX = baseRotationX; - let targetY = baseRotationY; - let easing = 0.12; - - if (followHoverEnabled) { - const rangeRadians = (settings.animation.hoverRange * Math.PI) / 180; - - if ( - settings.animation.hoverReturn || - interaction.mouseX !== 0.5 || - interaction.mouseY !== 0.5 - ) { - targetX += (interaction.mouseY - 0.5) * rangeRadians; - targetY += (interaction.mouseX - 0.5) * rangeRadians; - } - - easing = settings.animation.hoverEase; - } - - if (followDragEnabled) { - if (!interaction.dragging && settings.animation.dragMomentum) { - interaction.targetRotationX += interaction.velocityX; - interaction.targetRotationY += interaction.velocityY; - interaction.velocityX *= 1 - settings.animation.dragFriction; - interaction.velocityY *= 1 - settings.animation.dragFriction; - } - - targetX += interaction.targetRotationX; - targetY += interaction.targetRotationY; - easing = settings.animation.dragFriction; - } - - if (autoRotateEnabled && !followHoverEnabled && !followDragEnabled) { - targetX = baseRotationX + interaction.targetRotationX; - targetY = baseRotationY + interaction.targetRotationY; - - if (interaction.dragging) { - targetX = interaction.targetRotationX; - targetY = interaction.targetRotationY; - } - - easing = 0.08; - } - - if (settings.animation.springReturnEnabled) { - const springX = applySpringStep( - interaction.rotationX, - targetX, - interaction.rotationVelocityX, - settings.animation.springStrength, - settings.animation.springDamping, - ); - const springY = applySpringStep( - interaction.rotationY, - targetY, - interaction.rotationVelocityY, - settings.animation.springStrength, - settings.animation.springDamping, - ); - const springZ = applySpringStep( - interaction.rotationZ, - baseRotationZ, - interaction.rotationVelocityZ, - settings.animation.springStrength, - settings.animation.springDamping, - ); - - interaction.rotationX = springX.value; - interaction.rotationY = springY.value; - interaction.rotationZ = springZ.value; - interaction.rotationVelocityX = springX.velocity; - interaction.rotationVelocityY = springY.velocity; - interaction.rotationVelocityZ = springZ.velocity; - } else { - interaction.rotationX += (targetX - interaction.rotationX) * easing; - interaction.rotationY += (targetY - interaction.rotationY) * easing; - interaction.rotationZ += - (baseRotationZ - interaction.rotationZ) * - (settings.animation.rotatePingPong ? 0.18 : 0.12); - } - - mesh.rotation.set( - interaction.rotationX, - interaction.rotationY, - interaction.rotationZ, - ); - mesh.position.y = meshOffsetY; - mesh.scale.setScalar(meshScale); - - if (settings.animation.cameraParallaxEnabled) { - const cameraRange = settings.animation.cameraParallaxAmount; - const cameraEase = settings.animation.cameraParallaxEase; - const centeredX = (interaction.mouseX - 0.5) * 2; - const centeredY = (0.5 - interaction.mouseY) * 2; - const orbitYaw = centeredX * cameraRange; - const orbitPitch = centeredY * cameraRange * 0.7; - const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance; - const targetCameraX = Math.sin(orbitYaw) * horizontalRadius; - const targetCameraY = Math.sin(orbitPitch) * baseCameraDistance * 0.85; - const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius; - - camera.position.x += (targetCameraX - camera.position.x) * cameraEase; - camera.position.y += (targetCameraY - camera.position.y) * cameraEase; - camera.position.z += (targetCameraZ - camera.position.z) * cameraEase; - } else { - camera.position.x += (0 - camera.position.x) * 0.12; - camera.position.y += (0 - camera.position.y) * 0.12; - camera.position.z += (baseCameraDistance - camera.position.z) * 0.12; - } - - const lookAtTarget = new THREE.Vector3(0, meshOffsetY * 0.2, 0); - - camera.lookAt(lookAtTarget); - setPrimaryLightPosition(primaryLight, lightAngle, lightHeight); - halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale( - getVirtualWidth(), - getVirtualHeight(), - lookAtTarget, - ); - - if (!settings.halftone.enabled) { - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(scene3d, camera); - return; - } - - renderer.setRenderTarget(sceneTarget); - renderer.render(scene3d, camera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderFrame(); - - return () => { - window.cancelAnimationFrame(animationFrameId); - clock.dispose(); - resizeObserver.disconnect(); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - window.removeEventListener('pointerup', handlePointerUp); - window.removeEventListener('pointermove', handleWindowPointerMove); - canvas.removeEventListener('pointercancel', handlePointerCancel); - window.removeEventListener('blur', handleWindowBlur); - canvas.removeEventListener('pointerdown', handlePointerDown); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - fullScreenGeometry.dispose(); - material.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - disposeHalftoneMaterialAssets(materialAssets); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; -} - -const StyledVisualMount = styled.div` - background: transparent; - display: block; - height: 100%; - min-width: 0; - width: 100%; -`; - -type WhyTwentyHeroHalftoneProps = { - modelUrl?: string; - style?: CSSProperties; -}; - -export function WhyTwentyHeroHalftone({ - modelUrl = '/illustrations/why-twenty/hero/hero.glb', - style, -}: WhyTwentyHeroHalftoneProps) { - const mountReference = useRef(null); - - useEffect(() => { - const container = mountReference.current; - - if (!container) { - return; - } - - const unmount = mountHalftoneCanvas({ - container, - modelUrl, - onError: (error) => { - console.error(error); - }, - }); - - return () => { - void Promise.resolve(unmount).then((dispose) => dispose?.()); - }; - }, [modelUrl]); - - return ; -} diff --git a/packages/twenty-website-new/src/sections/Hero/visuals/milestone-config.ts b/packages/twenty-website-new/src/sections/Hero/visuals/milestone-config.ts new file mode 100644 index 0000000000..0b12068fbf --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/visuals/milestone-config.ts @@ -0,0 +1,139 @@ +import { + normalizeHalftoneStudioSettings, + type HalftoneExportPose, + type HalftoneImageFit, + type HalftoneStudioSettings, + type HalftoneStudioSettingsOverrides, +} from '@/lib/halftone'; + +export type MilestoneSettingsOverrides = HalftoneStudioSettingsOverrides; + +export const MILESTONE_IMAGE_URL = '/illustrations/generated/milestone.jpg'; +export const MILESTONE_IMAGE_FIT: HalftoneImageFit = 'cover'; +export const MILESTONE_PREVIEW_DISTANCE = 4; + +export const MILESTONE_INITIAL_POSE: HalftoneExportPose = { + autoElapsed: 0, + rotateElapsed: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + targetRotationX: 0, + targetRotationY: 0, + timeElapsed: 33.42220000023842, +}; + +const BASE_MILESTONE_SETTINGS = normalizeHalftoneStudioSettings({ + sourceMode: 'image', + shapeKey: 'torusKnot', + lighting: { + intensity: 1.5, + fillIntensity: 0.15, + ambientIntensity: 0.08, + angleDegrees: 45, + height: 2, + }, + material: { + surface: 'solid', + color: '#d4d0c8', + roughness: 0.42, + metalness: 0.16, + thickness: 150, + refraction: 2, + environmentPower: 5, + }, + halftone: { + enabled: true, + scale: 22, + power: -0.07, + toneTarget: 'light', + width: 0.46, + imageContrast: 1, + dashColor: '#F3F3F3', + hoverDashColor: '#F3F3F3', + }, + background: { + transparent: false, + color: '#4A38F5', + }, + animation: { + autoRotateEnabled: true, + breatheEnabled: false, + cameraParallaxEnabled: false, + followHoverEnabled: false, + followDragEnabled: false, + floatEnabled: false, + hoverHalftoneEnabled: false, + hoverLightEnabled: true, + dragFlowEnabled: false, + lightSweepEnabled: false, + rotateEnabled: false, + autoSpeed: 0.2, + autoWobble: 0.3, + breatheAmount: 0.04, + breatheSpeed: 0.8, + cameraParallaxAmount: 0.3, + cameraParallaxEase: 0.08, + driftAmount: 8, + hoverRange: 25, + hoverEase: 0.08, + hoverReturn: true, + dragSens: 0.008, + dragFriction: 0.08, + dragMomentum: true, + rotateAxis: 'y', + rotatePreset: 'axis', + rotateSpeed: 0.2, + rotatePingPong: false, + floatAmplitude: 0.16, + floatSpeed: 0.8, + lightSweepHeightRange: 0.5, + lightSweepRange: 28, + lightSweepSpeed: 0.7, + springDamping: 0.72, + springReturnEnabled: false, + springStrength: 0.18, + hoverHalftonePowerShift: 0.42, + hoverHalftoneRadius: 0.2, + hoverHalftoneWidthShift: -0.18, + hoverLightIntensity: 1.2, + hoverLightRadius: 0.45, + dragFlowDecay: 0.08, + dragFlowRadius: 0.24, + dragFlowStrength: 1.8, + hoverWarpStrength: 3, + hoverWarpRadius: 0.15, + dragWarpStrength: 5, + waveEnabled: false, + waveSpeed: 1, + waveAmount: 2, + }, +}); + +export const buildMilestoneSettings = ( + overrides?: MilestoneSettingsOverrides, +): HalftoneStudioSettings => + normalizeHalftoneStudioSettings({ + ...BASE_MILESTONE_SETTINGS, + ...overrides, + animation: { + ...BASE_MILESTONE_SETTINGS.animation, + ...overrides?.animation, + }, + background: { + ...BASE_MILESTONE_SETTINGS.background, + ...overrides?.background, + }, + halftone: { + ...BASE_MILESTONE_SETTINGS.halftone, + ...overrides?.halftone, + }, + lighting: { + ...BASE_MILESTONE_SETTINGS.lighting, + ...overrides?.lighting, + }, + material: { + ...BASE_MILESTONE_SETTINGS.material, + ...overrides?.material, + }, + }); diff --git a/packages/twenty-website-new/src/sections/Hero/visuals/product-effect-config.ts b/packages/twenty-website-new/src/sections/Hero/visuals/product-effect-config.ts new file mode 100644 index 0000000000..aafa5271a5 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/visuals/product-effect-config.ts @@ -0,0 +1,108 @@ +import { + normalizeHalftoneStudioSettings, + type HalftoneExportPose, + type HalftoneStudioSettings, +} from '@/lib/halftone'; + +export const PRODUCT_EFFECT_MODEL_URL = '/illustrations/product/hero/hero.glb'; +export const PRODUCT_EFFECT_PREVIEW_DISTANCE = 2.5; +export const PRODUCT_EFFECT_VIRTUAL_RENDER_HEIGHT = 400; + +export const PRODUCT_EFFECT_INITIAL_POSE: HalftoneExportPose = { + autoElapsed: 0, + rotateElapsed: 0, + rotationX: 0.4, + rotationY: 0, + rotationZ: 0.6, + targetRotationX: 0, + targetRotationY: 0, + timeElapsed: 0, +}; + +export const PRODUCT_EFFECT_SETTINGS: HalftoneStudioSettings = + normalizeHalftoneStudioSettings({ + sourceMode: 'shape', + shapeKey: 'userUpload_1776153228532', + lighting: { + intensity: 0.5, + fillIntensity: 0, + ambientIntensity: 0, + angleDegrees: 80, + height: -4, + }, + material: { + surface: 'solid', + color: '#F5F5F5', + roughness: 0.4, + metalness: 0.1, + thickness: 150, + refraction: 2, + environmentPower: 5, + }, + halftone: { + enabled: true, + scale: 12, + power: 0.1, + toneTarget: 'light', + width: 0.6, + imageContrast: 1, + dashColor: '#4A38F5', + hoverDashColor: '#4A38F5', + }, + background: { + transparent: true, + color: '#F4F4F4', + }, + animation: { + autoRotateEnabled: true, + breatheEnabled: false, + cameraParallaxEnabled: false, + followHoverEnabled: false, + followDragEnabled: true, + floatEnabled: false, + hoverHalftoneEnabled: false, + hoverLightEnabled: false, + dragFlowEnabled: false, + lightSweepEnabled: false, + rotateEnabled: false, + autoSpeed: 0.01, + autoWobble: 0.2, + breatheAmount: 0.04, + breatheSpeed: 0.8, + cameraParallaxAmount: 0.3, + cameraParallaxEase: 0.08, + driftAmount: 8, + hoverRange: 25, + hoverEase: 0.08, + hoverReturn: true, + dragSens: 0.008, + dragFriction: 0.08, + dragMomentum: true, + rotateAxis: 'y', + rotatePreset: 'axis', + rotateSpeed: 0.1, + rotatePingPong: false, + floatAmplitude: 0.16, + floatSpeed: 0.8, + lightSweepHeightRange: 0.5, + lightSweepRange: 28, + lightSweepSpeed: 0.7, + springDamping: 0.52, + springReturnEnabled: true, + springStrength: 0.2, + hoverHalftonePowerShift: 0.42, + hoverHalftoneRadius: 0.2, + hoverHalftoneWidthShift: -0.18, + hoverLightIntensity: 0.8, + hoverLightRadius: 0.2, + dragFlowDecay: 0.08, + dragFlowRadius: 0.24, + dragFlowStrength: 1.8, + hoverWarpStrength: 3, + hoverWarpRadius: 0.15, + dragWarpStrength: 5, + waveEnabled: false, + waveSpeed: 1, + waveAmount: 2, + }, + }); diff --git a/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperBackgroundHalftone.tsx b/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperBackgroundHalftone.tsx index ccfe6bb45a..fa13b4be92 100644 --- a/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperBackgroundHalftone.tsx +++ b/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperBackgroundHalftone.tsx @@ -5,8 +5,13 @@ import { getImageFootprintScale, getImagePreviewZoom, } from '@/lib/halftone'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; import { getPrefersReducedMotionSnapshot } from '@/lib/motion'; -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; +import { + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, +} from '@/lib/visual-runtime'; import { styled } from '@linaria/react'; import { useEffect, useRef } from 'react'; import * as THREE from 'three'; @@ -312,11 +317,20 @@ async function mountHalftoneCanvas({ return; } - const renderer = createSiteWebGlRenderer({ + let renderLoop: VisualRenderLoop | null = null; + const renderer = tryCreateSiteWebGlRenderer({ alpha: true, antialias: false, + onContextLost: () => { + renderLoop?.stop(); + }, powerPreference: 'high-performance', }); + + if (renderer === null) { + return undefined; + } + renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setClearColor(0x000000, 0); renderer.setPixelRatio(1); @@ -447,8 +461,7 @@ async function mountHalftoneCanvas({ updateViewportUniforms(virtualWidth, virtualHeight); }; - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); + const stopObservingSize = observeElementSize(container, syncSize); const updatePointerPosition = ( event: PointerEvent, @@ -511,10 +524,8 @@ async function mountHalftoneCanvas({ const clock = new THREE.Timer(); clock.connect(document); - let animationFrameId = 0; const renderFrame = (timestamp?: number) => { - animationFrameId = window.requestAnimationFrame(renderFrame); clock.update(timestamp); const deltaSeconds = clock.getDelta(); const hoverEasing = @@ -571,12 +582,17 @@ async function mountHalftoneCanvas({ renderer.render(postScene, orthographicCamera); }; - renderFrame(0); + renderLoop = createVisualRenderLoop({ + renderFrame, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); return () => { - window.cancelAnimationFrame(animationFrameId); + renderLoop?.dispose(); clock.dispose(); - resizeObserver.disconnect(); + stopObservingSize(); interactionTarget.removeEventListener('pointerleave', handlePointerLeave); interactionTarget.removeEventListener('pointermove', handlePointerMove); fullScreenGeometry.dispose(); diff --git a/packages/twenty-website-new/src/sections/Menu/components/Root.tsx b/packages/twenty-website-new/src/sections/Menu/components/Root.tsx index dc5a7b94dd..b5b769f9c8 100644 --- a/packages/twenty-website-new/src/sections/Menu/components/Root.tsx +++ b/packages/twenty-website-new/src/sections/Menu/components/Root.tsx @@ -2,6 +2,7 @@ import { Container, IconButton, LinkButton } from '@/design-system/components'; import { CloseIcon, MenuIcon } from '@/icons'; +import { useTimeoutRegistry } from '@/lib/react'; import type { MenuNavItemType, MenuScheme, @@ -10,7 +11,7 @@ import type { import { theme } from '@/theme'; import { Drawer } from '@base-ui/react/drawer'; import { styled } from '@linaria/react'; -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { CloseDrawerWhenNavigationExpandsEffect } from './../effect-components/CloseDrawerWhenNavigationExpandsEffect'; import { MenuDrawer } from './Drawer'; @@ -103,20 +104,20 @@ export function Root({ const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [hasScrolled, setHasScrolled] = useState(false); const [isScrolling, setIsScrolling] = useState(false); - const scrollTimeoutRef = useRef(null); + const timeoutRegistry = useTimeoutRegistry(); useEffect(() => { + let cancelScrollIdle: (() => void) | null = null; + const handleScroll = () => { setHasScrolled(window.scrollY > 8); setIsScrolling(true); - if (scrollTimeoutRef.current !== null) { - window.clearTimeout(scrollTimeoutRef.current); - } + cancelScrollIdle?.(); - scrollTimeoutRef.current = window.setTimeout(() => { + cancelScrollIdle = timeoutRegistry.schedule(() => { setIsScrolling(false); - scrollTimeoutRef.current = null; + cancelScrollIdle = null; }, SCROLL_IDLE_TIMEOUT_MS); }; @@ -125,12 +126,9 @@ export function Root({ return () => { window.removeEventListener('scroll', handleScroll); - - if (scrollTimeoutRef.current !== null) { - window.clearTimeout(scrollTimeoutRef.current); - } + cancelScrollIdle?.(); }; - }, []); + }, [timeoutRegistry]); const buttonColor: { border: string; diff --git a/packages/twenty-website-new/src/sections/Plans/components/BillingToggle.tsx b/packages/twenty-website-new/src/sections/Plans/components/BillingToggle.tsx index 57f0a3d04e..fb4c1fdd6d 100644 --- a/packages/twenty-website-new/src/sections/Plans/components/BillingToggle.tsx +++ b/packages/twenty-website-new/src/sections/Plans/components/BillingToggle.tsx @@ -1,5 +1,6 @@ 'use client'; +import { observeElementsSize } from '@/lib/dom/observe-element-size'; import type { PlansBillingPeriod } from '@/sections/Plans/types'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; @@ -146,13 +147,13 @@ export function BillingToggle({ syncHighlight(); - const resizeObserver = new ResizeObserver(syncHighlight); - resizeObserver.observe(track); - resizeObserver.observe(monthlyButton); - resizeObserver.observe(yearlyButton); + const stopObservingSize = observeElementsSize( + [track, monthlyButton, yearlyButton], + syncHighlight, + ); return () => { - resizeObserver.disconnect(); + stopObservingSize(); }; }, [billing]); diff --git a/packages/twenty-website-new/src/sections/Plans/components/Card.tsx b/packages/twenty-website-new/src/sections/Plans/components/Card.tsx index e2f04d2132..7b68e6c423 100644 --- a/packages/twenty-website-new/src/sections/Plans/components/Card.tsx +++ b/packages/twenty-website-new/src/sections/Plans/components/Card.tsx @@ -5,10 +5,12 @@ import NextImage from 'next/image'; import { Body, Heading, LinkButton } from '@/design-system/components'; import { CheckIcon } from '@/icons/informative/Check'; +import { useAnimatedNumber } from '@/lib/animation'; +import { useTimeoutRegistry } from '@/lib/react'; import type { PlanCardType } from '@/sections/Plans/types'; import { theme } from '@/theme'; import { css } from '@linaria/core'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; const StyledCard = styled.div` background-color: ${theme.colors.primary.background[100]}; @@ -245,45 +247,9 @@ type CardProps = { maxBullets: number; }; -const PRICE_ROLL_DURATION_MS = 500; const PRICE_NUMBER_FORMATTER = new Intl.NumberFormat('en-US'); const PRICE_HEADING_NUMBER_REGEX = /^(.*?)(\d[\d,]*)(.*)$/; -const useAnimatedNumber = (target: number) => { - const [display, setDisplay] = useState(target); - const previousValueRef = useRef(target); - - useEffect(() => { - const from = previousValueRef.current; - previousValueRef.current = target; - - if (from === target) { - return; - } - - const start = performance.now(); - let animationFrameId = 0; - - const tick = (now: number) => { - const progress = Math.min((now - start) / PRICE_ROLL_DURATION_MS, 1); - const eased = 1 - (1 - progress) ** 3; - setDisplay(Math.round(from + (target - from) * eased)); - - if (progress < 1) { - animationFrameId = requestAnimationFrame(tick); - } - }; - - animationFrameId = requestAnimationFrame(tick); - - return () => { - cancelAnimationFrame(animationFrameId); - }; - }, [target]); - - return display; -}; - function getHeadingSegments(heading: PlanCardType['price']['heading']) { return Array.isArray(heading) ? heading : [heading]; } @@ -360,6 +326,7 @@ function getFeaturesAnimationMinHeight(maxBullets: number) { } export function Card({ card, highlighted = false, maxBullets }: CardProps) { + const timeoutRegistry = useTimeoutRegistry(); const iconWidth = card.icon.width ?? 80; const targetPriceValue = getPriceHeadingNumericValue(card.price.heading); const animatedPriceValue = useAnimatedNumber(targetPriceValue ?? 0); @@ -401,7 +368,7 @@ export function Card({ card, highlighted = false, maxBullets }: CardProps) { return; } - const timeoutId = window.setTimeout( + return timeoutRegistry.schedule( () => { setComparisonBullets(visibleBullets); setVisibleBullets(queuedBullets); @@ -411,18 +378,14 @@ export function Card({ card, highlighted = false, maxBullets }: CardProps) { FEATURES_SWITCH_ANIMATION_MS + FEATURE_ITEM_STAGGER_MS * visibleBullets.length, ); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [featuresPhase, queuedBullets, visibleBullets]); + }, [featuresPhase, queuedBullets, timeoutRegistry, visibleBullets]); useEffect(() => { if (featuresPhase !== 'entering') { return; } - const timeoutId = window.setTimeout( + return timeoutRegistry.schedule( () => { setComparisonBullets(null); setFeaturesPhase('stable'); @@ -430,11 +393,7 @@ export function Card({ card, highlighted = false, maxBullets }: CardProps) { FEATURES_SWITCH_ANIMATION_MS + FEATURE_ITEM_STAGGER_MS * visibleBullets.length, ); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [featuresPhase, visibleBullets]); + }, [featuresPhase, timeoutRegistry, visibleBullets]); const comparisonBulletTexts = new Set( (featuresPhase === 'exiting' ? queuedBullets : comparisonBullets)?.map( diff --git a/packages/twenty-website-new/src/sections/Problem/components/Visual/monolith.tsx b/packages/twenty-website-new/src/sections/Problem/components/Visual/monolith.tsx index dffddd0941..da673b38d3 100644 --- a/packages/twenty-website-new/src/sections/Problem/components/Visual/monolith.tsx +++ b/packages/twenty-website-new/src/sections/Problem/components/Visual/monolith.tsx @@ -2,9 +2,12 @@ import { useEffect, useRef, type CSSProperties } from 'react'; import * as THREE from 'three'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; import { - createFrameTimer, - createSiteWebGlRenderer, + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, + type VisualRenderLoopFrame, } from '@/lib/visual-runtime'; const IMAGE_SRC = '/images/home/problem/monolith-problem.webp'; @@ -495,7 +498,19 @@ async function mountHalftoneCanvas(options: MountHalftoneCanvasOptions) { return undefined; } - const renderer = createSiteWebGlRenderer({ alpha: true, antialias: false }); + let renderLoop: VisualRenderLoop | null = null; + const renderer = tryCreateSiteWebGlRenderer({ + alpha: true, + antialias: false, + onContextLost: () => { + renderLoop?.stop(); + }, + }); + + if (renderer === null) { + return undefined; + } + renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setClearColor(0x000000, 0); renderer.setPixelRatio(1); @@ -648,8 +663,7 @@ async function mountHalftoneCanvas(options: MountHalftoneCanvasOptions) { ); }; - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); + const stopObservingSize = observeElementSize(container, syncSize); const updatePointerPosition = ( event: PointerEvent, @@ -725,13 +739,11 @@ async function mountHalftoneCanvas(options: MountHalftoneCanvasOptions) { canvas.addEventListener('pointerup', handlePointerUp); window.addEventListener('blur', handleWindowBlur); - const frameTimer = createFrameTimer(); - let animationFrameId = 0; - - const renderFrame = () => { - animationFrameId = window.requestAnimationFrame(renderFrame); - - halftoneMaterial.uniforms.time.value = frameTimer.getElapsed(); + const renderFrame = ( + _timestamp: DOMHighResTimeStamp, + { elapsedSeconds }: VisualRenderLoopFrame, + ) => { + halftoneMaterial.uniforms.time.value = elapsedSeconds; halftoneMaterial.uniforms.dashColor.value.set(tuning.halftone.dashColor); halftoneMaterial.uniforms.s_3.value = tuning.halftone.power; halftoneMaterial.uniforms.s_4.value = tuning.halftone.width; @@ -791,11 +803,17 @@ async function mountHalftoneCanvas(options: MountHalftoneCanvasOptions) { renderer.render(postScene, orthographicCamera); }; - renderFrame(); + renderLoop = createVisualRenderLoop({ + renderFrame, + shouldRender: () => !signal.aborted, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); return () => { - window.cancelAnimationFrame(animationFrameId); - resizeObserver.disconnect(); + renderLoop?.dispose(); + stopObservingSize(); canvas.removeEventListener('pointerdown', handlePointerDown); canvas.removeEventListener('pointerleave', handlePointerLeave); canvas.removeEventListener('pointermove', handlePointerMove); diff --git a/packages/twenty-website-new/src/sections/Quote/components/Heading.tsx b/packages/twenty-website-new/src/sections/Quote/components/Heading.tsx deleted file mode 100644 index f595548086..0000000000 --- a/packages/twenty-website-new/src/sections/Quote/components/Heading.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Heading as BaseHeading } from '@/design-system/components/Heading'; -import type { HeadingType } from '@/design-system/components/Heading'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; - -const HeadingWrapper = styled.div` - color: ${theme.colors.secondary.text[100]}; - max-width: 100%; - min-width: 0; - white-space: pre-line; - width: 100%; - position: relative; - - @media (min-width: ${theme.breakpoints.md}px) { - max-width: 517px; - - &::before { - left: -48px; - top: -24px; - font-size: 96px; - } - } -`; - -type QuoteHeadingProps = { - segments: HeadingType[]; -}; - -export function Heading({ segments }: QuoteHeadingProps) { - return ( - - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Quote/components/Root.tsx b/packages/twenty-website-new/src/sections/Quote/components/Root.tsx deleted file mode 100644 index 8d49946456..0000000000 --- a/packages/twenty-website-new/src/sections/Quote/components/Root.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Container } from '@/design-system/components'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import type { ReactNode } from 'react'; - -const StyledSection = styled.section` - min-width: 0; - width: 100%; -`; - -const StyledContainer = styled(Container)` - column-gap: ${theme.spacing(6)}; - display: grid; - grid-template-columns: minmax(0, 1fr); - justify-items: stretch; - padding-bottom: ${theme.spacing(12)}; - padding-left: ${theme.spacing(4)}; - padding-right: ${theme.spacing(4)}; - padding-top: ${theme.spacing(12)}; - row-gap: ${theme.spacing(10)}; - - @media (min-width: ${theme.breakpoints.md}px) { - align-items: center; - column-gap: ${theme.spacing(8)}; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - justify-items: start; - padding-bottom: ${theme.spacing(14)}; - padding-left: ${theme.spacing(10)}; - padding-right: ${theme.spacing(10)}; - padding-top: ${theme.spacing(14)}; - row-gap: ${theme.spacing(8)}; - } -`; - -type RootProps = { - backgroundColor: string; - children: ReactNode; -}; - -export function Root({ backgroundColor, children }: RootProps) { - return ( - - {children} - - ); -} diff --git a/packages/twenty-website-new/src/sections/Quote/components/Visual.tsx b/packages/twenty-website-new/src/sections/Quote/components/Visual.tsx deleted file mode 100644 index d86f8bf9f5..0000000000 --- a/packages/twenty-website-new/src/sections/Quote/components/Visual.tsx +++ /dev/null @@ -1,12 +0,0 @@ -'use client'; - -import { WebGlMount } from '@/lib/visual-runtime'; -import { Quotes } from '@/sections/Quote/visuals/Quotes'; - -export function Visual() { - return ( - - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Quote/components/index.ts b/packages/twenty-website-new/src/sections/Quote/components/index.ts deleted file mode 100644 index 4af34d1213..0000000000 --- a/packages/twenty-website-new/src/sections/Quote/components/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Heading } from './Heading'; -import { Root } from './Root'; -import { Visual } from './Visual'; - -export const Quote = { Heading, Root, Visual }; diff --git a/packages/twenty-website-new/src/sections/Quote/types/QuoteData.ts b/packages/twenty-website-new/src/sections/Quote/types/QuoteData.ts deleted file mode 100644 index 5626dc3ba3..0000000000 --- a/packages/twenty-website-new/src/sections/Quote/types/QuoteData.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { HeadingType } from '@/design-system/components/Heading'; - -export type QuoteDataType = { - heading: HeadingType[]; -}; diff --git a/packages/twenty-website-new/src/sections/Quote/types/index.ts b/packages/twenty-website-new/src/sections/Quote/types/index.ts deleted file mode 100644 index cbcfc4af84..0000000000 --- a/packages/twenty-website-new/src/sections/Quote/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { QuoteDataType } from './QuoteData'; diff --git a/packages/twenty-website-new/src/sections/Quote/visuals/Quotes.tsx b/packages/twenty-website-new/src/sections/Quote/visuals/Quotes.tsx deleted file mode 100644 index 002e6d8819..0000000000 --- a/packages/twenty-website-new/src/sections/Quote/visuals/Quotes.tsx +++ /dev/null @@ -1,2074 +0,0 @@ -// oxlint-disable-next-line typescript/ban-ts-comment -// @ts-nocheck -'use client'; - -import { theme } from '@/theme'; -import { useEffect, useRef, type CSSProperties } from 'react'; -import * as THREE from 'three'; -import { styled } from '@linaria/react'; -import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; -import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import { GLASS_ENVIRONMENT_TEXTURE_URL } from '@/lib/visual-runtime/textures/glass-environment'; -import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; - -const settings = { - sourceMode: 'shape', - shapeKey: 'userUpload_1776619970151', - lighting: { - intensity: 1.5, - fillIntensity: 0.15, - ambientIntensity: 0.08, - angleDegrees: 45, - height: 2, - }, - material: { - surface: 'solid', - color: '#d4d0c8', - roughness: 0.42, - metalness: 0.16, - thickness: 150, - refraction: 2, - environmentPower: 5, - }, - halftone: { - enabled: true, - scale: 24.72, - power: -0.07, - toneTarget: 'light', - width: 0.46, - imageContrast: 1, - dashColor: '#4A38F5', - hoverDashColor: '#4A38F5', - }, - background: { - transparent: true, - color: '#000000', - }, - animation: { - autoRotateEnabled: false, - breatheEnabled: true, - cameraParallaxEnabled: true, - followHoverEnabled: false, - followDragEnabled: true, - floatEnabled: false, - hoverHalftoneEnabled: false, - hoverLightEnabled: false, - dragFlowEnabled: false, - lightSweepEnabled: true, - rotateEnabled: false, - autoSpeed: 0.2, - autoWobble: 0.3, - breatheAmount: 0.04, - breatheSpeed: 0.45, - cameraParallaxAmount: 0.3, - cameraParallaxEase: 0.08, - driftAmount: 8, - hoverRange: 25, - hoverEase: 0.08, - hoverReturn: true, - dragSens: 0.008, - dragFriction: 0.08, - dragMomentum: true, - rotateAxis: 'y', - rotatePreset: 'axis', - rotateSpeed: 0.2, - rotatePingPong: false, - floatAmplitude: 0.16, - floatSpeed: 0.8, - lightSweepHeightRange: 0.5, - lightSweepRange: 28, - lightSweepSpeed: 0.7, - springDamping: 0.64, - springReturnEnabled: true, - springStrength: 0.07, - hoverHalftonePowerShift: 0.42, - hoverHalftoneRadius: 0.2, - hoverHalftoneWidthShift: -0.18, - hoverLightIntensity: 0.8, - hoverLightRadius: 0.2, - dragFlowDecay: 0.08, - dragFlowRadius: 0.24, - dragFlowStrength: 1.8, - hoverWarpStrength: 3, - hoverWarpRadius: 0.15, - dragWarpStrength: 5, - waveEnabled: false, - waveSpeed: 1, - waveAmount: 2, - }, -}; -const shape = { - filename: 'quote.glb', - key: 'userUpload_1776619970151', - kind: 'imported', - label: 'quote.glb', - loader: 'glb', -}; -const initialPose = { - autoElapsed: 11.836299999999984, - rotateElapsed: 0, - rotationX: 2.2913757597130354e-55, - rotationY: 1.2648833163320286e-55, - rotationZ: 1.3486653847227601e-56, - targetRotationX: 0, - targetRotationY: 0, - timeElapsed: 54.6611, -}; -const previewDistance = 4; -const VIRTUAL_RENDER_HEIGHT = 768; -const passThroughVertexShader = - '\n varying vec2 vUv;\n\n void main() {\n vUv = uv;\n gl_Position = vec4(position, 1.0);\n }\n'; -const blurFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tInput;\n uniform vec2 dir;\n uniform vec2 res;\n\n varying vec2 vUv;\n\n void main() {\n vec4 sum = vec4(0.0);\n vec2 px = dir / res;\n\n float w[5];\n w[0] = 0.227027;\n w[1] = 0.1945946;\n w[2] = 0.1216216;\n w[3] = 0.054054;\n w[4] = 0.016216;\n\n sum += texture2D(tInput, vUv) * w[0];\n\n for (int i = 1; i < 5; i++) {\n float fi = float(i) * 3.0;\n sum += texture2D(tInput, vUv + px * fi) * w[i];\n sum += texture2D(tInput, vUv - px * fi) * w[i];\n }\n\n gl_FragColor = sum;\n }\n'; -const halftoneFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tScene;\n uniform sampler2D tGlow;\n uniform vec2 effectResolution;\n uniform vec2 logicalResolution;\n uniform float tile;\n uniform float s_3;\n uniform float s_4;\n uniform float applyToDarkAreas;\n uniform vec3 dashColor;\n uniform vec3 hoverDashColor;\n uniform float time;\n uniform float waveAmount;\n uniform float waveSpeed;\n uniform float footprintScale;\n uniform vec2 interactionUv;\n uniform vec2 interactionVelocity;\n uniform vec2 dragOffset;\n uniform float hoverHalftoneActive;\n uniform float hoverHalftonePowerShift;\n uniform float hoverHalftoneRadius;\n uniform float hoverHalftoneWidthShift;\n uniform float hoverLightStrength;\n uniform float hoverLightRadius;\n uniform float hoverFlowStrength;\n uniform float hoverFlowRadius;\n uniform float dragFlowStrength;\n uniform float cropToBounds;\n\n varying vec2 vUv;\n\n float distSegment(in vec2 p, in vec2 a, in vec2 b) {\n vec2 pa = p - a;\n vec2 ba = b - a;\n float denom = max(dot(ba, ba), 0.000001);\n float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);\n return length(pa - ba * h);\n }\n\n float lineSimpleEt(in vec2 p, in float r, in float thickness) {\n vec2 a = vec2(0.5) + vec2(-r, 0.0);\n vec2 b = vec2(0.5) + vec2(r, 0.0);\n float distToSegment = distSegment(p, a, b);\n float halfThickness = thickness * r;\n return distToSegment - halfThickness;\n }\n\n void main() {\n if (cropToBounds > 0.5) {\n vec4 boundsCheck = texture2D(tScene, vUv);\n if (boundsCheck.a < 0.01) {\n gl_FragColor = vec4(0.0);\n return;\n }\n }\n\n vec2 fragCoord =\n (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;\n float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);\n vec2 pointerPx = interactionUv * logicalResolution;\n vec2 fragDelta = fragCoord - pointerPx;\n float fragDist = length(fragDelta);\n vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);\n float velocityMagnitude = length(interactionVelocity);\n vec2 motionDir = velocityMagnitude > 0.001\n ? interactionVelocity / velocityMagnitude\n : vec2(0.0, 0.0);\n float motionBias = velocityMagnitude > 0.001\n ? dot(-radialDir, motionDir) * 0.5 + 0.5\n : 0.5;\n\n float hoverLightMask = 0.0;\n if (hoverLightStrength > 0.0) {\n float lightRadiusPx = hoverLightRadius * logicalResolution.y;\n hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);\n }\n\n float hoverHalftoneMask = 0.0;\n if (hoverHalftoneActive > 0.0) {\n float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;\n hoverHalftoneMask =\n smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *\n clamp(hoverHalftoneActive, 0.0, 1.0);\n }\n\n float hoverFlowMask = 0.0;\n if (hoverFlowStrength > 0.0) {\n float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;\n hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);\n }\n\n vec2 hoverDisplacement =\n radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +\n motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;\n vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;\n vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;\n\n float bandRow = floor(effectCoord.y / halftoneSize);\n float waveOffset =\n waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;\n effectCoord.x += waveOffset;\n\n vec2 cellIndex = floor(effectCoord / halftoneSize);\n vec2 sampleUv = clamp(\n (cellIndex + 0.5) * halftoneSize / logicalResolution,\n vec2(0.0),\n vec2(1.0)\n );\n vec2 cellUv = fract(effectCoord / halftoneSize);\n\n vec4 sceneSample = texture2D(tScene, sampleUv);\n float mask = smoothstep(0.02, 0.08, sceneSample.a);\n float localPower = clamp(\n s_3 + hoverHalftonePowerShift * hoverHalftoneMask,\n -1.5,\n 1.5\n );\n float localWidth = clamp(\n s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,\n 0.05,\n 1.4\n );\n float lightLift =\n hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;\n float toneValue =\n (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);\n if (applyToDarkAreas > 0.5) {\n toneValue = 1.0 - toneValue;\n }\n // Preserve the pre-toneTarget light-mode response by keeping the power\n // bias inside the averaged tone calculation.\n float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0);\n float bandRadius = clamp(\n toneValue + powerBias + lightLift,\n 0.0,\n 1.0\n ) * 1.86 * 0.5;\n\n float alpha = 0.0;\n if (bandRadius > 0.0001) {\n float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);\n float edge = 0.02;\n alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;\n }\n\n vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);\n vec3 color = activeDashColor * alpha;\n gl_FragColor = vec4(color, alpha);\n\n #include \n #include \n }\n'; - -const REFERENCE_PREVIEW_DISTANCE = 4; -const MIN_FOOTPRINT_SCALE = 0.001; - -function clampRectToViewport(rect, viewportWidth, viewportHeight) { - const minX = Math.max(rect.x, 0); - const minY = Math.max(rect.y, 0); - const maxX = Math.min(rect.x + rect.width, viewportWidth); - const maxY = Math.min(rect.y + rect.height, viewportHeight); - - if (maxX <= minX || maxY <= minY) { - return null; - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; -} - -function getRectArea(rect) { - if (!rect) { - return 0; - } - - return Math.max(rect.width, 0) * Math.max(rect.height, 0); -} - -function createBox3Corners(bounds) { - const { min, max } = bounds; - - return [ - new THREE.Vector3(min.x, min.y, min.z), - new THREE.Vector3(min.x, min.y, max.z), - new THREE.Vector3(min.x, max.y, min.z), - new THREE.Vector3(min.x, max.y, max.z), - new THREE.Vector3(max.x, min.y, min.z), - new THREE.Vector3(max.x, min.y, max.z), - new THREE.Vector3(max.x, max.y, min.z), - new THREE.Vector3(max.x, max.y, max.z), - ]; -} - -function getImagePreviewZoom(previewDistance) { - return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); -} - -function getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom, -}) { - if ( - imageWidth <= 0 || - imageHeight <= 0 || - viewportWidth <= 0 || - viewportHeight <= 0 - ) { - return null; - } - - const imageAspect = imageWidth / imageHeight; - const viewAspect = viewportWidth / viewportHeight; - - let fittedWidth = viewportWidth; - let fittedHeight = viewportHeight; - - if (imageAspect > viewAspect) { - fittedHeight = viewportWidth / imageAspect; - } else { - fittedWidth = viewportHeight * imageAspect; - } - - const scaledWidth = fittedWidth * zoom; - const scaledHeight = fittedHeight * zoom; - - return clampRectToViewport( - { - x: (viewportWidth - scaledWidth) * 0.5, - y: (viewportHeight - scaledHeight) * 0.5, - width: scaledWidth, - height: scaledHeight, - }, - viewportWidth, - viewportHeight, - ); -} - -function getFootprintScaleFromRects(currentRect, referenceRect) { - const currentArea = getRectArea(currentRect); - const referenceArea = getRectArea(referenceRect); - - if (currentArea <= 0 || referenceArea <= 0) { - return 1; - } - - return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE); -} - -function getImageFootprintScale({ - imageHeight, - imageWidth, - previewDistance, - viewportHeight, - viewportWidth, -}) { - const currentRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: getImagePreviewZoom(previewDistance), - }); - const referenceRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: 1, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function projectBox3ToViewport({ - camera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, -}) { - if (localBounds.isEmpty() || viewportWidth <= 0 || viewportHeight <= 0) { - return null; - } - - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - let hasProjectedCorner = false; - - for (const corner of createBox3Corners(localBounds)) { - corner.applyMatrix4(meshMatrixWorld).project(camera); - - if ( - !Number.isFinite(corner.x) || - !Number.isFinite(corner.y) || - !Number.isFinite(corner.z) - ) { - continue; - } - - hasProjectedCorner = true; - - const x = (corner.x * 0.5 + 0.5) * viewportWidth; - const y = (1 - (corner.y * 0.5 + 0.5)) * viewportHeight; - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - - if (!hasProjectedCorner) { - return null; - } - - return clampRectToViewport( - { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }, - viewportWidth, - viewportHeight, - ); -} - -function getMeshFootprintScale({ - camera, - localBounds, - lookAtTarget, - meshMatrixWorld, - viewportHeight, - viewportWidth, -}) { - const currentRect = projectBox3ToViewport({ - camera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, - }); - const referenceCamera = camera.clone(); - const currentOffset = referenceCamera.position.clone().sub(lookAtTarget); - const referenceOffset = - currentOffset.lengthSq() > 0 - ? currentOffset.setLength(REFERENCE_PREVIEW_DISTANCE) - : new THREE.Vector3(0, 0, REFERENCE_PREVIEW_DISTANCE); - - referenceCamera.position.copy(lookAtTarget).add(referenceOffset); - referenceCamera.lookAt(lookAtTarget); - referenceCamera.updateProjectionMatrix(); - referenceCamera.updateMatrixWorld(true); - - const referenceRect = projectBox3ToViewport({ - camera: referenceCamera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function makePolarShape(radiusFunction, segments = 320) { - const shape = new THREE.Shape(); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * Math.PI * 2; - const radius = radiusFunction(angle); - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius; - - if (segmentIndex === 0) { - shape.moveTo(x, y); - } else { - shape.lineTo(x, y); - } - } - - return shape; -} - -function makeReliefGeometry(shape, options = {}) { - const { - bevelSegments = 8, - bevelSize = 0.08, - bevelThickness = 0.1, - depth = 0.58, - waveDepth = 0.016, - waves = 8, - } = options; - - const geometry = new THREE.ExtrudeGeometry(shape, { - depth, - steps: 2, - bevelEnabled: true, - bevelThickness, - bevelSize, - bevelSegments, - curveSegments: 96, - }); - - geometry.center(); - - const position = geometry.attributes.position; - let maxRadius = 0; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - maxRadius = Math.max( - maxRadius, - Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), - ); - } - - const fullDepth = depth + bevelThickness * 2; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - const x = position.getX(vertexIndex); - const y = position.getY(vertexIndex); - const z = position.getZ(vertexIndex); - const radius = Math.hypot(x, y) / maxRadius; - const angle = Math.atan2(y, x); - const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); - const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); - const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); - const wave = - Math.cos(angle * waves) * - Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * - waveDepth; - const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); - - position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); - } - - position.needsUpdate = true; - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function mergeGeometries(geometries) { - if (geometries.length === 1) { - return geometries[0]; - } - - let totalVertices = 0; - let totalIndices = 0; - let hasUv = false; - - const geometryInfos = geometries.map((geometry) => { - const position = geometry.attributes.position; - const normal = geometry.attributes.normal; - const uv = geometry.attributes.uv ?? null; - const index = geometry.index; - const indexCount = index ? index.count : position.count; - - totalVertices += position.count; - totalIndices += indexCount; - hasUv = hasUv || uv !== null; - - return { - index, - indexCount, - normal, - position, - uv, - vertexCount: position.count, - }; - }); - - const positions = new Float32Array(totalVertices * 3); - const normals = new Float32Array(totalVertices * 3); - const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; - const indices = new Uint32Array(totalIndices); - - let vertexOffset = 0; - let indexOffset = 0; - - for (const geometryInfo of geometryInfos) { - for ( - let vertexIndex = 0; - vertexIndex < geometryInfo.vertexCount; - vertexIndex += 1 - ) { - const positionOffset = (vertexOffset + vertexIndex) * 3; - positions[positionOffset] = geometryInfo.position.getX(vertexIndex); - positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); - positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); - normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); - normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); - normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); - - if (uvs !== null) { - const uvOffset = (vertexOffset + vertexIndex) * 2; - uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; - uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; - } - } - - if (geometryInfo.index) { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = - geometryInfo.index.getX(localIndex) + vertexOffset; - } - } else { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = localIndex + vertexOffset; - } - } - - vertexOffset += geometryInfo.vertexCount; - indexOffset += geometryInfo.indexCount; - } - - const merged = new THREE.BufferGeometry(); - merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); - - if (uvs !== null) { - merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); - } - - merged.setIndex(new THREE.BufferAttribute(indices, 1)); - - return merged; -} - -function makeArrowTarget() { - const targetParts = []; - const arrowParts = []; - const baseRadius = 1.35; - const baseDepth = 0.32; - const bevel = 0.12; - const points = []; - const segments = 16; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - -baseDepth / 2 + bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - baseDepth / 2 - bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - targetParts.push(disc); - - for (const radius of [0.45, 0.85, 1.22]) { - const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); - ring.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), - ); - targetParts.push(ring); - } - - const bump = new THREE.SphereGeometry( - 0.32, - 32, - 24, - 0, - Math.PI * 2, - 0, - Math.PI / 2, - ); - bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); - bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); - targetParts.push(bump); - - const shaftLength = 1.5; - const shaftRadius = 0.05; - const shaft = new THREE.CylinderGeometry( - shaftRadius, - shaftRadius, - shaftLength, - 10, - 1, - ); - shaft.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0), - ); - arrowParts.push(shaft); - - const head = new THREE.ConeGeometry(0.12, 0.35, 10); - head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); - arrowParts.push(head); - - for (let finIndex = 0; finIndex < 3; finIndex += 1) { - const finShape = new THREE.Shape(); - finShape.moveTo(0, 0); - finShape.lineTo(0.22, 0.25); - finShape.lineTo(0, 0.5); - finShape.lineTo(0, 0); - - const finGeometry = new THREE.ExtrudeGeometry(finShape, { - depth: 0.012, - bevelEnabled: false, - }); - - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, 0, -0.006), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), - ); - arrowParts.push(finGeometry); - } - - const nock = new THREE.SphereGeometry(0.065, 8, 8); - nock.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0), - ); - arrowParts.push(nock); - - const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); - const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); - const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); - - for (const geometry of arrowParts) { - geometry.applyMatrix4(aim); - geometry.applyMatrix4(tilt); - geometry.applyMatrix4(shift); - } - - const merged = mergeGeometries([...targetParts, ...arrowParts]); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function makeDollarCoin() { - const parts = []; - const baseRadius = 1.3; - const baseDepth = 0.45; - const bevel = 0.18; - const points = []; - const segments = 20; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - Math.sin(angle) * (baseDepth / 2), - ), - ); - } - - points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - parts.push(disc); - - const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - frontRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), - ); - parts.push(frontRim); - - const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - backRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), - ); - parts.push(backRim); - - const createDollarSign = () => { - const geometries = []; - const tubeRadius = 0.1; - const curveRadius = 0.28; - const verticalOffset = 0.22; - - const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); - geometries.push(bar); - - const topArc = new THREE.TorusGeometry( - curveRadius, - tubeRadius, - 16, - 32, - Math.PI, - ); - topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), - ); - geometries.push(topArc); - - const bottomArc = new THREE.TorusGeometry( - curveRadius, - tubeRadius, - 16, - 32, - Math.PI, - ); - bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); - bottomArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), - ); - geometries.push(bottomArc); - - const topSerif = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - 0.22, - 12, - ); - topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation( - 0.16, - verticalOffset + curveRadius, - 0, - ), - ); - geometries.push(topSerif); - - const bottomSerif = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - 0.22, - 12, - ); - bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - bottomSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation( - -0.16, - -verticalOffset - curveRadius, - 0, - ), - ); - geometries.push(bottomSerif); - - const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); - const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); - const diagonal = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - diagonalLength + 0.12, - 12, - ); - diagonal.applyMatrix4( - new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), - ); - geometries.push(diagonal); - - return geometries; - }; - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), - ); - parts.push(geometry); - } - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), - ); - parts.push(geometry); - } - - const merged = mergeGeometries(parts); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function createBuiltinGeometry(shapeKey) { - switch (shapeKey) { - case 'torusKnot': - return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); - case 'sphere': - return new THREE.SphereGeometry(1.4, 64, 64); - case 'torus': - return new THREE.TorusGeometry(1, 0.45, 64, 100); - case 'icosahedron': - return new THREE.IcosahedronGeometry(1.4, 4); - case 'box': - return new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6); - case 'cone': - return new THREE.ConeGeometry(1.2, 2.4, 64, 10); - case 'cylinder': - return new THREE.CylinderGeometry(1, 1, 2.3, 64, 10); - case 'octahedron': - return new THREE.OctahedronGeometry(1.5, 2); - case 'dodecahedron': - return new THREE.DodecahedronGeometry(1.35, 1); - case 'tetrahedron': - return new THREE.TetrahedronGeometry(1.7, 1); - case 'sunCoin': - return makeReliefGeometry( - makePolarShape( - (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), - ), - { depth: 0.62, waves: 12, waveDepth: 0.018 }, - ); - case 'lotusCoin': - return makeReliefGeometry( - makePolarShape( - (angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2), - ), - { depth: 0.64, waves: 8, waveDepth: 0.014 }, - ); - case 'arrowTarget': - return makeArrowTarget(); - case 'dollarCoin': - return makeDollarCoin(); - default: - return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); - } -} - -const EMPTY_TEXTURE_DATA_URL = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; - -function createLoadingManager() { - const loadingManager = new THREE.LoadingManager(); - loadingManager.setURLModifier((url) => - /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, - ); - return loadingManager; -} - -function normalizeImportedGeometry(geometry) { - geometry.computeBoundingBox(); - - let boundingBox = geometry.boundingBox; - let center = new THREE.Vector3(); - let size = new THREE.Vector3(); - - boundingBox?.getCenter(center); - boundingBox?.getSize(size); - geometry.translate(-center.x, -center.y, -center.z); - - const dimensions = [size.x, size.y, size.z]; - const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); - - if (thinnestAxis === 0) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); - } else if (thinnestAxis === 1) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - } - - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - const radius = geometry.boundingSphere?.radius || 1; - const scale = 1.6 / radius; - geometry.scale(scale, scale, scale); - - geometry.computeBoundingBox(); - boundingBox = geometry.boundingBox; - center = new THREE.Vector3(); - boundingBox?.getCenter(center); - geometry.translate(-center.x, -center.y, -center.z); - - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function extractMergedGeometry(root, emptyMessage) { - root.updateMatrixWorld(true); - const geometries = []; - - root.traverse((object) => { - if (!(object instanceof THREE.Mesh) || !object.geometry) { - return; - } - - const geometry = object.geometry.clone(); - - if (!geometry.attributes.normal) { - geometry.computeVertexNormals(); - } - - geometry.applyMatrix4(object.matrixWorld); - geometries.push(geometry); - }); - - if (geometries.length === 0) { - throw new Error(emptyMessage); - } - - return normalizeImportedGeometry(mergeGeometries(geometries)); -} - -function parseGlbGeometry(buffer, label) { - return new Promise((resolve, reject) => { - const loadingManager = createLoadingManager(); - const dracoLoader = new DRACOLoader(loadingManager); - dracoLoader.setDecoderPath(DRACO_DECODER_PATH); - - const loader = new GLTFLoader(loadingManager); - loader.setDRACOLoader(dracoLoader); - - const cleanup = () => { - dracoLoader.dispose(); - }; - - loader.parse( - buffer, - '', - (gltf) => { - try { - resolve( - extractMergedGeometry( - gltf.scene, - label + ' did not contain any mesh geometry.', - ), - ); - } catch (error) { - reject(error); - } finally { - cleanup(); - } - }, - (error) => { - cleanup(); - reject(error); - }, - ); - }); -} - -async function loadImportedGeometryFromUrl(modelUrl, label) { - const response = await fetch(modelUrl); - - if (!response.ok) { - throw new Error('Unable to load ' + label + ' from ' + modelUrl + '.'); - } - - const buffer = await response.arrayBuffer(); - - return parseGlbGeometry(buffer, label); -} - -const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320; -const GLASS_ATTENUATION_DISTANCE_MIN = 0.12; -const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18; -const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12; -const GLASS_ENVIRONMENT_ZOOM = 1.55; -const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303); -const MAX_TEXTURE_ANISOTROPY = 8; -const HALFTONE_TRANSMISSION_SHADER_PREFIX = - '\nuniform float chromaticAberration;\nuniform float anisotropicBlur;\nuniform float time;\nuniform float distortion;\nuniform float distortionScale;\nuniform float temporalDistortion;\nuniform sampler2D buffer;\n\nvec3 random3(vec3 c) {\n float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0)));\n vec3 r;\n r.z = fract(512.0 * j);\n j *= 0.125;\n r.x = fract(512.0 * j);\n j *= 0.125;\n r.y = fract(512.0 * j);\n return r - 0.5;\n}\n\nuint hash(uint x) {\n x += (x << 10u);\n x ^= (x >> 6u);\n x += (x << 3u);\n x ^= (x >> 11u);\n x += (x << 15u);\n return x;\n}\n\nuint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); }\nuint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); }\nuint hash(uvec4 v) {\n return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w));\n}\n\nfloat floatConstruct(uint m) {\n const uint ieeeMantissa = 0x007FFFFFu;\n const uint ieeeOne = 0x3F800000u;\n m &= ieeeMantissa;\n m |= ieeeOne;\n float f = uintBitsToFloat(m);\n return f - 1.0;\n}\n\nfloat randomBase(float x) {\n return floatConstruct(hash(floatBitsToUint(x)));\n}\nfloat randomBase(vec2 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\nfloat randomBase(vec3 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\nfloat randomBase(vec4 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\n\nfloat rand(float seed) {\n return randomBase(vec3(gl_FragCoord.xy, seed));\n}\n\nconst float F3 = 0.3333333;\nconst float G3 = 0.1666667;\n\nfloat snoise(vec3 p) {\n vec3 s = floor(p + dot(p, vec3(F3)));\n vec3 x = p - s + dot(s, vec3(G3));\n vec3 e = step(vec3(0.0), x - x.yzx);\n vec3 i1 = e * (1.0 - e.zxy);\n vec3 i2 = 1.0 - e.zxy * (1.0 - e);\n vec3 x1 = x - i1 + G3;\n vec3 x2 = x - i2 + 2.0 * G3;\n vec3 x3 = x - 1.0 + 3.0 * G3;\n vec4 w;\n vec4 d;\n w.x = dot(x, x);\n w.y = dot(x1, x1);\n w.z = dot(x2, x2);\n w.w = dot(x3, x3);\n w = max(0.6 - w, 0.0);\n d.x = dot(random3(s), x);\n d.y = dot(random3(s + i1), x1);\n d.z = dot(random3(s + i2), x2);\n d.w = dot(random3(s + 1.0), x3);\n w *= w;\n w *= w;\n d *= w;\n return dot(d, vec4(52.0));\n}\n\nfloat snoiseFractal(vec3 m) {\n return 0.5333333 * snoise(m)\n + 0.2666667 * snoise(2.0 * m)\n + 0.1333333 * snoise(4.0 * m)\n + 0.0666667 * snoise(8.0 * m);\n}\n'; -const HALFTONE_TRANSMISSION_PARS_FRAGMENT = - '\n#ifdef USE_TRANSMISSION\n uniform float _transmission;\n uniform float thickness;\n uniform float attenuationDistance;\n uniform vec3 attenuationColor;\n uniform sampler2D refractionEnvMap;\n uniform float useEnvMapRefraction;\n #ifdef USE_TRANSMISSIONMAP\n uniform sampler2D transmissionMap;\n #endif\n #ifdef USE_THICKNESSMAP\n uniform sampler2D thicknessMap;\n #endif\n uniform vec2 transmissionSamplerSize;\n uniform sampler2D transmissionSamplerMap;\n uniform mat4 modelMatrix;\n uniform mat4 projectionMatrix;\n varying vec3 vWorldPosition;\n\n vec3 getVolumeTransmissionRay(\n const in vec3 n,\n const in vec3 v,\n const in float thicknessValue,\n const in float ior,\n const in mat4 modelMatrix\n ) {\n vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior);\n vec3 modelScale;\n modelScale.x = length(vec3(modelMatrix[0].xyz));\n modelScale.y = length(vec3(modelMatrix[1].xyz));\n modelScale.z = length(vec3(modelMatrix[2].xyz));\n return normalize(refractionVector) * thicknessValue * modelScale;\n }\n\n float applyIorToRoughness(\n const in float roughnessValue,\n const in float ior\n ) {\n return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0);\n }\n\n vec2 directionToEquirectUv(const in vec3 direction) {\n vec3 dir = normalize(direction);\n vec2 uv = vec2(\n atan(dir.z, dir.x) * 0.15915494309189535 + 0.5,\n asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5\n );\n\n return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0));\n }\n\n vec4 getTransmissionSample(\n const in vec2 fragCoord,\n const in vec3 transmissionDirection,\n const in float roughnessValue,\n const in float ior\n ) {\n if (useEnvMapRefraction > 0.5) {\n return texture2D(\n refractionEnvMap,\n directionToEquirectUv(transmissionDirection)\n );\n }\n\n float framebufferLod =\n log2(transmissionSamplerSize.x) *\n applyIorToRoughness(roughnessValue, ior);\n return texture2D(buffer, fragCoord.xy);\n }\n\n vec3 applyVolumeAttenuation(\n const in vec3 radiance,\n const in float transmissionDistance,\n const in vec3 attenuationColorValue,\n const in float attenuationDistanceValue\n ) {\n if (isinf(attenuationDistanceValue)) {\n return radiance;\n }\n\n vec3 attenuationCoefficient =\n -log(attenuationColorValue) / attenuationDistanceValue;\n vec3 transmittance =\n exp(-attenuationCoefficient * transmissionDistance);\n\n return transmittance * radiance;\n }\n\n vec4 getIBLVolumeRefraction(\n const in vec3 n,\n const in vec3 v,\n const in float roughnessValue,\n const in vec3 diffuseColor,\n const in vec3 specularColor,\n const in float specularF90,\n const in vec3 position,\n const in mat4 modelMatrix,\n const in mat4 viewMatrix,\n const in mat4 projMatrix,\n const in float ior,\n const in float thicknessValue,\n const in vec3 attenuationColorValue,\n const in float attenuationDistanceValue\n ) {\n vec3 transmissionRay = getVolumeTransmissionRay(\n n,\n v,\n thicknessValue,\n ior,\n modelMatrix\n );\n vec3 refractedRayExit = position + transmissionRay;\n vec4 ndcPos =\n projMatrix * viewMatrix * vec4(refractedRayExit, 1.0);\n vec2 refractionCoords = ndcPos.xy / ndcPos.w;\n refractionCoords += 1.0;\n refractionCoords /= 2.0;\n vec3 transmissionDirection = normalize(transmissionRay);\n vec4 transmittedLight = getTransmissionSample(\n refractionCoords,\n transmissionDirection,\n roughnessValue,\n ior\n );\n vec3 attenuatedColor = applyVolumeAttenuation(\n transmittedLight.rgb,\n length(transmissionRay),\n attenuationColorValue,\n attenuationDistanceValue\n );\n vec3 F = EnvironmentBRDF(\n n,\n v,\n specularColor,\n specularF90,\n roughnessValue\n );\n return vec4(\n (1.0 - F) * attenuatedColor * diffuseColor,\n transmittedLight.a\n );\n }\n#endif\n'; -const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = - '\nmaterial.transmission = _transmission;\nmaterial.transmissionAlpha = 1.0;\nmaterial.thickness = thickness;\nmaterial.attenuationDistance = attenuationDistance;\nmaterial.attenuationColor = attenuationColor;\n#ifdef USE_TRANSMISSIONMAP\n material.transmission *= texture2D(transmissionMap, vUv).r;\n#endif\n#ifdef USE_THICKNESSMAP\n material.thickness *= texture2D(thicknessMap, vUv).g;\n#endif\n\nvec3 pos = vWorldPosition;\nfloat runningSeed = 0.0;\nvec3 v = normalize(cameraPosition - pos);\nvec3 n = inverseTransformDirection(normal, viewMatrix);\nvec3 transmission = vec3(0.0);\nfloat transmissionR;\nfloat transmissionG;\nfloat transmissionB;\nfloat randomCoords = rand(runningSeed++);\nfloat thicknessSmear =\n thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur);\nvec3 distortionNormal = vec3(0.0);\nvec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion;\n\nif (distortion > 0.0) {\n distortionNormal = distortion * vec3(\n snoiseFractal(vec3(pos * distortionScale + temporalOffset)),\n snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)),\n snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset))\n );\n}\n\nfor (float i = 0.0; i < __SAMPLES__.0; i++) {\n vec3 sampleNorm = normalize(\n n +\n roughnessFactor * roughnessFactor * 2.0 *\n normalize(\n vec3(\n rand(runningSeed++) - 0.5,\n rand(runningSeed++) - 0.5,\n rand(runningSeed++) - 0.5\n )\n ) *\n pow(rand(runningSeed++), 0.33) +\n distortionNormal\n );\n\n transmissionR = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior,\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).r;\n\n transmissionG = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).g;\n\n transmissionB = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).b;\n\n transmission.r += transmissionR;\n transmission.g += transmissionG;\n transmission.b += transmissionB;\n}\n\ntransmission /= __SAMPLES__.0;\ntotalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission);\n'; - -class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial { - constructor(samples = 10) { - super(); - - this.halftoneUniforms = { - chromaticAberration: { value: 0.05 }, - transmission: { value: 0 }, - _transmission: { value: 1 }, - transmissionMap: { value: null }, - refractionEnvMap: { value: null }, - useEnvMapRefraction: { value: 0 }, - roughness: { value: 0 }, - thickness: { value: 0 }, - thicknessMap: { value: null }, - attenuationDistance: { value: Infinity }, - attenuationColor: { value: new THREE.Color('white') }, - anisotropicBlur: { value: 0.1 }, - time: { value: 0 }, - distortion: { value: 0 }, - distortionScale: { value: 0.5 }, - temporalDistortion: { value: 0 }, - buffer: { value: null }, - }; - - this.customProgramCacheKey = () => 'halftone-transmission-' + samples; - - this.onBeforeCompile = (shader) => { - shader.uniforms = { - ...shader.uniforms, - ...this.halftoneUniforms, - }; - shader.defines ??= {}; - - if (this.anisotropy > 0) { - shader.defines.USE_ANISOTROPY = ''; - } - - shader.defines.USE_TRANSMISSION = ''; - shader.fragmentShader = - HALFTONE_TRANSMISSION_SHADER_PREFIX + shader.fragmentShader; - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - HALFTONE_TRANSMISSION_PARS_FRAGMENT, - ); - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE.replaceAll( - '__SAMPLES__', - String(samples), - ), - ); - }; - - Object.keys(this.halftoneUniforms).forEach((key) => { - Object.defineProperty(this, key, { - configurable: true, - enumerable: true, - get: () => this.halftoneUniforms[key]?.value, - set: (value) => { - this.halftoneUniforms[key].value = value; - }, - }); - }); - } -} - -function setTextureSampling(texture, renderer) { - texture.generateMipmaps = true; - texture.magFilter = THREE.LinearFilter; - texture.minFilter = THREE.LinearMipmapLinearFilter; - texture.anisotropy = Math.min( - renderer.capabilities.getMaxAnisotropy(), - MAX_TEXTURE_ANISOTROPY, - ); -} - -function disposeEnvironmentScene(scene) { - scene.traverse((object) => { - if (object.geometry) { - object.geometry.dispose(); - } - - if (Array.isArray(object.material)) { - object.material.forEach((material) => material.dispose()); - return; - } - - object.material?.dispose?.(); - }); -} - -function createSolidEnvironmentTexture(renderer) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = pmremGenerator.fromScene( - new RoomEnvironment(), - 0.04, - ).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function getTextureImageSize(texture) { - const image = texture.image; - - return { - height: - image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined, - width: - image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined, - }; -} - -function createZoomedGlassTexture(sourceTexture, renderer, zoom) { - if (zoom <= 1) { - return sourceTexture; - } - - const { width, height } = getTextureImageSize(sourceTexture); - - if (!width || !height) { - return sourceTexture; - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext('2d'); - - if (!context) { - return sourceTexture; - } - - const cropWidth = width / zoom; - const cropHeight = height / zoom; - const sourceX = (width - cropWidth) / 2; - const sourceY = (height - cropHeight) / 2; - - context.drawImage( - sourceTexture.image, - sourceX, - sourceY, - cropWidth, - cropHeight, - 0, - 0, - width, - height, - ); - - const zoomedTexture = new THREE.CanvasTexture(canvas); - zoomedTexture.colorSpace = sourceTexture.colorSpace; - zoomedTexture.wrapS = THREE.ClampToEdgeWrapping; - zoomedTexture.wrapT = THREE.ClampToEdgeWrapping; - setTextureSampling(zoomedTexture, renderer); - zoomedTexture.needsUpdate = true; - - return zoomedTexture; -} - -function createStudioGlassEnvironmentTexture(renderer, backdropTexture) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = backdropTexture - ? pmremGenerator.fromEquirectangular(backdropTexture).texture - : pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function createFallbackGlassBackdropTexture(renderer) { - const texture = new THREE.DataTexture( - new Uint8Array([3, 3, 3, 255]), - 1, - 1, - THREE.RGBAFormat, - ); - texture.colorSpace = THREE.SRGBColorSpace; - texture.wrapS = THREE.ClampToEdgeWrapping; - texture.wrapT = THREE.ClampToEdgeWrapping; - texture.mapping = THREE.EquirectangularReflectionMapping; - setTextureSampling(texture, renderer); - texture.needsUpdate = true; - - return texture; -} - -function loadTexture(url, renderer, colorSpace) { - const loader = new THREE.TextureLoader(); - - return new Promise((resolve, reject) => { - loader.load( - url, - (texture) => { - texture.colorSpace = colorSpace; - setTextureSampling(texture, renderer); - resolve(texture); - }, - undefined, - reject, - ); - }); -} - -async function loadGlassEnvironmentTexture(renderer) { - const sourceBackgroundTexture = await loadTexture( - GLASS_ENVIRONMENT_TEXTURE_URL, - renderer, - THREE.SRGBColorSpace, - ); - const backgroundTexture = createZoomedGlassTexture( - sourceBackgroundTexture, - renderer, - GLASS_ENVIRONMENT_ZOOM, - ); - if (backgroundTexture !== sourceBackgroundTexture) { - sourceBackgroundTexture.dispose(); - } - backgroundTexture.mapping = THREE.EquirectangularReflectionMapping; - backgroundTexture.wrapS = THREE.ClampToEdgeWrapping; - backgroundTexture.wrapT = THREE.ClampToEdgeWrapping; - backgroundTexture.needsUpdate = true; - const environmentTexture = createStudioGlassEnvironmentTexture( - renderer, - backgroundTexture, - ); - - return { - backgroundTexture, - environmentTexture, - }; -} - -async function createHalftoneMaterialAssets(renderer) { - const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer); - - try { - const glassEnvironmentAssets = await loadGlassEnvironmentTexture(renderer); - - return { - glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture, - glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture, - solidEnvironmentTexture, - }; - } catch { - const fallbackGlassBackdropTexture = - createFallbackGlassBackdropTexture(renderer); - const fallbackGlassEnvironmentTexture = - createStudioGlassEnvironmentTexture(renderer); - - return { - glassBackgroundTexture: fallbackGlassBackdropTexture, - glassEnvironmentTexture: fallbackGlassEnvironmentTexture, - solidEnvironmentTexture, - }; - } -} - -function createHalftoneMaterial() { - return new HalftoneTransmissionMaterial(); -} - -function applyHalftoneMaterialSettings( - material, - materialSettings, - materialAssets, -) { - const isGlass = materialSettings.surface === 'glass'; - const glassThickness = - materialSettings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS; - const glassEnvironmentIntensity = - GLASS_ENVIRONMENT_INTENSITY_BASE + - materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER; - const glassAttenuationDistance = Math.max( - glassThickness * 4, - GLASS_ATTENUATION_DISTANCE_MIN, - ); - - material.color.set(isGlass ? '#ffffff' : materialSettings.color); - material.roughness = materialSettings.roughness; - material.metalness = materialSettings.metalness; - material.envMap = isGlass - ? materialAssets.glassEnvironmentTexture - : materialAssets.solidEnvironmentTexture; - material.envMapIntensity = isGlass - ? GLASS_ENVIRONMENT_INTENSITY_BASE + - materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER - : 0.25; - material.clearcoat = isGlass ? 1 : 0; - material.clearcoatRoughness = isGlass - ? Math.max(materialSettings.roughness * 0.25, 0.01) - : 0.08; - material.reflectivity = isGlass ? 0.98 : 0.5; - material.transmission = 0; - material._transmission = isGlass ? 1 : 0; - material.refractionEnvMap = isGlass - ? materialAssets.glassBackgroundTexture - : null; - material.useEnvMapRefraction = isGlass ? 1 : 0; - material.thickness = isGlass ? glassThickness : 0; - material.ior = isGlass ? materialSettings.refraction : 1.5; - material.buffer = null; - material.bumpMap = null; - material.bumpScale = 0; - material.roughnessMap = null; - material.side = THREE.FrontSide; - material.transparent = false; - material.opacity = 1; - material.depthWrite = true; - material.attenuationColor.set(isGlass ? materialSettings.color : 'white'); - material.attenuationDistance = isGlass ? glassAttenuationDistance : Infinity; - material.anisotropicBlur = isGlass - ? THREE.MathUtils.lerp(0.03, 0.12, materialSettings.roughness) - : 0.1; - material.chromaticAberration = isGlass ? 0 : 0.05; - material.distortion = 0; - material.distortionScale = 0.5; - material.temporalDistortion = 0; - material.userData.halftoneIsGlass = isGlass; - material.userData.halftoneGlassBacksideThickness = isGlass - ? glassThickness * 2 - : 0; - material.userData.halftoneGlassBacksideEnvIntensity = isGlass - ? glassEnvironmentIntensity * 2.8 - : 0; - material.userData.halftoneUseEnvironmentRefraction = isGlass; - material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25; - - material.needsUpdate = true; -} - -function disposeHalftoneMaterialAssets(materialAssets) { - materialAssets.glassBackgroundTexture.dispose(); - - if ( - materialAssets.glassEnvironmentTexture !== - materialAssets.glassBackgroundTexture - ) { - materialAssets.glassEnvironmentTexture.dispose(); - } - - materialAssets.solidEnvironmentTexture.dispose(); -} - -function createRenderTarget(width, height) { - return new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); -} - -function createInteractionState() { - return { - autoElapsed: initialPose.autoElapsed, - activePointerId: null, - dragging: false, - hoverStrength: 0, - mouseX: 0.5, - mouseY: 0.5, - pointerInside: false, - pointerVelocityX: 0, - pointerVelocityY: 0, - pointerX: 0, - pointerY: 0, - rotateElapsed: initialPose.rotateElapsed, - rotationX: initialPose.rotationX, - rotationVelocityX: 0, - rotationY: initialPose.rotationY, - rotationVelocityY: 0, - rotationZ: initialPose.rotationZ, - rotationVelocityZ: 0, - smoothedMouseX: 0.5, - smoothedMouseY: 0.5, - targetRotationX: initialPose.targetRotationX, - targetRotationY: initialPose.targetRotationY, - velocityX: 0, - velocityY: 0, - }; -} - -function setPrimaryLightPosition(light, angleDegrees, height) { - const lightAngle = (angleDegrees * Math.PI) / 180; - light.position.set( - Math.cos(lightAngle) * 5, - height, - Math.sin(lightAngle) * 5, - ); -} - -function applySpringStep(current, target, velocity, strength, damping) { - const nextVelocity = (velocity + (target - current) * strength) * damping; - const nextValue = current + nextVelocity; - - return { - value: nextValue, - velocity: nextVelocity, - }; -} - -function resetInteractionState(interactionState) { - interactionState.dragging = false; - interactionState.mouseX = 0.5; - interactionState.mouseY = 0.5; - interactionState.targetRotationX = 0; - interactionState.targetRotationY = 0; - interactionState.velocityX = 0; - interactionState.velocityY = 0; - interactionState.rotationVelocityX = 0; - interactionState.rotationVelocityY = 0; - interactionState.rotationVelocityZ = 0; - interactionState.autoElapsed = 0; -} - -async function createGeometry(modelUrl) { - if (shape.kind === 'imported') { - if (!modelUrl) { - throw new Error('No model URL was provided for ' + shape.label + '.'); - } - - return loadImportedGeometryFromUrl(modelUrl, shape.label); - } - - return createBuiltinGeometry(shape.key); -} - -async function mountHalftoneCanvas(options) { - const { container, modelUrl, onError } = options; - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))), - 1, - ); - - let geometry; - - try { - geometry = await createGeometry(modelUrl); - } catch (error) { - onError?.(error); - return () => {}; - } - - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor(0x000000, 0); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.cursor = settings.animation.followDragEnabled - ? 'grab' - : 'default'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const materialAssets = await createHalftoneMaterialAssets(renderer); - - const scene3d = new THREE.Scene(); - scene3d.background = null; - - const baseCameraDistance = previewDistance; - const camera = new THREE.PerspectiveCamera( - 45, - getWidth() / getHeight(), - 0.1, - 100, - ); - camera.position.z = baseCameraDistance; - - const primaryLight = new THREE.DirectionalLight( - 0xffffff, - settings.lighting.intensity, - ); - setPrimaryLightPosition( - primaryLight, - settings.lighting.angleDegrees, - settings.lighting.height, - ); - scene3d.add(primaryLight); - - const fillLight = new THREE.DirectionalLight( - 0xffffff, - settings.lighting.fillIntensity, - ); - fillLight.position.set(-3, -1, 1); - scene3d.add(fillLight); - - const ambientLight = new THREE.AmbientLight( - 0xffffff, - settings.lighting.ambientIntensity, - ); - scene3d.add(ambientLight); - - const material = createHalftoneMaterial(); - applyHalftoneMaterialSettings(material, settings.material, materialAssets); - - const mesh = new THREE.Mesh(geometry, material); - scene3d.add(mesh); - - const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - effectResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - logicalResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - tile: { value: settings.halftone.scale }, - s_3: { value: settings.halftone.power }, - s_4: { value: settings.halftone.width }, - applyToDarkAreas: { - value: settings.halftone.toneTarget === 'dark' ? 1 : 0, - }, - dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, - hoverDashColor: { - value: new THREE.Color(settings.halftone.hoverDashColor), - }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: 1 }, - footprintScale: { value: 1.0 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - interactionVelocity: { value: new THREE.Vector2(0, 0) }, - dragOffset: { value: new THREE.Vector2(0, 0) }, - hoverHalftoneActive: { value: 0 }, - hoverHalftonePowerShift: { value: 0 }, - hoverHalftoneRadius: { value: 0.2 }, - hoverHalftoneWidthShift: { value: 0 }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: 0.2 }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - cropToBounds: { value: 0 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add( - new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), - ); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const updateViewportUniforms = ( - logicalWidth, - logicalHeight, - effectWidth, - effectHeight, - ) => { - blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - halftoneMaterial.uniforms.effectResolution.value.set( - effectWidth, - effectHeight, - ); - halftoneMaterial.uniforms.logicalResolution.value.set( - logicalWidth, - logicalHeight, - ); - }; - - const getHalftoneScale = (viewportWidth, viewportHeight, lookAtTarget) => { - if (!mesh.geometry.boundingBox) { - mesh.geometry.computeBoundingBox(); - } - - if (!mesh.geometry.boundingBox) { - return 1; - } - - mesh.updateMatrixWorld(); - camera.updateMatrixWorld(); - - return getMeshFootprintScale({ - camera, - localBounds: mesh.geometry.boundingBox, - lookAtTarget, - meshMatrixWorld: mesh.matrixWorld, - viewportHeight, - viewportWidth, - }); - }; - - const interaction = createInteractionState(); - const autoRotateEnabled = settings.animation.autoRotateEnabled; - const followHoverEnabled = settings.animation.followHoverEnabled; - const followDragEnabled = settings.animation.followDragEnabled; - const rotateEnabled = settings.animation.rotateEnabled; - - const syncSize = () => { - const width = getWidth(); - const height = getHeight(); - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - updateViewportUniforms( - virtualWidth, - virtualHeight, - virtualWidth, - virtualHeight, - ); - }; - - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); - - const updatePointerPosition = (event) => { - const rect = canvas.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - - interaction.mouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, - 0, - 1, - ); - interaction.mouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, - 0, - 1, - ); - }; - - const handlePointerDown = (event) => { - updatePointerPosition(event); - if (!followDragEnabled) { - return; - } - - interaction.dragging = true; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - interaction.velocityX = 0; - interaction.velocityY = 0; - canvas.style.cursor = 'grabbing'; - }; - - const handlePointerMove = (event) => { - updatePointerPosition(event); - }; - - const handleWindowPointerMove = (event) => { - updatePointerPosition(event); - - if (!interaction.dragging || !followDragEnabled) { - return; - } - - const deltaX = - (event.clientX - interaction.pointerX) * settings.animation.dragSens; - const deltaY = - (event.clientY - interaction.pointerY) * settings.animation.dragSens; - interaction.velocityX = deltaY; - interaction.velocityY = deltaX; - interaction.targetRotationY += deltaX; - interaction.targetRotationX += deltaY; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - }; - - const handlePointerLeave = () => { - if (interaction.dragging) { - return; - } - - interaction.mouseX = 0.5; - interaction.mouseY = 0.5; - }; - - const handlePointerUp = () => { - interaction.dragging = false; - canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; - - if (!settings.animation.springReturnEnabled) { - return; - } - - const springImpulse = Math.max(settings.animation.springStrength * 10, 1.2); - interaction.rotationVelocityX += interaction.velocityX * springImpulse; - interaction.rotationVelocityY += interaction.velocityY * springImpulse; - interaction.rotationVelocityZ += - interaction.velocityY * springImpulse * 0.12; - interaction.targetRotationX = 0; - interaction.targetRotationY = 0; - interaction.velocityX = 0; - interaction.velocityY = 0; - }; - - const handlePointerCancel = () => { - interaction.dragging = false; - interaction.velocityX = 0; - interaction.velocityY = 0; - canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; - handlePointerLeave(); - }; - - const handleWindowBlur = () => { - handlePointerUp(); - handlePointerLeave(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointercancel', handlePointerCancel); - window.addEventListener('pointerup', handlePointerUp); - window.addEventListener('pointermove', handleWindowPointerMove); - window.addEventListener('blur', handleWindowBlur); - canvas.addEventListener('pointerdown', handlePointerDown); - - const clock = new THREE.Timer(); - clock.connect(document); - let animationFrameId = 0; - - const renderFrame = (timestamp) => { - animationFrameId = window.requestAnimationFrame(renderFrame); - clock.update(timestamp); - - const delta = 1 / 60; - const elapsedTime = initialPose.timeElapsed + clock.getElapsed(); - halftoneMaterial.uniforms.time.value = elapsedTime; - - let baseRotationX = initialPose.rotationX; - let baseRotationY = initialPose.rotationY; - let baseRotationZ = initialPose.rotationZ; - let meshOffsetY = 0; - let meshScale = 1; - let lightAngle = settings.lighting.angleDegrees; - let lightHeight = settings.lighting.height; - - if (autoRotateEnabled) { - interaction.autoElapsed += delta; - baseRotationY += interaction.autoElapsed * settings.animation.autoSpeed; - baseRotationX += - Math.sin(interaction.autoElapsed * 0.2) * settings.animation.autoWobble; - } - - if (settings.animation.floatEnabled) { - const floatPhase = elapsedTime * settings.animation.floatSpeed; - const driftAmount = (settings.animation.driftAmount * Math.PI) / 180; - - meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude; - baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45; - baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3; - } - - if (settings.animation.breatheEnabled) { - meshScale *= - 1 + - Math.sin(elapsedTime * settings.animation.breatheSpeed) * - settings.animation.breatheAmount; - } - - if (rotateEnabled) { - interaction.rotateElapsed += delta; - const rotateProgress = settings.animation.rotatePingPong - ? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) * - Math.PI - : interaction.rotateElapsed * settings.animation.rotateSpeed; - - if (settings.animation.rotatePreset === 'axis') { - const axisDirection = settings.animation.rotateAxis.startsWith('-') - ? -1 - : 1; - const axisProgress = rotateProgress * axisDirection; - - if ( - settings.animation.rotateAxis === 'x' || - settings.animation.rotateAxis === 'xy' || - settings.animation.rotateAxis === '-x' || - settings.animation.rotateAxis === '-xy' - ) { - baseRotationX += axisProgress; - } - - if ( - settings.animation.rotateAxis === 'y' || - settings.animation.rotateAxis === 'xy' || - settings.animation.rotateAxis === '-y' || - settings.animation.rotateAxis === '-xy' - ) { - baseRotationY += axisProgress; - } - - if ( - settings.animation.rotateAxis === 'z' || - settings.animation.rotateAxis === '-z' - ) { - baseRotationZ += axisProgress; - } - } else if (settings.animation.rotatePreset === 'lissajous') { - baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65; - baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05; - baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32; - } else if (settings.animation.rotatePreset === 'orbit') { - baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42; - baseRotationY += Math.cos(rotateProgress) * 1.2; - baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24; - } else if (settings.animation.rotatePreset === 'tumble') { - baseRotationX += rotateProgress * 0.55; - baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9; - baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38; - } - } - - if (settings.animation.lightSweepEnabled) { - const lightPhase = elapsedTime * settings.animation.lightSweepSpeed; - lightAngle += Math.sin(lightPhase) * settings.animation.lightSweepRange; - lightHeight += - Math.cos(lightPhase * 0.85) * settings.animation.lightSweepHeightRange; - } - - let targetX = baseRotationX; - let targetY = baseRotationY; - let easing = 0.12; - - if (followHoverEnabled) { - const rangeRadians = (settings.animation.hoverRange * Math.PI) / 180; - - if ( - settings.animation.hoverReturn || - interaction.mouseX !== 0.5 || - interaction.mouseY !== 0.5 - ) { - targetX += (interaction.mouseY - 0.5) * rangeRadians; - targetY += (interaction.mouseX - 0.5) * rangeRadians; - } - - easing = settings.animation.hoverEase; - } - - if (followDragEnabled) { - if (!interaction.dragging && settings.animation.dragMomentum) { - interaction.targetRotationX += interaction.velocityX; - interaction.targetRotationY += interaction.velocityY; - interaction.velocityX *= 1 - settings.animation.dragFriction; - interaction.velocityY *= 1 - settings.animation.dragFriction; - } - - targetX += interaction.targetRotationX; - targetY += interaction.targetRotationY; - easing = settings.animation.dragFriction; - } - - if (autoRotateEnabled && !followHoverEnabled && !followDragEnabled) { - targetX = baseRotationX + interaction.targetRotationX; - targetY = baseRotationY + interaction.targetRotationY; - - if (interaction.dragging) { - targetX = interaction.targetRotationX; - targetY = interaction.targetRotationY; - } - - easing = 0.08; - } - - if (settings.animation.springReturnEnabled) { - const springX = applySpringStep( - interaction.rotationX, - targetX, - interaction.rotationVelocityX, - settings.animation.springStrength, - settings.animation.springDamping, - ); - const springY = applySpringStep( - interaction.rotationY, - targetY, - interaction.rotationVelocityY, - settings.animation.springStrength, - settings.animation.springDamping, - ); - const springZ = applySpringStep( - interaction.rotationZ, - baseRotationZ, - interaction.rotationVelocityZ, - settings.animation.springStrength, - settings.animation.springDamping, - ); - - interaction.rotationX = springX.value; - interaction.rotationY = springY.value; - interaction.rotationZ = springZ.value; - interaction.rotationVelocityX = springX.velocity; - interaction.rotationVelocityY = springY.velocity; - interaction.rotationVelocityZ = springZ.velocity; - } else { - interaction.rotationX += (targetX - interaction.rotationX) * easing; - interaction.rotationY += (targetY - interaction.rotationY) * easing; - interaction.rotationZ += - (baseRotationZ - interaction.rotationZ) * - (settings.animation.rotatePingPong ? 0.18 : 0.12); - } - - mesh.rotation.set( - interaction.rotationX, - interaction.rotationY, - interaction.rotationZ, - ); - mesh.position.y = meshOffsetY; - mesh.scale.setScalar(meshScale); - - if (settings.animation.cameraParallaxEnabled) { - const cameraRange = settings.animation.cameraParallaxAmount; - const cameraEase = settings.animation.cameraParallaxEase; - const centeredX = (interaction.mouseX - 0.5) * 2; - const centeredY = (0.5 - interaction.mouseY) * 2; - const orbitYaw = centeredX * cameraRange; - const orbitPitch = centeredY * cameraRange * 0.7; - const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance; - const targetCameraX = Math.sin(orbitYaw) * horizontalRadius; - const targetCameraY = Math.sin(orbitPitch) * baseCameraDistance * 0.85; - const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius; - - camera.position.x += (targetCameraX - camera.position.x) * cameraEase; - camera.position.y += (targetCameraY - camera.position.y) * cameraEase; - camera.position.z += (targetCameraZ - camera.position.z) * cameraEase; - } else { - camera.position.x += (0 - camera.position.x) * 0.12; - camera.position.y += (0 - camera.position.y) * 0.12; - camera.position.z += (baseCameraDistance - camera.position.z) * 0.12; - } - - const lookAtTarget = new THREE.Vector3(0, meshOffsetY * 0.2, 0); - - camera.lookAt(lookAtTarget); - setPrimaryLightPosition(primaryLight, lightAngle, lightHeight); - halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale( - getVirtualWidth(), - getVirtualHeight(), - lookAtTarget, - ); - - if (!settings.halftone.enabled) { - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(scene3d, camera); - return; - } - - renderer.setRenderTarget(sceneTarget); - renderer.render(scene3d, camera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderFrame(); - - return () => { - window.cancelAnimationFrame(animationFrameId); - clock.dispose(); - resizeObserver.disconnect(); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - window.removeEventListener('pointerup', handlePointerUp); - window.removeEventListener('pointermove', handleWindowPointerMove); - canvas.removeEventListener('pointercancel', handlePointerCancel); - window.removeEventListener('blur', handleWindowBlur); - canvas.removeEventListener('pointerdown', handlePointerDown); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - fullScreenGeometry.dispose(); - material.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - disposeHalftoneMaterialAssets(materialAssets); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; -} - -const StyledVisualMount = styled.div` - background: transparent; - display: block; - height: min(640px, 80vw); - min-height: ${theme.spacing(80)}; - min-width: 0; - overflow: visible; - position: relative; - width: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - height: 720px; - max-width: 760px; - } -`; - -type QuotesProps = { - modelUrl?: string; - style?: CSSProperties; -}; - -export function Quotes({ - modelUrl = '/illustrations/why-twenty/quote/quote.glb', - style, -}: QuotesProps) { - const mountReference = useRef(null); - - useEffect(() => { - const container = mountReference.current; - - if (!container) { - return; - } - - const unmount = mountHalftoneCanvas({ - container, - modelUrl, - onError: (error) => { - console.error(error); - }, - }); - - return () => { - void Promise.resolve(unmount).then((dispose) => dispose?.()); - }; - }, [modelUrl]); - - return ; -} - -export default Quotes; diff --git a/packages/twenty-website-new/src/sections/Salesforce/components/PricingWindow.tsx b/packages/twenty-website-new/src/sections/Salesforce/components/PricingWindow.tsx index cc037484b5..174586b243 100644 --- a/packages/twenty-website-new/src/sections/Salesforce/components/PricingWindow.tsx +++ b/packages/twenty-website-new/src/sections/Salesforce/components/PricingWindow.tsx @@ -5,9 +5,10 @@ import type { SalesforcePricingPanelType, SalesforceRichTextPartType, } from '@/sections/Salesforce/types'; +import { useAnimatedNumber } from '@/lib/animation'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; -import { useEffect, useRef, useState } from 'react'; +import { useRef } from 'react'; const formatPriceAmount = (amount: number) => `$${new Intl.NumberFormat('en-US').format(amount)}`; @@ -53,40 +54,6 @@ const calculatePriceAmounts = ( }; }; -const ANIMATION_DURATION_MS = 500; - -const useAnimatedNumber = (target: number) => { - const [display, setDisplay] = useState(target); - const prevRef = useRef(target); - - useEffect(() => { - const from = prevRef.current; - prevRef.current = target; - - if (from === target) { - return; - } - - const start = performance.now(); - let rafId: number; - - const tick = (now: number) => { - const progress = Math.min((now - start) / ANIMATION_DURATION_MS, 1); - const eased = 1 - (1 - progress) ** 3; - setDisplay(Math.round(from + (target - from) * eased)); - - if (progress < 1) { - rafId = requestAnimationFrame(tick); - } - }; - - rafId = requestAnimationFrame(tick); - return () => cancelAnimationFrame(rafId); - }, [target]); - - return display; -}; - const PANEL_BACKGROUND = '#c9c9c9'; const SALESFORCE_BLUE = '#009EDB'; diff --git a/packages/twenty-website-new/src/sections/Salesforce/components/WrongChoicePopup.tsx b/packages/twenty-website-new/src/sections/Salesforce/components/WrongChoicePopup.tsx index c5c6657d02..da048a9f72 100644 --- a/packages/twenty-website-new/src/sections/Salesforce/components/WrongChoicePopup.tsx +++ b/packages/twenty-website-new/src/sections/Salesforce/components/WrongChoicePopup.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTimeoutRegistry } from '@/lib/react'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; import { useEffect, useState } from 'react'; @@ -117,17 +118,14 @@ export function WrongChoicePopup({ titleBar, titleId, }: WrongChoicePopupProps) { + const timeoutRegistry = useTimeoutRegistry(); const [isClosing, setIsClosing] = useState(false); useEffect(() => { - const fadeTimer = window.setTimeout(() => { + return timeoutRegistry.schedule(() => { setIsClosing(true); }, POPUP_VISIBLE_DURATION_MS); - - return () => { - window.clearTimeout(fadeTimer); - }; - }, []); + }, [timeoutRegistry]); useEffect(() => { if (isClosingRequested) { @@ -140,14 +138,10 @@ export function WrongChoicePopup({ return; } - const removeTimer = window.setTimeout(() => { + return timeoutRegistry.schedule(() => { onClose(); }, POPUP_FADE_DURATION_MS); - - return () => { - window.clearTimeout(removeTimer); - }; - }, [isClosing, onClose]); + }, [isClosing, onClose, timeoutRegistry]); return ( - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Statement/components/Root.tsx b/packages/twenty-website-new/src/sections/Statement/components/Root.tsx deleted file mode 100644 index 0aa3342dc7..0000000000 --- a/packages/twenty-website-new/src/sections/Statement/components/Root.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Container } from '@/design-system/components'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import type { ReactNode } from 'react'; - -const StyledSection = styled.section` - min-width: 0; - overflow: visible; - position: relative; - width: 100%; -`; - -const StyledContainer = styled(Container)` - display: grid; - justify-items: center; - margin-left: auto; - margin-right: auto; - padding-bottom: ${theme.spacing(28)}; - padding-left: ${theme.spacing(4)}; - padding-right: ${theme.spacing(4)}; - padding-top: ${theme.spacing(28)}; - position: relative; - - @media (min-width: ${theme.breakpoints.md}px) { - padding-left: ${theme.spacing(10)}; - padding-right: ${theme.spacing(10)}; - } -`; - -type RootProps = { - backgroundColor: string; - children: ReactNode; - color: string; -}; - -export function Root({ backgroundColor, children, color }: RootProps) { - return ( - - {children} - - ); -} diff --git a/packages/twenty-website-new/src/sections/Statement/components/index.ts b/packages/twenty-website-new/src/sections/Statement/components/index.ts deleted file mode 100644 index 4c9d9c48bc..0000000000 --- a/packages/twenty-website-new/src/sections/Statement/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Heading } from './Heading'; -import { Root } from './Root'; - -export const Statement = { Heading, Root }; diff --git a/packages/twenty-website-new/src/sections/Statement/types/StatementData.ts b/packages/twenty-website-new/src/sections/Statement/types/StatementData.ts deleted file mode 100644 index f928c87ecb..0000000000 --- a/packages/twenty-website-new/src/sections/Statement/types/StatementData.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { HeadingType } from '@/design-system/components/Heading'; - -export type StatementDataType = { - heading: HeadingType; -}; diff --git a/packages/twenty-website-new/src/sections/Statement/types/index.ts b/packages/twenty-website-new/src/sections/Statement/types/index.ts deleted file mode 100644 index 40cb1032e6..0000000000 --- a/packages/twenty-website-new/src/sections/Statement/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { StatementDataType } from './StatementData'; diff --git a/packages/twenty-website-new/src/sections/Tabs/components/Body.tsx b/packages/twenty-website-new/src/sections/Tabs/components/Body.tsx deleted file mode 100644 index 4936d44232..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/components/Body.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Body as BaseBody } from '@/design-system/components'; -import { BodyType } from '@/design-system/components/Body'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; - -const StyledBody = styled(BaseBody)` - --body-sm-color: color-mix(in srgb, currentColor 80%, transparent); - margin-top: calc(${theme.spacing(2)} - ${theme.spacing(6)}); - margin-bottom: ${theme.spacing(12)}; -`; - -type BodyProps = { - body: BodyType; -}; - -export function Body({ body }: BodyProps) { - return ; -} diff --git a/packages/twenty-website-new/src/sections/Tabs/components/Heading.tsx b/packages/twenty-website-new/src/sections/Tabs/components/Heading.tsx deleted file mode 100644 index 837a1ec8fd..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/components/Heading.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Heading as BaseHeading } from '@/design-system/components'; -import type { HeadingType } from '@/design-system/components/Heading'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; - -const StyledHeading = styled(BaseHeading)` - white-space: pre-line; - position: relative; - display: inline-block; - - @media (min-width: ${theme.breakpoints.md}px) { - max-width: 672px; - } -`; - -type HeadingProps = { - segments: HeadingType[]; -}; - -export function Heading({ segments }: HeadingProps) { - return ; -} diff --git a/packages/twenty-website-new/src/sections/Tabs/components/Root.tsx b/packages/twenty-website-new/src/sections/Tabs/components/Root.tsx deleted file mode 100644 index cee2170e9b..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/components/Root.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { Container } from '@/design-system/components'; -import { theme } from '@/theme'; -import { css } from '@linaria/core'; -import { styled } from '@linaria/react'; -import NextImage from 'next/image'; -import type { ReactNode } from 'react'; - -const RootWrapper = styled.div` - display: grid; - grid-template-columns: 1fr; - position: relative; - width: 100%; -`; - -const TabBackgrounds = styled.div` - align-self: stretch; - grid-column: 1; - grid-row: 1; - min-height: 0; - pointer-events: none; - position: relative; - width: 100%; - z-index: 0; -`; - -const BackgroundShape = styled.div` - bottom: 0; - left: 0; - pointer-events: none; - position: absolute; - right: 0; - top: 0; -`; - -const ShapeClip = styled.div` - bottom: 0; - left: 0; - overflow: hidden; - pointer-events: none; - position: absolute; - right: 0; - top: 0; - z-index: 0; -`; - -const shapeFillClassName = css` - object-fit: cover; - object-position: center top; -`; - -const PatternLayer = styled.div` - bottom: -81px; - height: 575px; - left: 50%; - opacity: 0.4; - pointer-events: none; - position: absolute; - transform: translateX(-50%); - width: 100%; - z-index: 1; -`; - -const patternImageClassName = css` - object-fit: cover; -`; - -const StyledSection = styled.section` - align-self: start; - grid-column: 1; - grid-row: 1; - position: relative; - width: 100%; - z-index: 2; -`; - -const StyledContainer = styled(Container)` - color: ${theme.colors.secondary.text[100]}; - display: grid; - grid-template-columns: 1fr; - justify-items: center; - padding-bottom: ${theme.spacing(16)}; - padding-left: ${theme.spacing(4)}; - padding-right: ${theme.spacing(4)}; - padding-top: ${theme.spacing(16)}; - row-gap: ${theme.spacing(6)}; - text-align: center; - width: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - padding-bottom: ${theme.spacing(40)}; - padding-left: ${theme.spacing(10)}; - padding-right: ${theme.spacing(10)}; - padding-top: ${theme.spacing(40)}; - row-gap: ${theme.spacing(6)}; - } -`; - -type RootProps = { children: ReactNode }; - -export function Root({ children }: RootProps) { - return ( - - - - - - - - - - - - - {children} - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Tabs/components/TabButton.tsx b/packages/twenty-website-new/src/sections/Tabs/components/TabButton.tsx deleted file mode 100644 index 7c7e60408c..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/components/TabButton.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client'; - -import { INFORMATIVE_ICONS } from '@/icons'; -import type { TabType } from '@/sections/Tabs/types'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; - -const Label = styled.span` - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const StyledButton = styled.button` - align-items: center; - border-radius: ${theme.radius(2)}; - box-sizing: border-box; - cursor: pointer; - display: grid; - font-family: ${theme.font.family.sans}; - font-size: ${theme.font.size(3.5)}; - font-weight: ${theme.font.weight.regular}; - gap: ${theme.spacing(2)}; - grid-template-columns: minmax(0, 1fr) auto; - line-height: ${theme.lineHeight(3.5)}; - max-width: 100%; - min-width: 0; - padding: ${theme.spacing(2)}; - text-align: left; - transition: - background-color 0.2s ease, - background-image 0.2s ease, - border-color 0.2s ease, - color 0.2s ease; - - &[data-active='true'] { - background-color: ${theme.colors.primary.background[100]}; - border: 1px solid transparent; - color: ${theme.colors.primary.text[100]}; - } - - &[data-active='false'] { - background-color: ${theme.colors.secondary.background[100]}; - background-image: linear-gradient( - 90deg, - rgba(255, 255, 255, 0.1) 0%, - rgba(255, 255, 255, 0.1) 100% - ); - border: 1px solid ${theme.colors.secondary.border[10]}; - color: ${theme.colors.secondary.text[100]}; - } - - &:focus-visible { - outline: 1px solid ${theme.colors.highlight[100]}; - outline-offset: 1px; - } -`; - -const TabIconBox = styled.span` - border-radius: ${theme.radius(1)}; - display: grid; - height: ${theme.spacing(6)}; - overflow: hidden; - place-items: center; - width: ${theme.spacing(6)}; - - & svg { - display: block; - max-height: 100%; - max-width: 100%; - } - - &[data-active='true'] { - background-color: ${theme.colors.primary.text[10]}; - } - - &[data-active='false'] { - background-color: ${theme.colors.highlight[100]}; - } -`; - -type TabButtonProps = { - tab: TabType; - isActive: boolean; - onSelect: () => void; -}; - -export function TabButton({ tab, isActive, onSelect }: TabButtonProps) { - const iconColor = isActive - ? theme.colors.highlight[100] - : theme.colors.secondary.text[100]; - - const Icon = INFORMATIVE_ICONS[tab.icon]; - - return ( - - - - {Icon ? : null} - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Tabs/components/TabButtons.tsx b/packages/twenty-website-new/src/sections/Tabs/components/TabButtons.tsx deleted file mode 100644 index 1320137212..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/components/TabButtons.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client'; - -import type { TabType } from '@/sections/Tabs/types'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import { TabButton } from './TabButton'; - -const TabButtonsGrid = styled.div` - display: grid; - gap: ${theme.spacing(2)}; - grid-template-columns: minmax(0, max-content); - justify-content: center; - width: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - grid-template-columns: repeat(2, minmax(0, max-content)); - } - - @media (min-width: ${theme.breakpoints.lg}px) { - grid-template-columns: repeat(4, minmax(0, max-content)); - } -`; - -type TabButtonsProps = { - tabs: TabType[]; - activeIndex: number; - onSelect: (index: number) => void; -}; - -export function TabButtons({ tabs, activeIndex, onSelect }: TabButtonsProps) { - return ( - - {tabs.map((tab, index) => ( - onSelect(index)} - /> - ))} - - ); -} diff --git a/packages/twenty-website-new/src/sections/Tabs/components/TabGroup.tsx b/packages/twenty-website-new/src/sections/Tabs/components/TabGroup.tsx deleted file mode 100644 index 552054106a..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/components/TabGroup.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client'; - -import type { TabType } from '@/sections/Tabs/types'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import { useState } from 'react'; -import { TabButtons } from './TabButtons'; -import { TabVisuals } from './TabVisuals'; - -const StyledTabGroup = styled.div` - display: grid; - grid-template-columns: 1fr; - width: 100%; - row-gap: ${theme.spacing(18)}; -`; - -type TabGroupProps = { - tabs: TabType[]; -}; - -export function TabGroup({ tabs }: TabGroupProps) { - const [activeIndex, setActiveIndex] = useState(0); - - return ( - - - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Tabs/components/TabVisual.tsx b/packages/twenty-website-new/src/sections/Tabs/components/TabVisual.tsx deleted file mode 100644 index 87eb930b93..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/components/TabVisual.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import { Image } from '@/design-system/components'; -import type { ImageType } from '@/design-system/components/Image'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; - -const VisualRoot = styled.div` - width: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - margin-left: auto; - margin-right: auto; - max-width: 995px; - } -`; - -const VisualImage = styled(Image)` - border-radius: ${theme.radius(1)}; -`; - -type TabVisualProps = { image: ImageType }; - -export function TabVisual({ image }: TabVisualProps) { - return ( - - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Tabs/components/TabVisuals.tsx b/packages/twenty-website-new/src/sections/Tabs/components/TabVisuals.tsx deleted file mode 100644 index 56ff024575..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/components/TabVisuals.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'use client'; - -import type { TabType } from '@/sections/Tabs/types'; -import { TabVisual } from './TabVisual'; - -type TabVisualsProps = { - tabs: TabType[]; - activeIndex: number; -}; - -export function TabVisuals({ tabs, activeIndex }: TabVisualsProps) { - const tab = tabs[activeIndex]; - - if (!tab) { - return null; - } - - return ; -} diff --git a/packages/twenty-website-new/src/sections/Tabs/components/index.ts b/packages/twenty-website-new/src/sections/Tabs/components/index.ts deleted file mode 100644 index e68c3f6e37..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/components/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Body } from './Body'; -import { Heading } from './Heading'; -import { Root } from './Root'; -import { TabButton } from './TabButton'; -import { TabButtons } from './TabButtons'; -import { TabGroup } from './TabGroup'; -import { TabVisual } from './TabVisual'; -import { TabVisuals } from './TabVisuals'; - -export const Tabs = { - Root, - Heading, - Body, - TabGroup, - TabButtons, - TabButton, - TabVisuals, - TabVisual, -}; diff --git a/packages/twenty-website-new/src/sections/Tabs/types/Tab.ts b/packages/twenty-website-new/src/sections/Tabs/types/Tab.ts deleted file mode 100644 index 077b5427e7..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/types/Tab.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { BodyType } from '@/design-system/components/Body'; -import type { ImageType } from '@/design-system/components/Image'; - -export type TabType = { - body: BodyType; - icon: string; - image: ImageType; -}; diff --git a/packages/twenty-website-new/src/sections/Tabs/types/TabsData.ts b/packages/twenty-website-new/src/sections/Tabs/types/TabsData.ts deleted file mode 100644 index 8ec0564673..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/types/TabsData.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { BodyType } from '@/design-system/components/Body'; -import type { EyebrowType } from '@/design-system/components/Eyebrow'; -import type { HeadingType } from '@/design-system/components/Heading'; -import type { TabType } from '@/sections/Tabs/types/Tab'; - -export type TabsDataType = { - eyebrow: EyebrowType; - heading: HeadingType[]; - body: BodyType; - tabs: TabType[]; -}; diff --git a/packages/twenty-website-new/src/sections/Tabs/types/index.ts b/packages/twenty-website-new/src/sections/Tabs/types/index.ts deleted file mode 100644 index 72cb368c4b..0000000000 --- a/packages/twenty-website-new/src/sections/Tabs/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { TabType } from './Tab'; -export type { TabsDataType } from './TabsData'; diff --git a/packages/twenty-website-new/src/sections/Testimonials/visuals/HourglassCanvas.tsx b/packages/twenty-website-new/src/sections/Testimonials/visuals/HourglassCanvas.tsx index 620dfdb193..aa508c8dcb 100644 --- a/packages/twenty-website-new/src/sections/Testimonials/visuals/HourglassCanvas.tsx +++ b/packages/twenty-website-new/src/sections/Testimonials/visuals/HourglassCanvas.tsx @@ -4,9 +4,12 @@ import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment import { styled } from '@linaria/react'; import { useEffect, useRef } from 'react'; import * as THREE from 'three'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; import { - createFrameTimer, - createSiteWebGlRenderer, + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, + type VisualRenderLoopFrame, } from '@/lib/visual-runtime'; interface HourglassLightingSettings { @@ -82,23 +85,6 @@ interface HourglassPose { timeElapsed: number; } -interface InteractionState { - autoElapsed: number; - dragging: boolean; - mouseX: number; - mouseY: number; - pointerX: number; - pointerY: number; - rotateElapsed: number; - rotationX: number; - rotationY: number; - rotationZ: number; - targetRotationX: number; - targetRotationY: number; - velocityX: number; - velocityY: number; -} - const VIRTUAL_RENDER_HEIGHT = 768; const REFERENCE_PREVIEW_DISTANCE = 4; @@ -339,7 +325,7 @@ export function HourglassCanvas({ return; } - let animationFrameId = 0; + let renderLoop: VisualRenderLoop | null = null; const getWidth = () => Math.max(container.clientWidth, 1); const getHeight = () => Math.max(container.clientHeight, 1); @@ -352,7 +338,18 @@ export function HourglassCanvas({ 1, ); - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); + const renderer = tryCreateSiteWebGlRenderer({ + antialias: false, + alpha: true, + onContextLost: () => { + renderLoop?.stop(); + }, + }); + + if (renderer === null) { + return; + } + renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setPixelRatio(1); renderer.setClearColor(0x000000, 0); @@ -530,8 +527,7 @@ export function HourglassCanvas({ ); }; - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); + const stopObservingSize = observeElementSize(container, syncSize); const updatePointerPosition = (event: PointerEvent) => { const rect = canvas.getBoundingClientRect(); @@ -592,14 +588,12 @@ export function HourglassCanvas({ window.addEventListener('pointerup', handlePointerUp); canvas.addEventListener('pointerdown', handlePointerDown); - const frameTimer = createFrameTimer(); - - const renderFrame = () => { - animationFrameId = window.requestAnimationFrame(renderFrame); - - const delta = frameTimer.tick(); - const elapsedTime = - (initialPose?.timeElapsed ?? 0) + frameTimer.getElapsed(); + const renderFrame = ( + _timestamp: DOMHighResTimeStamp, + { deltaSeconds, elapsedSeconds }: VisualRenderLoopFrame, + ) => { + const delta = deltaSeconds; + const elapsedTime = (initialPose?.timeElapsed ?? 0) + elapsedSeconds; halftoneMaterial.uniforms.time.value = elapsedTime; let baseRotationX = 0; @@ -753,11 +747,16 @@ export function HourglassCanvas({ renderer.render(postScene, orthographicCamera); }; - renderFrame(); + renderLoop = createVisualRenderLoop({ + renderFrame, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); return () => { - window.cancelAnimationFrame(animationFrameId); - resizeObserver.disconnect(); + renderLoop?.dispose(); + stopObservingSize(); window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); canvas.removeEventListener('pointerdown', handlePointerDown); diff --git a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FastPathVisual.tsx b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FastPathVisual.tsx index 84aaadfcd4..a2af52181d 100644 --- a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FastPathVisual.tsx +++ b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FastPathVisual.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTimeoutRegistry } from '@/lib/react'; import { WebGlMount } from '@/lib/visual-runtime'; import { useScaleToFit } from '@/sections/ThreeCards/utils/use-scale-to-fit'; import { theme } from '@/theme'; @@ -829,6 +830,7 @@ export function FastPathVisual({ }: FastPathVisualProps) { const rootRef = useRef(null); const previewSurfaceRef = useRef(null); + const timeoutRegistry = useTimeoutRegistry(); const [confettiBursts, setConfettiBursts] = useState< Array<{ id: number; left: number; top: number }> >([]); @@ -873,7 +875,7 @@ export function FastPathVisual({ { id: burstId, left: burstLeft, top: burstTop }, ]); - window.setTimeout(() => { + timeoutRegistry.schedule(() => { setConfettiBursts((currentBursts) => currentBursts.filter((currentBurst) => currentBurst.id !== burstId), ); diff --git a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/HalftoneImageBackdrop.tsx b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/HalftoneImageBackdrop.tsx index dae1fa254b..d36c83c1a3 100644 --- a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/HalftoneImageBackdrop.tsx +++ b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/HalftoneImageBackdrop.tsx @@ -4,7 +4,12 @@ import { styled } from '@linaria/react'; import { type RefObject, useEffect, useRef } from 'react'; import * as THREE from 'three'; -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; +import { + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, +} from '@/lib/visual-runtime'; const PASS_THROUGH_VERTEX_SHADER = /* glsl */ ` varying vec2 vUv; @@ -215,11 +220,23 @@ async function mountHalftoneImageBackdrop({ }) { const image = await loadDecodedImage(config.imageUrl); - const renderer = createSiteWebGlRenderer({ + let renderLoop: VisualRenderLoop | null = null; + const renderer = tryCreateSiteWebGlRenderer({ alpha: true, antialias: false, + onContextLost: () => { + renderLoop?.stop(); + }, premultipliedAlpha: false, }); + + if (renderer === null) { + return { + dispose: () => {}, + wake: () => {}, + }; + } + renderer.setClearColor(0x000000, 0); const pixelRatio = getDevicePixelRatio(); renderer.setPixelRatio(pixelRatio); @@ -274,7 +291,6 @@ async function mountHalftoneImageBackdrop({ scene.add(fullScreenMesh); const interaction = createInteractionState(); - let animationFrameId: number | null = null; let lastFrameTime: number | null = null; const renderFrame = (deltaSeconds: number) => { @@ -371,8 +387,6 @@ async function mountHalftoneImageBackdrop({ }; const runFrame = (timestamp: number) => { - animationFrameId = null; - const deltaSeconds = lastFrameTime === null ? 1 / 60 @@ -381,18 +395,15 @@ async function mountHalftoneImageBackdrop({ lastFrameTime = timestamp; if (renderFrame(deltaSeconds)) { - animationFrameId = window.requestAnimationFrame(runFrame); - return; + return true; } lastFrameTime = null; + return false; }; const ensureAnimationLoop = () => { - if (animationFrameId !== null) { - return; - } - animationFrameId = window.requestAnimationFrame(runFrame); + renderLoop?.start(); }; const syncSize = () => { @@ -409,13 +420,18 @@ async function mountHalftoneImageBackdrop({ 1, ); - if (animationFrameId === null) { + if (!renderLoop?.isRunning()) { renderFrame(0); } }; - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); + renderLoop = createVisualRenderLoop({ + renderFrame: runFrame, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + + const stopObservingSize = observeElementSize(container, syncSize); syncSize(); const updatePointerPosition = ( @@ -483,11 +499,8 @@ async function mountHalftoneImageBackdrop({ return { dispose: () => { - if (animationFrameId !== null) { - window.cancelAnimationFrame(animationFrameId); - } - - resizeObserver.disconnect(); + renderLoop?.dispose(); + stopObservingSize(); trackingElement.removeEventListener('pointermove', handlePointerMove); trackingElement.removeEventListener('pointerleave', handlePointerLeave); @@ -526,6 +539,22 @@ export function HalftoneImageBackdrop({ const mountRef = useRef(null); const activeRef = useRef(active); const wakeRef = useRef(null); + const { + activeHoverX, + activeHoverY, + dashColor, + flipImageY, + halftonePower, + halftoneScalePx, + halftoneWidth, + hoverDashColor, + hoverHalftoneRadius, + hoverLightIntensity, + hoverLightRadius, + imageContrast, + imageUrl, + previewDistance, + } = config; activeRef.current = active; @@ -539,7 +568,22 @@ export function HalftoneImageBackdrop({ let cancelled = false; void mountHalftoneImageBackdrop({ - config, + config: { + activeHoverX, + activeHoverY, + dashColor, + flipImageY, + halftonePower, + halftoneScalePx, + halftoneWidth, + hoverDashColor, + hoverHalftoneRadius, + hoverLightIntensity, + hoverLightRadius, + imageContrast, + imageUrl, + previewDistance, + }, container, isExternallyActive: () => activeRef.current, pointerTarget: pointerTargetRef?.current, @@ -564,20 +608,20 @@ export function HalftoneImageBackdrop({ dispose?.(); }; }, [ - config.activeHoverX, - config.activeHoverY, - config.dashColor, - config.flipImageY, - config.halftonePower, - config.halftoneScalePx, - config.halftoneWidth, - config.hoverDashColor, - config.hoverHalftoneRadius, - config.hoverLightIntensity, - config.hoverLightRadius, - config.imageContrast, - config.imageUrl, - config.previewDistance, + activeHoverX, + activeHoverY, + dashColor, + flipImageY, + halftonePower, + halftoneScalePx, + halftoneWidth, + hoverDashColor, + hoverHalftoneRadius, + hoverLightIntensity, + hoverLightRadius, + imageContrast, + imageUrl, + previewDistance, pointerTargetRef, ]); diff --git a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataHeroTable.tsx b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataHeroTable.tsx index fbbb265f2c..e0b433c3e2 100644 --- a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataHeroTable.tsx +++ b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataHeroTable.tsx @@ -1,6 +1,12 @@ 'use client'; import { SHARED_COMPANY_LOGO_URLS } from '@/content/site/asset-paths'; +import { useHorizontalDragScroll } from '@/lib/dom/use-horizontal-drag-scroll'; +import { + Chip, + ChipVariant, +} from '@/sections/Hero/components/HomeVisual/Shared/homeVisualChip'; +import { VISUAL_TOKENS } from '@/sections/Hero/components/HomeVisual/Shared/homeVisualTokens'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; import { @@ -10,17 +16,7 @@ import { IconLink, IconPlus, } from '@tabler/icons-react'; -import { - useEffect, - useRef, - useState, - type PointerEvent as ReactPointerEvent, -} from 'react'; -import { - Chip, - ChipVariant, -} from '@/sections/Hero/components/HomeVisual/homeVisualChip'; -import { VISUAL_TOKENS } from '@/sections/Hero/components/HomeVisual/homeVisualTokens'; +import { useState } from 'react'; const APP_FONT = VISUAL_TOKENS.font.family; const TABLE_CELL_HORIZONTAL_PADDING = 8; @@ -486,14 +482,17 @@ export function LiveDataHeroTable({ isFirstTagHoveredByAlice, showExtendedRows, }: LiveDataHeroTableProps) { - const viewportRef = useRef(null); - const dragRef = useRef({ - active: false, - pointerId: -1, - startScrollLeft: 0, - startX: 0, + const { + dragging, + onPointerCancel, + onPointerDown, + onPointerLeave, + onPointerMove, + onPointerUp, + viewportRef, + } = useHorizontalDragScroll({ + wheelScrollsHorizontally: true, }); - const [dragging, setDragging] = useState(false); const [hoveredRowIndex, setHoveredRowIndex] = useState(null); const visibleRows = showExtendedRows ? EXPANDED_TABLE_ROWS : BASE_TABLE_ROWS; @@ -504,100 +503,17 @@ export function LiveDataHeroTable({ const totalTableWidth = Math.max(DEFAULT_TABLE_WIDTH, columnWidth); const fillerWidth = Math.max(totalTableWidth - columnWidth, 0); - const handlePointerDown = (event: ReactPointerEvent) => { - if ( - event.pointerType !== 'mouse' || - event.button !== 0 || - !viewportRef.current - ) { - return; - } - - dragRef.current = { - active: true, - pointerId: event.pointerId, - startScrollLeft: viewportRef.current.scrollLeft, - startX: event.clientX, - }; - - viewportRef.current.setPointerCapture(event.pointerId); - setDragging(true); - event.preventDefault(); - }; - - const handlePointerMove = (event: ReactPointerEvent) => { - if (!dragRef.current.active || !viewportRef.current) { - return; - } - - viewportRef.current.scrollLeft = - dragRef.current.startScrollLeft - - (event.clientX - dragRef.current.startX); - }; - - const endDragging = () => { - dragRef.current.active = false; - dragRef.current.pointerId = -1; - setDragging(false); - }; - - const handlePointerUp = (event: ReactPointerEvent) => { - if (!viewportRef.current || dragRef.current.pointerId !== event.pointerId) { - return; - } - - viewportRef.current.releasePointerCapture(event.pointerId); - endDragging(); - }; - - useEffect(() => { - const node = viewportRef.current; - - if (!node) { - return; - } - - const onWheel: EventListener = (event) => { - if (!(event instanceof WheelEvent)) { - return; - } - - if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) { - return; - } - - const maxScrollLeft = Math.max(node.scrollWidth - node.clientWidth, 0); - const nextScrollLeft = Math.min( - Math.max(node.scrollLeft + event.deltaY, 0), - maxScrollLeft, - ); - - if (Math.abs(nextScrollLeft - node.scrollLeft) < 0.5) { - return; - } - - node.scrollLeft = nextScrollLeft; - event.preventDefault(); - }; - - node.addEventListener('wheel', onWheel, { passive: false }); - - return () => { - node.removeEventListener('wheel', onWheel); - }; - }, []); - return ( diff --git a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx index eda7a0dd62..c6f0976662 100644 --- a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx +++ b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx @@ -1,5 +1,7 @@ 'use client'; +import { createAnimationFrameLoop } from '@/lib/animation'; +import { useTimeoutRegistry } from '@/lib/react'; import { WebGlMount } from '@/lib/visual-runtime'; import { useScaleToFit } from '@/sections/ThreeCards/utils/use-scale-to-fit'; import { theme } from '@/theme'; @@ -719,6 +721,7 @@ export function LiveDataVisual({ const typeFilterRef = useRef(null); const employeesFilterRef = useRef(null); const sceneScale = useScaleToFit(rootRef, SCENE_WIDTH, SCENE_HEIGHT); + const timeoutRegistry = useTimeoutRegistry(); const [isBobHovered, setIsBobHovered] = useState(false); const [isTomHovered, setIsTomHovered] = useState(false); const [phase, setPhase] = useState('idle'); @@ -734,42 +737,46 @@ export function LiveDataVisual({ return; } - const timeoutIds: number[] = []; + const cancelSequenceSteps: Array<() => void> = []; setPhase('move-to-tag'); for (const step of LIVE_DATA_SEQUENCE.slice(1)) { - const timeoutId = window.setTimeout(() => { - setPhase(step.phase); - }, step.delay); - - timeoutIds.push(timeoutId); + cancelSequenceSteps.push( + timeoutRegistry.schedule(() => { + setPhase(step.phase); + }, step.delay), + ); } return () => { - for (const timeoutId of timeoutIds) { - window.clearTimeout(timeoutId); - } + cancelSequenceSteps.forEach((cancelSequenceStep) => cancelSequenceStep()); }; - }, [active]); + }, [active, timeoutRegistry]); useEffect(() => { if (phase === 'rename-tag') { setTypedTagLabel(EDITED_TAG_LABEL.slice(0, 1)); let nextIndex = 2; - const intervalId = window.setInterval(() => { - setTypedTagLabel(EDITED_TAG_LABEL.slice(0, nextIndex)); + const cancelTypingSteps: Array<() => void> = []; - if (nextIndex >= EDITED_TAG_LABEL.length) { - window.clearInterval(intervalId); - return; - } + const scheduleNextTypingStep = () => { + const cancelTypingStep = timeoutRegistry.schedule(() => { + setTypedTagLabel(EDITED_TAG_LABEL.slice(0, nextIndex)); + nextIndex += 1; - nextIndex += 1; - }, TYPING_STEP_MS); + if (nextIndex <= EDITED_TAG_LABEL.length) { + scheduleNextTypingStep(); + } + }, TYPING_STEP_MS); + + cancelTypingSteps.push(cancelTypingStep); + }; + + scheduleNextTypingStep(); return () => { - window.clearInterval(intervalId); + cancelTypingSteps.forEach((cancelTypingStep) => cancelTypingStep()); }; } @@ -786,10 +793,16 @@ export function LiveDataVisual({ } setTypedTagLabel(''); - }, [phase]); + }, [phase, timeoutRegistry]); useEffect(() => { + let isMounted = true; + const measureAddFilterLefts = () => { + if (!isMounted) { + return; + } + const typeFilter = typeFilterRef.current; const employeesFilter = employeesFilterRef.current; @@ -817,12 +830,20 @@ export function LiveDataVisual({ ); }; - const frameId = window.requestAnimationFrame(measureAddFilterLefts); + const measureTask = createAnimationFrameLoop({ + onFrame: () => { + measureAddFilterLefts(); + return false; + }, + }); + + measureTask.start(); window.addEventListener('resize', measureAddFilterLefts); void document.fonts?.ready.then(measureAddFilterLefts); return () => { - window.cancelAnimationFrame(frameId); + isMounted = false; + measureTask.stop(); window.removeEventListener('resize', measureAddFilterLefts); }; }, []); diff --git a/packages/twenty-website-new/src/sections/ThreeCards/utils/use-scale-to-fit.ts b/packages/twenty-website-new/src/sections/ThreeCards/utils/use-scale-to-fit.ts index ee648fae9c..9875727135 100644 --- a/packages/twenty-website-new/src/sections/ThreeCards/utils/use-scale-to-fit.ts +++ b/packages/twenty-website-new/src/sections/ThreeCards/utils/use-scale-to-fit.ts @@ -1,5 +1,6 @@ 'use client'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; import { type RefObject, useEffect, useState } from 'react'; export function useScaleToFit( @@ -26,12 +27,11 @@ export function useScaleToFit( setScale(baseScale * fit); }; - const observer = new ResizeObserver(compute); - observer.observe(element); + const stopObservingSize = observeElementSize(element, compute); compute(); return () => { - observer.disconnect(); + stopObservingSize(); }; }, [baseScale, containerRef, designHeight, designWidth]); diff --git a/packages/twenty-website-new/src/sections/ThreeCards/visuals/PartnerThreeCard.tsx b/packages/twenty-website-new/src/sections/ThreeCards/visuals/PartnerThreeCard.tsx index 4df0308c3f..19f3d4ff33 100644 --- a/packages/twenty-website-new/src/sections/ThreeCards/visuals/PartnerThreeCard.tsx +++ b/packages/twenty-website-new/src/sections/ThreeCards/visuals/PartnerThreeCard.tsx @@ -1,2122 +1,24 @@ -// @ts-nocheck 'use client'; -import { createSiteWebGlRenderer } from '@/lib/visual-runtime'; -import { useEffect, useRef, type CSSProperties } from 'react'; -import * as THREE from 'three'; -import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; -import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; -import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'; -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import { GLASS_ENVIRONMENT_TEXTURE_URL } from '@/lib/visual-runtime/textures/glass-environment'; -import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; - -const settings = { - sourceMode: 'shape', - shapeKey: 'userUpload_1776089370856', - lighting: { - intensity: 1.5, - fillIntensity: 0.48, - ambientIntensity: 0.3, - angleDegrees: 53, - height: 2, - }, - material: { - surface: 'solid', - color: '#d4d0c8', - roughness: 0.42, - metalness: 0.15, - thickness: 150, - refraction: 2, - environmentPower: 5, - }, - halftone: { - enabled: true, - scale: 14, - power: 0.4, - width: 0.5, - imageContrast: 1, - dashColor: '#4A38F5', - hoverDashColor: '#4A38F5', - }, - background: { - transparent: true, - color: '#000000', - }, - animation: { - autoRotateEnabled: true, - breatheEnabled: false, - cameraParallaxEnabled: false, - followHoverEnabled: false, - followDragEnabled: true, - floatEnabled: false, - hoverHalftoneEnabled: false, - hoverLightEnabled: false, - dragFlowEnabled: false, - lightSweepEnabled: false, - rotateEnabled: false, - autoSpeed: 0.1, - autoWobble: 0, - breatheAmount: 0.04, - breatheSpeed: 0.8, - cameraParallaxAmount: 0.3, - cameraParallaxEase: 0.08, - driftAmount: 8, - hoverRange: 25, - hoverEase: 0.19, - hoverReturn: true, - dragSens: 0.008, - dragFriction: 0.08, - dragMomentum: true, - rotateAxis: 'y', - rotatePreset: 'axis', - rotateSpeed: 0.1, - rotatePingPong: false, - floatAmplitude: 0.16, - floatSpeed: 0.8, - lightSweepHeightRange: 0.5, - lightSweepRange: 28, - lightSweepSpeed: 0.7, - springDamping: 0.6, - springReturnEnabled: true, - springStrength: 0.06, - hoverHalftonePowerShift: 0.42, - hoverHalftoneRadius: 0.2, - hoverHalftoneWidthShift: -0.18, - hoverLightIntensity: 0.8, - hoverLightRadius: 0.2, - dragFlowDecay: 0.08, - dragFlowRadius: 0.24, - dragFlowStrength: 1.8, - hoverWarpStrength: 3, - hoverWarpRadius: 0.15, - dragWarpStrength: 5, - waveEnabled: false, - waveSpeed: 1, - waveAmount: 2, - }, -}; -const shape = { - filename: 'partner-three-card.glb', - key: 'userUpload_1776089370856', - kind: 'imported', - label: 'partner three-card illustration', - loader: 'glb', -}; -const initialPose = { - autoElapsed: 11.523399999928483, - rotateElapsed: 0, - rotationX: -4.020043134225878e-15, - rotationY: 1.1339840023154435, - rotationZ: 0, - targetRotationX: 0, - targetRotationY: 0, - timeElapsed: 11.523399999928476, -}; -const DIAMOND_MODEL_URL = '/illustrations/home/three-cards/diamond.glb'; -const LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET = 2.75; -const previewDistance = 4.5; -const VIRTUAL_RENDER_HEIGHT = 768; - -const passThroughVertexShader = - '\n varying vec2 vUv;\n\n void main() {\n vUv = uv;\n gl_Position = vec4(position, 1.0);\n }\n'; -const blurFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tInput;\n uniform vec2 dir;\n uniform vec2 res;\n\n varying vec2 vUv;\n\n void main() {\n vec4 sum = vec4(0.0);\n vec2 px = dir / res;\n\n float w[5];\n w[0] = 0.227027;\n w[1] = 0.1945946;\n w[2] = 0.1216216;\n w[3] = 0.054054;\n w[4] = 0.016216;\n\n sum += texture2D(tInput, vUv) * w[0];\n\n for (int i = 1; i < 5; i++) {\n float fi = float(i) * 3.0;\n sum += texture2D(tInput, vUv + px * fi) * w[i];\n sum += texture2D(tInput, vUv - px * fi) * w[i];\n }\n\n gl_FragColor = sum;\n }\n'; -const halftoneFragmentShader = - '\n precision highp float;\n\n uniform sampler2D tScene;\n uniform sampler2D tGlow;\n uniform vec2 effectResolution;\n uniform vec2 logicalResolution;\n uniform float tile;\n uniform float s_3;\n uniform float s_4;\n uniform vec3 dashColor;\n uniform vec3 hoverDashColor;\n uniform float time;\n uniform float waveAmount;\n uniform float waveSpeed;\n uniform float footprintScale;\n uniform vec2 interactionUv;\n uniform vec2 interactionVelocity;\n uniform vec2 dragOffset;\n uniform float hoverHalftoneActive;\n uniform float hoverHalftonePowerShift;\n uniform float hoverHalftoneRadius;\n uniform float hoverHalftoneWidthShift;\n uniform float hoverLightStrength;\n uniform float hoverLightRadius;\n uniform float hoverFlowStrength;\n uniform float hoverFlowRadius;\n uniform float dragFlowStrength;\n uniform float cropToBounds;\n\n varying vec2 vUv;\n\n float distSegment(in vec2 p, in vec2 a, in vec2 b) {\n vec2 pa = p - a;\n vec2 ba = b - a;\n float denom = max(dot(ba, ba), 0.000001);\n float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);\n return length(pa - ba * h);\n }\n\n float lineSimpleEt(in vec2 p, in float r, in float thickness) {\n vec2 a = vec2(0.5) + vec2(-r, 0.0);\n vec2 b = vec2(0.5) + vec2(r, 0.0);\n float distToSegment = distSegment(p, a, b);\n float halfThickness = thickness * r;\n return distToSegment - halfThickness;\n }\n\n void main() {\n if (cropToBounds > 0.5) {\n vec4 boundsCheck = texture2D(tScene, vUv);\n if (boundsCheck.a < 0.01) {\n gl_FragColor = vec4(0.0);\n return;\n }\n }\n\n vec2 fragCoord =\n (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;\n float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);\n vec2 pointerPx = interactionUv * logicalResolution;\n vec2 fragDelta = fragCoord - pointerPx;\n float fragDist = length(fragDelta);\n vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);\n float velocityMagnitude = length(interactionVelocity);\n vec2 motionDir = velocityMagnitude > 0.001\n ? interactionVelocity / velocityMagnitude\n : vec2(0.0, 0.0);\n float motionBias = velocityMagnitude > 0.001\n ? dot(-radialDir, motionDir) * 0.5 + 0.5\n : 0.5;\n\n float hoverLightMask = 0.0;\n if (hoverLightStrength > 0.0) {\n float lightRadiusPx = hoverLightRadius * logicalResolution.y;\n hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);\n }\n\n float hoverHalftoneMask = 0.0;\n if (hoverHalftoneActive > 0.0) {\n float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;\n hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist);\n }\n\n float hoverFlowMask = 0.0;\n if (hoverFlowStrength > 0.0) {\n float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;\n hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);\n }\n\n vec2 hoverDisplacement =\n radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +\n motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;\n vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;\n vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;\n\n float bandRow = floor(effectCoord.y / halftoneSize);\n float waveOffset =\n waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;\n effectCoord.x += waveOffset;\n\n vec2 cellIndex = floor(effectCoord / halftoneSize);\n vec2 sampleUv = clamp(\n (cellIndex + 0.5) * halftoneSize / logicalResolution,\n vec2(0.0),\n vec2(1.0)\n );\n vec2 cellUv = fract(effectCoord / halftoneSize);\n\n vec4 sceneSample = texture2D(tScene, sampleUv);\n float mask = smoothstep(0.02, 0.08, sceneSample.a);\n float localPower = clamp(\n s_3 + hoverHalftonePowerShift * hoverHalftoneMask,\n -1.5,\n 1.5\n );\n float localWidth = clamp(\n s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,\n 0.05,\n 1.4\n );\n float lightLift =\n hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;\n float bandRadius = clamp(\n (\n (\n sceneSample.r +\n sceneSample.g +\n sceneSample.b +\n localPower * length(vec2(0.5))\n ) *\n (1.0 / 3.0)\n ) + lightLift,\n 0.0,\n 1.0\n ) * 1.86 * 0.5;\n\n float alpha = 0.0;\n if (bandRadius > 0.0001) {\n float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);\n float edge = 0.02;\n alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;\n }\n\n vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);\n vec3 color = activeDashColor * alpha;\n gl_FragColor = vec4(color, alpha);\n\n #include \n #include \n }\n'; - -const REFERENCE_PREVIEW_DISTANCE = 4; -const MIN_FOOTPRINT_SCALE = 0.001; - -function getModelOverrides(modelUrl) { - if (modelUrl !== DIAMOND_MODEL_URL) { - return null; - } - - return { - importedGeometry: { - useLegacyNormalization: true, - postRotateZ: 1, - }, - initialPose: { - ...initialPose, - autoElapsed: 0, - rotateElapsed: 0, - rotationX: 0, - rotationY: 0, - rotationZ: 0, - targetRotationX: 0, - targetRotationY: 0, - timeElapsed: 0, - }, - }; -} - -function clampRectToViewport(rect, viewportWidth, viewportHeight) { - const minX = Math.max(rect.x, 0); - const minY = Math.max(rect.y, 0); - const maxX = Math.min(rect.x + rect.width, viewportWidth); - const maxY = Math.min(rect.y + rect.height, viewportHeight); - - if (maxX <= minX || maxY <= minY) { - return null; - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; -} - -function getRectArea(rect) { - if (!rect) { - return 0; - } - - return Math.max(rect.width, 0) * Math.max(rect.height, 0); -} - -function createBox3Corners(bounds) { - const { min, max } = bounds; - - return [ - new THREE.Vector3(min.x, min.y, min.z), - new THREE.Vector3(min.x, min.y, max.z), - new THREE.Vector3(min.x, max.y, min.z), - new THREE.Vector3(min.x, max.y, max.z), - new THREE.Vector3(max.x, min.y, min.z), - new THREE.Vector3(max.x, min.y, max.z), - new THREE.Vector3(max.x, max.y, min.z), - new THREE.Vector3(max.x, max.y, max.z), - ]; -} - -function getImagePreviewZoom(previewDistance) { - return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); -} - -function getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom, -}) { - if ( - imageWidth <= 0 || - imageHeight <= 0 || - viewportWidth <= 0 || - viewportHeight <= 0 - ) { - return null; - } - - const imageAspect = imageWidth / imageHeight; - const viewAspect = viewportWidth / viewportHeight; - - let fittedWidth = viewportWidth; - let fittedHeight = viewportHeight; - - if (imageAspect > viewAspect) { - fittedHeight = viewportWidth / imageAspect; - } else { - fittedWidth = viewportHeight * imageAspect; - } - - const scaledWidth = fittedWidth * zoom; - const scaledHeight = fittedHeight * zoom; - - return clampRectToViewport( - { - x: (viewportWidth - scaledWidth) * 0.5, - y: (viewportHeight - scaledHeight) * 0.5, - width: scaledWidth, - height: scaledHeight, - }, - viewportWidth, - viewportHeight, - ); -} - -function getFootprintScaleFromRects(currentRect, referenceRect) { - const currentArea = getRectArea(currentRect); - const referenceArea = getRectArea(referenceRect); - - if (currentArea <= 0 || referenceArea <= 0) { - return 1; - } - - return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE); -} - -function getImageFootprintScale({ - imageHeight, - imageWidth, - previewDistance, - viewportHeight, - viewportWidth, -}) { - const currentRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: getImagePreviewZoom(previewDistance), - }); - const referenceRect = getContainedImageRect({ - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: 1, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function projectBox3ToViewport({ - camera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, -}) { - if (localBounds.isEmpty() || viewportWidth <= 0 || viewportHeight <= 0) { - return null; - } - - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - let hasProjectedCorner = false; - - for (const corner of createBox3Corners(localBounds)) { - corner.applyMatrix4(meshMatrixWorld).project(camera); - - if ( - !Number.isFinite(corner.x) || - !Number.isFinite(corner.y) || - !Number.isFinite(corner.z) - ) { - continue; - } - - hasProjectedCorner = true; - - const x = (corner.x * 0.5 + 0.5) * viewportWidth; - const y = (1 - (corner.y * 0.5 + 0.5)) * viewportHeight; - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - - if (!hasProjectedCorner) { - return null; - } - - return clampRectToViewport( - { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }, - viewportWidth, - viewportHeight, - ); -} - -function getMeshFootprintScale({ - camera, - localBounds, - lookAtTarget, - meshMatrixWorld, - viewportHeight, - viewportWidth, -}) { - const currentRect = projectBox3ToViewport({ - camera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, - }); - const referenceCamera = camera.clone(); - const currentOffset = referenceCamera.position.clone().sub(lookAtTarget); - const referenceOffset = - currentOffset.lengthSq() > 0 - ? currentOffset.setLength(REFERENCE_PREVIEW_DISTANCE) - : new THREE.Vector3(0, 0, REFERENCE_PREVIEW_DISTANCE); - - referenceCamera.position.copy(lookAtTarget).add(referenceOffset); - referenceCamera.lookAt(lookAtTarget); - referenceCamera.updateProjectionMatrix(); - referenceCamera.updateMatrixWorld(true); - - const referenceRect = projectBox3ToViewport({ - camera: referenceCamera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function makePolarShape(radiusFunction, segments = 320) { - const shape = new THREE.Shape(); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * Math.PI * 2; - const radius = radiusFunction(angle); - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius; - - if (segmentIndex === 0) { - shape.moveTo(x, y); - } else { - shape.lineTo(x, y); - } - } - - return shape; -} - -function makeReliefGeometry(shape, options = {}) { - const { - bevelSegments = 8, - bevelSize = 0.08, - bevelThickness = 0.1, - depth = 0.58, - waveDepth = 0.016, - waves = 8, - } = options; - - const geometry = new THREE.ExtrudeGeometry(shape, { - depth, - steps: 2, - bevelEnabled: true, - bevelThickness, - bevelSize, - bevelSegments, - curveSegments: 96, - }); - - geometry.center(); - - const position = geometry.attributes.position; - let maxRadius = 0; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - maxRadius = Math.max( - maxRadius, - Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), - ); - } - - const fullDepth = depth + bevelThickness * 2; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - const x = position.getX(vertexIndex); - const y = position.getY(vertexIndex); - const z = position.getZ(vertexIndex); - const radius = Math.hypot(x, y) / maxRadius; - const angle = Math.atan2(y, x); - const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); - const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); - const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); - const wave = - Math.cos(angle * waves) * - Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * - waveDepth; - const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); - - position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); - } - - position.needsUpdate = true; - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function mergeGeometries(geometries) { - if (geometries.length === 1) { - return geometries[0]; - } - - let totalVertices = 0; - let totalIndices = 0; - let hasUv = false; - - const geometryInfos = geometries.map((geometry) => { - const position = geometry.attributes.position; - const normal = geometry.attributes.normal; - const uv = geometry.attributes.uv ?? null; - const index = geometry.index; - const indexCount = index ? index.count : position.count; - - totalVertices += position.count; - totalIndices += indexCount; - hasUv = hasUv || uv !== null; - - return { - index, - indexCount, - normal, - position, - uv, - vertexCount: position.count, - }; - }); - - const positions = new Float32Array(totalVertices * 3); - const normals = new Float32Array(totalVertices * 3); - const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; - const indices = new Uint32Array(totalIndices); - - let vertexOffset = 0; - let indexOffset = 0; - - for (const geometryInfo of geometryInfos) { - for ( - let vertexIndex = 0; - vertexIndex < geometryInfo.vertexCount; - vertexIndex += 1 - ) { - const positionOffset = (vertexOffset + vertexIndex) * 3; - positions[positionOffset] = geometryInfo.position.getX(vertexIndex); - positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); - positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); - normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); - normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); - normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); - - if (uvs !== null) { - const uvOffset = (vertexOffset + vertexIndex) * 2; - uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; - uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; - } - } - - if (geometryInfo.index) { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = - geometryInfo.index.getX(localIndex) + vertexOffset; - } - } else { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = localIndex + vertexOffset; - } - } - - vertexOffset += geometryInfo.vertexCount; - indexOffset += geometryInfo.indexCount; - } - - const merged = new THREE.BufferGeometry(); - merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); - - if (uvs !== null) { - merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); - } - - merged.setIndex(new THREE.BufferAttribute(indices, 1)); - - return merged; -} - -function makeArrowTarget() { - const targetParts = []; - const arrowParts = []; - const baseRadius = 1.35; - const baseDepth = 0.32; - const bevel = 0.12; - const points = []; - const segments = 16; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - -baseDepth / 2 + bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - baseDepth / 2 - bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - targetParts.push(disc); - - for (const radius of [0.45, 0.85, 1.22]) { - const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); - ring.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), - ); - targetParts.push(ring); - } - - const bump = new THREE.SphereGeometry( - 0.32, - 32, - 24, - 0, - Math.PI * 2, - 0, - Math.PI / 2, - ); - bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); - bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); - targetParts.push(bump); - - const shaftLength = 1.5; - const shaftRadius = 0.05; - const shaft = new THREE.CylinderGeometry( - shaftRadius, - shaftRadius, - shaftLength, - 10, - 1, - ); - shaft.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0), - ); - arrowParts.push(shaft); - - const head = new THREE.ConeGeometry(0.12, 0.35, 10); - head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); - arrowParts.push(head); - - for (let finIndex = 0; finIndex < 3; finIndex += 1) { - const finShape = new THREE.Shape(); - finShape.moveTo(0, 0); - finShape.lineTo(0.22, 0.25); - finShape.lineTo(0, 0.5); - finShape.lineTo(0, 0); - - const finGeometry = new THREE.ExtrudeGeometry(finShape, { - depth: 0.012, - bevelEnabled: false, - }); - - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, 0, -0.006), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), - ); - arrowParts.push(finGeometry); - } - - const nock = new THREE.SphereGeometry(0.065, 8, 8); - nock.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0), - ); - arrowParts.push(nock); - - const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); - const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); - const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); - - for (const geometry of arrowParts) { - geometry.applyMatrix4(aim); - geometry.applyMatrix4(tilt); - geometry.applyMatrix4(shift); - } - - const merged = mergeGeometries([...targetParts, ...arrowParts]); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function makeDollarCoin() { - const parts = []; - const baseRadius = 1.3; - const baseDepth = 0.45; - const bevel = 0.18; - const points = []; - const segments = 20; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - Math.sin(angle) * (baseDepth / 2), - ), - ); - } - - points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - parts.push(disc); - - const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - frontRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), - ); - parts.push(frontRim); - - const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - backRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), - ); - parts.push(backRim); - - const createDollarSign = () => { - const geometries = []; - const tubeRadius = 0.1; - const curveRadius = 0.28; - const verticalOffset = 0.22; - - const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); - geometries.push(bar); - - const topArc = new THREE.TorusGeometry( - curveRadius, - tubeRadius, - 16, - 32, - Math.PI, - ); - topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), - ); - geometries.push(topArc); - - const bottomArc = new THREE.TorusGeometry( - curveRadius, - tubeRadius, - 16, - 32, - Math.PI, - ); - bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); - bottomArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), - ); - geometries.push(bottomArc); - - const topSerif = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - 0.22, - 12, - ); - topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation( - 0.16, - verticalOffset + curveRadius, - 0, - ), - ); - geometries.push(topSerif); - - const bottomSerif = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - 0.22, - 12, - ); - bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - bottomSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation( - -0.16, - -verticalOffset - curveRadius, - 0, - ), - ); - geometries.push(bottomSerif); - - const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); - const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); - const diagonal = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - diagonalLength + 0.12, - 12, - ); - diagonal.applyMatrix4( - new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), - ); - geometries.push(diagonal); - - return geometries; - }; - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), - ); - parts.push(geometry); - } - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), - ); - parts.push(geometry); - } - - const merged = mergeGeometries(parts); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function createBuiltinGeometry(shapeKey) { - switch (shapeKey) { - case 'torusKnot': - return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); - case 'sphere': - return new THREE.SphereGeometry(1.4, 64, 64); - case 'torus': - return new THREE.TorusGeometry(1, 0.45, 64, 100); - case 'icosahedron': - return new THREE.IcosahedronGeometry(1.4, 4); - case 'box': - return new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6); - case 'cone': - return new THREE.ConeGeometry(1.2, 2.4, 64, 10); - case 'cylinder': - return new THREE.CylinderGeometry(1, 1, 2.3, 64, 10); - case 'octahedron': - return new THREE.OctahedronGeometry(1.5, 2); - case 'dodecahedron': - return new THREE.DodecahedronGeometry(1.35, 1); - case 'tetrahedron': - return new THREE.TetrahedronGeometry(1.7, 1); - case 'sunCoin': - return makeReliefGeometry( - makePolarShape( - (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), - ), - { depth: 0.62, waves: 12, waveDepth: 0.018 }, - ); - case 'lotusCoin': - return makeReliefGeometry( - makePolarShape( - (angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2), - ), - { depth: 0.64, waves: 8, waveDepth: 0.014 }, - ); - case 'arrowTarget': - return makeArrowTarget(); - case 'dollarCoin': - return makeDollarCoin(); - default: - return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); - } -} - -const EMPTY_TEXTURE_DATA_URL = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; - -function createLoadingManager() { - const loadingManager = new THREE.LoadingManager(); - loadingManager.setURLModifier((url) => - /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, - ); - return loadingManager; -} - -function normalizeImportedGeometry(geometry, options = {}) { - const { useLegacyNormalization = false, postRotateZ = 0 } = options; - geometry.computeBoundingBox(); - - let boundingBox = geometry.boundingBox; - let center = new THREE.Vector3(); - let size = new THREE.Vector3(); - - boundingBox?.getCenter(center); - boundingBox?.getSize(size); - geometry.translate(-center.x, -center.y, -center.z); - - if (!useLegacyNormalization) { - const dimensions = [size.x, size.y, size.z]; - const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); - - if (thinnestAxis === 0) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); - } else if (thinnestAxis === 1) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - } - } - - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - const scale = useLegacyNormalization - ? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET / - Math.max(size.x, size.y, size.z, 0.001) - : 1.6 / (geometry.boundingSphere?.radius || 1); - geometry.scale(scale, scale, scale); - - geometry.computeBoundingBox(); - boundingBox = geometry.boundingBox; - center = new THREE.Vector3(); - boundingBox?.getCenter(center); - geometry.translate(-center.x, -center.y, -center.z); - - if (postRotateZ !== 0) { - geometry.rotateZ(postRotateZ); - } - - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function extractMergedGeometry(root, emptyMessage, geometryOptions) { - root.updateMatrixWorld(true); - const geometries = []; - - root.traverse((object) => { - if (!(object instanceof THREE.Mesh) || !object.geometry) { - return; - } - - const geometry = object.geometry.clone(); - - if (!geometry.attributes.normal) { - geometry.computeVertexNormals(); - } - - geometry.applyMatrix4(object.matrixWorld); - geometries.push(geometry); - }); - - if (geometries.length === 0) { - throw new Error(emptyMessage); - } - - return normalizeImportedGeometry( - mergeGeometries(geometries), - geometryOptions, - ); -} - -function parseFbxGeometry(buffer, label, geometryOptions) { - const originalWarn = console.warn; - - console.warn = (...args) => { - if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) { - return; - } - - originalWarn(...args); - }; - - try { - const root = new FBXLoader(createLoadingManager()).parse(buffer, ''); - return extractMergedGeometry( - root, - label + ' did not contain any mesh geometry.', - geometryOptions, - ); - } finally { - console.warn = originalWarn; - } -} - -function parseGlbGeometry(buffer, label, geometryOptions) { - return new Promise((resolve, reject) => { - const loadingManager = createLoadingManager(); - const dracoLoader = new DRACOLoader(loadingManager); - dracoLoader.setDecoderPath(DRACO_DECODER_PATH); - - const loader = new GLTFLoader(loadingManager); - loader.setDRACOLoader(dracoLoader); - - const cleanup = () => { - dracoLoader.dispose(); - }; - - loader.parse( - buffer, - '', - (gltf) => { - try { - resolve( - extractMergedGeometry( - gltf.scene, - label + ' did not contain any mesh geometry.', - geometryOptions, - ), - ); - } catch (error) { - reject(error); - } finally { - cleanup(); - } - }, - (error) => { - cleanup(); - reject(error); - }, - ); - }); -} - -async function loadImportedGeometryFromUrl( - loader, - modelUrl, - label, - geometryOptions, -) { - const response = await fetch(modelUrl); - - if (!response.ok) { - throw new Error('Unable to load ' + label + ' from ' + modelUrl + '.'); - } - - const buffer = await response.arrayBuffer(); - - if (loader === 'fbx') { - return parseFbxGeometry(buffer, label, geometryOptions); - } - - return parseGlbGeometry(buffer, label, geometryOptions); -} - -const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320; -const GLASS_ATTENUATION_DISTANCE_MIN = 0.12; -const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18; -const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12; -const GLASS_ENVIRONMENT_ZOOM = 1.55; -const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303); -const MAX_TEXTURE_ANISOTROPY = 8; -const HALFTONE_TRANSMISSION_SHADER_PREFIX = - '\nuniform float chromaticAberration;\nuniform float anisotropicBlur;\nuniform float time;\nuniform float distortion;\nuniform float distortionScale;\nuniform float temporalDistortion;\nuniform sampler2D buffer;\n\nvec3 random3(vec3 c) {\n float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0)));\n vec3 r;\n r.z = fract(512.0 * j);\n j *= 0.125;\n r.x = fract(512.0 * j);\n j *= 0.125;\n r.y = fract(512.0 * j);\n return r - 0.5;\n}\n\nuint hash(uint x) {\n x += (x << 10u);\n x ^= (x >> 6u);\n x += (x << 3u);\n x ^= (x >> 11u);\n x += (x << 15u);\n return x;\n}\n\nuint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); }\nuint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); }\nuint hash(uvec4 v) {\n return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w));\n}\n\nfloat floatConstruct(uint m) {\n const uint ieeeMantissa = 0x007FFFFFu;\n const uint ieeeOne = 0x3F800000u;\n m &= ieeeMantissa;\n m |= ieeeOne;\n float f = uintBitsToFloat(m);\n return f - 1.0;\n}\n\nfloat randomBase(float x) {\n return floatConstruct(hash(floatBitsToUint(x)));\n}\nfloat randomBase(vec2 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\nfloat randomBase(vec3 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\nfloat randomBase(vec4 v) {\n return floatConstruct(hash(floatBitsToUint(v)));\n}\n\nfloat rand(float seed) {\n return randomBase(vec3(gl_FragCoord.xy, seed));\n}\n\nconst float F3 = 0.3333333;\nconst float G3 = 0.1666667;\n\nfloat snoise(vec3 p) {\n vec3 s = floor(p + dot(p, vec3(F3)));\n vec3 x = p - s + dot(s, vec3(G3));\n vec3 e = step(vec3(0.0), x - x.yzx);\n vec3 i1 = e * (1.0 - e.zxy);\n vec3 i2 = 1.0 - e.zxy * (1.0 - e);\n vec3 x1 = x - i1 + G3;\n vec3 x2 = x - i2 + 2.0 * G3;\n vec3 x3 = x - 1.0 + 3.0 * G3;\n vec4 w;\n vec4 d;\n w.x = dot(x, x);\n w.y = dot(x1, x1);\n w.z = dot(x2, x2);\n w.w = dot(x3, x3);\n w = max(0.6 - w, 0.0);\n d.x = dot(random3(s), x);\n d.y = dot(random3(s + i1), x1);\n d.z = dot(random3(s + i2), x2);\n d.w = dot(random3(s + 1.0), x3);\n w *= w;\n w *= w;\n d *= w;\n return dot(d, vec4(52.0));\n}\n\nfloat snoiseFractal(vec3 m) {\n return 0.5333333 * snoise(m)\n + 0.2666667 * snoise(2.0 * m)\n + 0.1333333 * snoise(4.0 * m)\n + 0.0666667 * snoise(8.0 * m);\n}\n'; -const HALFTONE_TRANSMISSION_PARS_FRAGMENT = - '\n#ifdef USE_TRANSMISSION\n uniform float _transmission;\n uniform float thickness;\n uniform float attenuationDistance;\n uniform vec3 attenuationColor;\n uniform sampler2D refractionEnvMap;\n uniform float useEnvMapRefraction;\n #ifdef USE_TRANSMISSIONMAP\n uniform sampler2D transmissionMap;\n #endif\n #ifdef USE_THICKNESSMAP\n uniform sampler2D thicknessMap;\n #endif\n uniform vec2 transmissionSamplerSize;\n uniform sampler2D transmissionSamplerMap;\n uniform mat4 modelMatrix;\n uniform mat4 projectionMatrix;\n varying vec3 vWorldPosition;\n\n vec3 getVolumeTransmissionRay(\n const in vec3 n,\n const in vec3 v,\n const in float thicknessValue,\n const in float ior,\n const in mat4 modelMatrix\n ) {\n vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior);\n vec3 modelScale;\n modelScale.x = length(vec3(modelMatrix[0].xyz));\n modelScale.y = length(vec3(modelMatrix[1].xyz));\n modelScale.z = length(vec3(modelMatrix[2].xyz));\n return normalize(refractionVector) * thicknessValue * modelScale;\n }\n\n float applyIorToRoughness(\n const in float roughnessValue,\n const in float ior\n ) {\n return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0);\n }\n\n vec2 directionToEquirectUv(const in vec3 direction) {\n vec3 dir = normalize(direction);\n vec2 uv = vec2(\n atan(dir.z, dir.x) * 0.15915494309189535 + 0.5,\n asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5\n );\n\n return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0));\n }\n\n vec4 getTransmissionSample(\n const in vec2 fragCoord,\n const in vec3 transmissionDirection,\n const in float roughnessValue,\n const in float ior\n ) {\n if (useEnvMapRefraction > 0.5) {\n return texture2D(\n refractionEnvMap,\n directionToEquirectUv(transmissionDirection)\n );\n }\n\n float framebufferLod =\n log2(transmissionSamplerSize.x) *\n applyIorToRoughness(roughnessValue, ior);\n return texture2D(buffer, fragCoord.xy);\n }\n\n vec3 applyVolumeAttenuation(\n const in vec3 radiance,\n const in float transmissionDistance,\n const in vec3 attenuationColorValue,\n const in float attenuationDistanceValue\n ) {\n if (isinf(attenuationDistanceValue)) {\n return radiance;\n }\n\n vec3 attenuationCoefficient =\n -log(attenuationColorValue) / attenuationDistanceValue;\n vec3 transmittance =\n exp(-attenuationCoefficient * transmissionDistance);\n\n return transmittance * radiance;\n }\n\n vec4 getIBLVolumeRefraction(\n const in vec3 n,\n const in vec3 v,\n const in float roughnessValue,\n const in vec3 diffuseColor,\n const in vec3 specularColor,\n const in float specularF90,\n const in vec3 position,\n const in mat4 modelMatrix,\n const in mat4 viewMatrix,\n const in mat4 projMatrix,\n const in float ior,\n const in float thicknessValue,\n const in vec3 attenuationColorValue,\n const in float attenuationDistanceValue\n ) {\n vec3 transmissionRay = getVolumeTransmissionRay(\n n,\n v,\n thicknessValue,\n ior,\n modelMatrix\n );\n vec3 refractedRayExit = position + transmissionRay;\n vec4 ndcPos =\n projMatrix * viewMatrix * vec4(refractedRayExit, 1.0);\n vec2 refractionCoords = ndcPos.xy / ndcPos.w;\n refractionCoords += 1.0;\n refractionCoords /= 2.0;\n vec3 transmissionDirection = normalize(transmissionRay);\n vec4 transmittedLight = getTransmissionSample(\n refractionCoords,\n transmissionDirection,\n roughnessValue,\n ior\n );\n vec3 attenuatedColor = applyVolumeAttenuation(\n transmittedLight.rgb,\n length(transmissionRay),\n attenuationColorValue,\n attenuationDistanceValue\n );\n vec3 F = EnvironmentBRDF(\n n,\n v,\n specularColor,\n specularF90,\n roughnessValue\n );\n return vec4(\n (1.0 - F) * attenuatedColor * diffuseColor,\n transmittedLight.a\n );\n }\n#endif\n'; -const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = - '\nmaterial.transmission = _transmission;\nmaterial.transmissionAlpha = 1.0;\nmaterial.thickness = thickness;\nmaterial.attenuationDistance = attenuationDistance;\nmaterial.attenuationColor = attenuationColor;\n#ifdef USE_TRANSMISSIONMAP\n material.transmission *= texture2D(transmissionMap, vUv).r;\n#endif\n#ifdef USE_THICKNESSMAP\n material.thickness *= texture2D(thicknessMap, vUv).g;\n#endif\n\nvec3 pos = vWorldPosition;\nfloat runningSeed = 0.0;\nvec3 v = normalize(cameraPosition - pos);\nvec3 n = inverseTransformDirection(normal, viewMatrix);\nvec3 transmission = vec3(0.0);\nfloat transmissionR;\nfloat transmissionG;\nfloat transmissionB;\nfloat randomCoords = rand(runningSeed++);\nfloat thicknessSmear =\n thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur);\nvec3 distortionNormal = vec3(0.0);\nvec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion;\n\nif (distortion > 0.0) {\n distortionNormal = distortion * vec3(\n snoiseFractal(vec3(pos * distortionScale + temporalOffset)),\n snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)),\n snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset))\n );\n}\n\nfor (float i = 0.0; i < __SAMPLES__.0; i++) {\n vec3 sampleNorm = normalize(\n n +\n roughnessFactor * roughnessFactor * 2.0 *\n normalize(\n vec3(\n rand(runningSeed++) - 0.5,\n rand(runningSeed++) - 0.5,\n rand(runningSeed++) - 0.5\n )\n ) *\n pow(rand(runningSeed++), 0.33) +\n distortionNormal\n );\n\n transmissionR = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior,\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).r;\n\n transmissionG = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).g;\n\n transmissionB = getIBLVolumeRefraction(\n sampleNorm,\n v,\n material.roughness,\n material.diffuseColor,\n material.specularColor,\n material.specularF90,\n pos,\n modelMatrix,\n viewMatrix,\n projectionMatrix,\n material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),\n material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),\n material.attenuationColor,\n material.attenuationDistance\n ).b;\n\n transmission.r += transmissionR;\n transmission.g += transmissionG;\n transmission.b += transmissionB;\n}\n\ntransmission /= __SAMPLES__.0;\ntotalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission);\n'; - -class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial { - constructor(samples = 10) { - super(); - - this.halftoneUniforms = { - chromaticAberration: { value: 0.05 }, - transmission: { value: 0 }, - _transmission: { value: 1 }, - transmissionMap: { value: null }, - refractionEnvMap: { value: null }, - useEnvMapRefraction: { value: 0 }, - roughness: { value: 0 }, - thickness: { value: 0 }, - thicknessMap: { value: null }, - attenuationDistance: { value: Infinity }, - attenuationColor: { value: new THREE.Color('white') }, - anisotropicBlur: { value: 0.1 }, - time: { value: 0 }, - distortion: { value: 0 }, - distortionScale: { value: 0.5 }, - temporalDistortion: { value: 0 }, - buffer: { value: null }, - }; - - this.customProgramCacheKey = () => 'halftone-transmission-' + samples; - - this.onBeforeCompile = (shader) => { - shader.uniforms = { - ...shader.uniforms, - ...this.halftoneUniforms, - }; - shader.defines ??= {}; - - if (this.anisotropy > 0) { - shader.defines.USE_ANISOTROPY = ''; - } - - shader.defines.USE_TRANSMISSION = ''; - shader.fragmentShader = - HALFTONE_TRANSMISSION_SHADER_PREFIX + shader.fragmentShader; - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - HALFTONE_TRANSMISSION_PARS_FRAGMENT, - ); - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE.replaceAll( - '__SAMPLES__', - String(samples), - ), - ); - }; - - Object.keys(this.halftoneUniforms).forEach((key) => { - Object.defineProperty(this, key, { - configurable: true, - enumerable: true, - get: () => this.halftoneUniforms[key]?.value, - set: (value) => { - this.halftoneUniforms[key].value = value; - }, - }); - }); - } -} - -function setTextureSampling(texture, renderer) { - texture.generateMipmaps = true; - texture.magFilter = THREE.LinearFilter; - texture.minFilter = THREE.LinearMipmapLinearFilter; - texture.anisotropy = Math.min( - renderer.capabilities.getMaxAnisotropy(), - MAX_TEXTURE_ANISOTROPY, - ); -} - -function disposeEnvironmentScene(scene) { - scene.traverse((object) => { - if (object.geometry) { - object.geometry.dispose(); - } - - if (Array.isArray(object.material)) { - object.material.forEach((material) => material.dispose()); - return; - } - - object.material?.dispose?.(); - }); -} - -function createSolidEnvironmentTexture(renderer) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = pmremGenerator.fromScene( - new RoomEnvironment(), - 0.04, - ).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function getTextureImageSize(texture) { - const image = texture.image; - - return { - height: - image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined, - width: - image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined, - }; -} - -function createZoomedGlassTexture(sourceTexture, renderer, zoom) { - if (zoom <= 1) { - return sourceTexture; - } - - const { width, height } = getTextureImageSize(sourceTexture); - - if (!width || !height) { - return sourceTexture; - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext('2d'); - - if (!context) { - return sourceTexture; - } - - const cropWidth = width / zoom; - const cropHeight = height / zoom; - const sourceX = (width - cropWidth) / 2; - const sourceY = (height - cropHeight) / 2; - - context.drawImage( - sourceTexture.image, - sourceX, - sourceY, - cropWidth, - cropHeight, - 0, - 0, - width, - height, - ); - - const zoomedTexture = new THREE.CanvasTexture(canvas); - zoomedTexture.colorSpace = sourceTexture.colorSpace; - zoomedTexture.wrapS = THREE.ClampToEdgeWrapping; - zoomedTexture.wrapT = THREE.ClampToEdgeWrapping; - setTextureSampling(zoomedTexture, renderer); - zoomedTexture.needsUpdate = true; - - return zoomedTexture; -} - -function createStudioGlassEnvironmentTexture(renderer, backdropTexture) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = backdropTexture - ? pmremGenerator.fromEquirectangular(backdropTexture).texture - : pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function createFallbackGlassBackdropTexture(renderer) { - const texture = new THREE.DataTexture( - new Uint8Array([3, 3, 3, 255]), - 1, - 1, - THREE.RGBAFormat, - ); - texture.colorSpace = THREE.SRGBColorSpace; - texture.wrapS = THREE.ClampToEdgeWrapping; - texture.wrapT = THREE.ClampToEdgeWrapping; - texture.mapping = THREE.EquirectangularReflectionMapping; - setTextureSampling(texture, renderer); - texture.needsUpdate = true; - - return texture; -} - -function loadTexture(url, renderer, colorSpace) { - const loader = new THREE.TextureLoader(); - - return new Promise((resolve, reject) => { - loader.load( - url, - (texture) => { - texture.colorSpace = colorSpace; - setTextureSampling(texture, renderer); - resolve(texture); - }, - undefined, - reject, - ); - }); -} - -async function loadGlassEnvironmentTexture(renderer) { - const sourceBackgroundTexture = await loadTexture( - GLASS_ENVIRONMENT_TEXTURE_URL, - renderer, - THREE.SRGBColorSpace, - ); - const backgroundTexture = createZoomedGlassTexture( - sourceBackgroundTexture, - renderer, - GLASS_ENVIRONMENT_ZOOM, - ); - if (backgroundTexture !== sourceBackgroundTexture) { - sourceBackgroundTexture.dispose(); - } - backgroundTexture.mapping = THREE.EquirectangularReflectionMapping; - backgroundTexture.wrapS = THREE.ClampToEdgeWrapping; - backgroundTexture.wrapT = THREE.ClampToEdgeWrapping; - backgroundTexture.needsUpdate = true; - const environmentTexture = createStudioGlassEnvironmentTexture( - renderer, - backgroundTexture, - ); - - return { - backgroundTexture, - environmentTexture, - }; -} - -async function createHalftoneMaterialAssets(renderer) { - const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer); - - try { - const glassEnvironmentAssets = await loadGlassEnvironmentTexture(renderer); - - return { - glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture, - glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture, - solidEnvironmentTexture, - }; - } catch { - const fallbackGlassBackdropTexture = - createFallbackGlassBackdropTexture(renderer); - const fallbackGlassEnvironmentTexture = - createStudioGlassEnvironmentTexture(renderer); - - return { - glassBackgroundTexture: fallbackGlassBackdropTexture, - glassEnvironmentTexture: fallbackGlassEnvironmentTexture, - solidEnvironmentTexture, - }; - } -} - -function createHalftoneMaterial() { - return new HalftoneTransmissionMaterial(); -} - -function applyHalftoneMaterialSettings( - material, - materialSettings, - materialAssets, -) { - const isGlass = materialSettings.surface === 'glass'; - const glassThickness = - materialSettings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS; - const glassEnvironmentIntensity = - GLASS_ENVIRONMENT_INTENSITY_BASE + - materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER; - const glassAttenuationDistance = Math.max( - glassThickness * 4, - GLASS_ATTENUATION_DISTANCE_MIN, - ); - - material.color.set(isGlass ? '#ffffff' : materialSettings.color); - material.roughness = materialSettings.roughness; - material.metalness = materialSettings.metalness; - material.envMap = isGlass - ? materialAssets.glassEnvironmentTexture - : materialAssets.solidEnvironmentTexture; - material.envMapIntensity = isGlass - ? GLASS_ENVIRONMENT_INTENSITY_BASE + - materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER - : 0.25; - material.clearcoat = isGlass ? 1 : 0; - material.clearcoatRoughness = isGlass - ? Math.max(materialSettings.roughness * 0.25, 0.01) - : 0.08; - material.reflectivity = isGlass ? 0.98 : 0.5; - material.transmission = 0; - material._transmission = isGlass ? 1 : 0; - material.refractionEnvMap = isGlass - ? materialAssets.glassBackgroundTexture - : null; - material.useEnvMapRefraction = isGlass ? 1 : 0; - material.thickness = isGlass ? glassThickness : 0; - material.ior = isGlass ? materialSettings.refraction : 1.5; - material.buffer = null; - material.bumpMap = null; - material.bumpScale = 0; - material.roughnessMap = null; - material.side = THREE.FrontSide; - material.transparent = false; - material.opacity = 1; - material.depthWrite = true; - material.attenuationColor.set(isGlass ? materialSettings.color : 'white'); - material.attenuationDistance = isGlass ? glassAttenuationDistance : Infinity; - material.anisotropicBlur = isGlass - ? THREE.MathUtils.lerp(0.03, 0.12, materialSettings.roughness) - : 0.1; - material.chromaticAberration = isGlass ? 0 : 0.05; - material.distortion = 0; - material.distortionScale = 0.5; - material.temporalDistortion = 0; - material.userData.halftoneIsGlass = isGlass; - material.userData.halftoneGlassBacksideThickness = isGlass - ? glassThickness * 2 - : 0; - material.userData.halftoneGlassBacksideEnvIntensity = isGlass - ? glassEnvironmentIntensity * 2.8 - : 0; - material.userData.halftoneUseEnvironmentRefraction = isGlass; - material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25; - - material.needsUpdate = true; -} - -function disposeHalftoneMaterialAssets(materialAssets) { - materialAssets.glassBackgroundTexture.dispose(); - - if ( - materialAssets.glassEnvironmentTexture !== - materialAssets.glassBackgroundTexture - ) { - materialAssets.glassEnvironmentTexture.dispose(); - } - - materialAssets.solidEnvironmentTexture.dispose(); -} - -function createRenderTarget(width, height) { - return new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); -} - -function createInteractionState(initialPoseConfig = initialPose) { - return { - autoElapsed: initialPoseConfig.autoElapsed, - activePointerId: null, - dragging: false, - mouseX: 0.5, - mouseY: 0.5, - pointerInside: false, - pointerVelocityX: 0, - pointerVelocityY: 0, - pointerX: 0, - pointerY: 0, - rotateElapsed: initialPoseConfig.rotateElapsed, - rotationX: initialPoseConfig.rotationX, - rotationVelocityX: 0, - rotationY: initialPoseConfig.rotationY, - rotationVelocityY: 0, - rotationZ: initialPoseConfig.rotationZ, - rotationVelocityZ: 0, - smoothedMouseX: 0.5, - smoothedMouseY: 0.5, - targetRotationX: initialPoseConfig.targetRotationX, - targetRotationY: initialPoseConfig.targetRotationY, - velocityX: 0, - velocityY: 0, - }; -} - -function setPrimaryLightPosition(light, angleDegrees, height) { - const lightAngle = (angleDegrees * Math.PI) / 180; - light.position.set( - Math.cos(lightAngle) * 5, - height, - Math.sin(lightAngle) * 5, - ); -} - -function applySpringStep(current, target, velocity, strength, damping) { - const nextVelocity = (velocity + (target - current) * strength) * damping; - const nextValue = current + nextVelocity; - - return { - value: nextValue, - velocity: nextVelocity, - }; -} - -function resetInteractionState(interactionState) { - interactionState.dragging = false; - interactionState.mouseX = 0.5; - interactionState.mouseY = 0.5; - interactionState.targetRotationX = 0; - interactionState.targetRotationY = 0; - interactionState.velocityX = 0; - interactionState.velocityY = 0; - interactionState.rotationVelocityX = 0; - interactionState.rotationVelocityY = 0; - interactionState.rotationVelocityZ = 0; - interactionState.autoElapsed = 0; -} - -async function createGeometry(modelUrl, geometryOptions) { - if (shape.kind === 'imported' && shape.loader && modelUrl) { - return loadImportedGeometryFromUrl( - shape.loader, - modelUrl, - modelUrl.split('/').pop() ?? shape.label, - geometryOptions, - ); - } - - return createBuiltinGeometry(shape.key); -} - -async function mountHalftoneCanvas(options) { - const { - animationOverrides, - container, - initialRotationX, - initialRotationY, - initialRotationZ, - meshScaleMultiplier = 1, - modelUrl, - onError, - } = options; - const modelOverrides = getModelOverrides(modelUrl); - const resolvedAnimation = { - ...settings.animation, - ...modelOverrides?.animation, - ...animationOverrides, - }; - const resolvedInitialPose = { - ...initialPose, - ...modelOverrides?.initialPose, - rotationX: - initialRotationX ?? - modelOverrides?.initialPose?.rotationX ?? - initialPose.rotationX, - rotationY: - initialRotationY ?? - modelOverrides?.initialPose?.rotationY ?? - initialPose.rotationY, - rotationZ: - initialRotationZ ?? - modelOverrides?.initialPose?.rotationZ ?? - initialPose.rotationZ, - }; - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))), - 1, - ); - - let geometry; - - try { - geometry = await createGeometry(modelUrl, modelOverrides?.importedGeometry); - } catch (error) { - onError?.(error); - geometry = createBuiltinGeometry('torusKnot'); - } - - const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true }); - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor(0x000000, 0); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.cursor = resolvedAnimation.followDragEnabled - ? 'grab' - : 'default'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const materialAssets = await createHalftoneMaterialAssets(renderer); - - const scene3d = new THREE.Scene(); - scene3d.background = null; - - const baseCameraDistance = previewDistance; - const camera = new THREE.PerspectiveCamera( - 45, - getWidth() / getHeight(), - 0.1, - 100, - ); - camera.position.z = baseCameraDistance; - - const primaryLight = new THREE.DirectionalLight( - 0xffffff, - settings.lighting.intensity, - ); - setPrimaryLightPosition( - primaryLight, - settings.lighting.angleDegrees, - settings.lighting.height, - ); - scene3d.add(primaryLight); - - const fillLight = new THREE.DirectionalLight( - 0xffffff, - settings.lighting.fillIntensity, - ); - fillLight.position.set(-3, -1, 1); - scene3d.add(fillLight); - - const ambientLight = new THREE.AmbientLight( - 0xffffff, - settings.lighting.ambientIntensity, - ); - scene3d.add(ambientLight); - - const material = createHalftoneMaterial(); - applyHalftoneMaterialSettings(material, settings.material, materialAssets); - - const mesh = new THREE.Mesh(geometry, material); - scene3d.add(mesh); - - const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - effectResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - logicalResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - tile: { value: settings.halftone.scale }, - s_3: { value: settings.halftone.power }, - s_4: { value: settings.halftone.width }, - dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, - hoverDashColor: { - value: new THREE.Color(settings.halftone.hoverDashColor), - }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: 1 }, - footprintScale: { value: 1.0 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - interactionVelocity: { value: new THREE.Vector2(0, 0) }, - dragOffset: { value: new THREE.Vector2(0, 0) }, - hoverHalftoneActive: { value: 0 }, - hoverHalftonePowerShift: { value: 0 }, - hoverHalftoneRadius: { value: 0.2 }, - hoverHalftoneWidthShift: { value: 0 }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: 0.2 }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - cropToBounds: { value: 0 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add( - new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), - ); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const updateViewportUniforms = ( - logicalWidth, - logicalHeight, - effectWidth, - effectHeight, - ) => { - blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - halftoneMaterial.uniforms.effectResolution.value.set( - effectWidth, - effectHeight, - ); - halftoneMaterial.uniforms.logicalResolution.value.set( - logicalWidth, - logicalHeight, - ); - }; - - const getHalftoneScale = (viewportWidth, viewportHeight, lookAtTarget) => { - if (!mesh.geometry.boundingBox) { - mesh.geometry.computeBoundingBox(); - } - - if (!mesh.geometry.boundingBox) { - return 1; - } - - mesh.updateMatrixWorld(); - camera.updateMatrixWorld(); - - return getMeshFootprintScale({ - camera, - localBounds: mesh.geometry.boundingBox, - lookAtTarget, - meshMatrixWorld: mesh.matrixWorld, - viewportHeight, - viewportWidth, - }); - }; - - const interaction = createInteractionState(resolvedInitialPose); - const autoRotateEnabled = resolvedAnimation.autoRotateEnabled; - const followHoverEnabled = resolvedAnimation.followHoverEnabled; - const followDragEnabled = resolvedAnimation.followDragEnabled; - const rotateEnabled = resolvedAnimation.rotateEnabled; - - const syncSize = () => { - const width = getWidth(); - const height = getHeight(); - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - updateViewportUniforms( - virtualWidth, - virtualHeight, - virtualWidth, - virtualHeight, - ); - }; - - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); - - const updatePointerPosition = (event) => { - const rect = canvas.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - - interaction.mouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, - 0, - 1, - ); - interaction.mouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, - 0, - 1, - ); - }; - - const handlePointerDown = (event) => { - updatePointerPosition(event); - if (!followDragEnabled) { - return; - } - - interaction.dragging = true; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - interaction.velocityX = 0; - interaction.velocityY = 0; - canvas.style.cursor = 'grabbing'; - }; - - const handlePointerMove = (event) => { - updatePointerPosition(event); - }; - - const handleWindowPointerMove = (event) => { - updatePointerPosition(event); - - if (!interaction.dragging || !followDragEnabled) { - return; - } - - const deltaX = - (event.clientX - interaction.pointerX) * resolvedAnimation.dragSens; - const deltaY = - (event.clientY - interaction.pointerY) * resolvedAnimation.dragSens; - interaction.velocityX = deltaY; - interaction.velocityY = deltaX; - interaction.targetRotationY += deltaX; - interaction.targetRotationX += deltaY; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - }; - - const handlePointerLeave = () => { - if (interaction.dragging) { - return; - } - - interaction.mouseX = 0.5; - interaction.mouseY = 0.5; - }; - - const handlePointerUp = () => { - interaction.dragging = false; - canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; - - if (!resolvedAnimation.springReturnEnabled) { - return; - } - - const springImpulse = Math.max(resolvedAnimation.springStrength * 10, 1.2); - interaction.rotationVelocityX += interaction.velocityX * springImpulse; - interaction.rotationVelocityY += interaction.velocityY * springImpulse; - interaction.rotationVelocityZ += - interaction.velocityY * springImpulse * 0.12; - interaction.targetRotationX = 0; - interaction.targetRotationY = 0; - interaction.velocityX = 0; - interaction.velocityY = 0; - }; - - const handleWindowBlur = () => { - handlePointerUp(); - handlePointerLeave(); - }; - - const handlePointerCancel = () => { - handlePointerUp(); - handlePointerLeave(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointercancel', handlePointerCancel); - window.addEventListener('pointerup', handlePointerUp); - window.addEventListener('pointermove', handleWindowPointerMove); - window.addEventListener('blur', handleWindowBlur); - canvas.addEventListener('pointerdown', handlePointerDown); - - const clock = new THREE.Timer(); - clock.connect(document); - let animationFrameId = 0; - - const renderFrame = (timestamp) => { - animationFrameId = window.requestAnimationFrame(renderFrame); - clock.update(timestamp); - - const delta = 1 / 60; - const elapsedTime = resolvedInitialPose.timeElapsed + clock.getElapsed(); - halftoneMaterial.uniforms.time.value = elapsedTime; - - let baseRotationX = resolvedInitialPose.rotationX; - let baseRotationY = resolvedInitialPose.rotationY; - let baseRotationZ = resolvedInitialPose.rotationZ; - let meshOffsetY = 0; - let meshScale = meshScaleMultiplier; - let lightAngle = settings.lighting.angleDegrees; - let lightHeight = settings.lighting.height; - - if (autoRotateEnabled) { - interaction.autoElapsed += delta; - baseRotationY += interaction.autoElapsed * resolvedAnimation.autoSpeed; - baseRotationX += - Math.sin(interaction.autoElapsed * 0.2) * resolvedAnimation.autoWobble; - } - - if (resolvedAnimation.floatEnabled) { - const floatPhase = elapsedTime * resolvedAnimation.floatSpeed; - const driftAmount = (resolvedAnimation.driftAmount * Math.PI) / 180; - - meshOffsetY += Math.sin(floatPhase) * resolvedAnimation.floatAmplitude; - baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45; - baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3; - } - - if (resolvedAnimation.breatheEnabled) { - meshScale *= - 1 + - Math.sin(elapsedTime * resolvedAnimation.breatheSpeed) * - resolvedAnimation.breatheAmount; - } - - if (rotateEnabled) { - interaction.rotateElapsed += delta; - const rotateProgress = resolvedAnimation.rotatePingPong - ? Math.sin(interaction.rotateElapsed * resolvedAnimation.rotateSpeed) * - Math.PI - : interaction.rotateElapsed * resolvedAnimation.rotateSpeed; - - if (resolvedAnimation.rotatePreset === 'axis') { - const axisDirection = resolvedAnimation.rotateAxis.startsWith('-') - ? -1 - : 1; - const axisProgress = rotateProgress * axisDirection; - - if ( - resolvedAnimation.rotateAxis === 'x' || - resolvedAnimation.rotateAxis === 'xy' || - resolvedAnimation.rotateAxis === '-x' || - resolvedAnimation.rotateAxis === '-xy' - ) { - baseRotationX += axisProgress; - } - - if ( - resolvedAnimation.rotateAxis === 'y' || - resolvedAnimation.rotateAxis === 'xy' || - resolvedAnimation.rotateAxis === '-y' || - resolvedAnimation.rotateAxis === '-xy' - ) { - baseRotationY += axisProgress; - } - - if ( - resolvedAnimation.rotateAxis === 'z' || - resolvedAnimation.rotateAxis === '-z' - ) { - baseRotationZ += axisProgress; - } - } else if (resolvedAnimation.rotatePreset === 'lissajous') { - baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65; - baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05; - baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32; - } else if (resolvedAnimation.rotatePreset === 'orbit') { - baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42; - baseRotationY += Math.cos(rotateProgress) * 1.2; - baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24; - } else if (resolvedAnimation.rotatePreset === 'tumble') { - baseRotationX += rotateProgress * 0.55; - baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9; - baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38; - } - } - - if (resolvedAnimation.lightSweepEnabled) { - const lightPhase = elapsedTime * resolvedAnimation.lightSweepSpeed; - lightAngle += Math.sin(lightPhase) * resolvedAnimation.lightSweepRange; - lightHeight += - Math.cos(lightPhase * 0.85) * resolvedAnimation.lightSweepHeightRange; - } - - let targetX = baseRotationX; - let targetY = baseRotationY; - let easing = 0.12; - - if (followHoverEnabled) { - const rangeRadians = (resolvedAnimation.hoverRange * Math.PI) / 180; - - if ( - resolvedAnimation.hoverReturn || - interaction.mouseX !== 0.5 || - interaction.mouseY !== 0.5 - ) { - targetX += (interaction.mouseY - 0.5) * rangeRadians; - targetY += (interaction.mouseX - 0.5) * rangeRadians; - } - - easing = resolvedAnimation.hoverEase; - } - - if (followDragEnabled) { - if (!interaction.dragging && resolvedAnimation.dragMomentum) { - interaction.targetRotationX += interaction.velocityX; - interaction.targetRotationY += interaction.velocityY; - interaction.velocityX *= 1 - resolvedAnimation.dragFriction; - interaction.velocityY *= 1 - resolvedAnimation.dragFriction; - } - - targetX += interaction.targetRotationX; - targetY += interaction.targetRotationY; - easing = resolvedAnimation.dragFriction; - } - - if (autoRotateEnabled && !followHoverEnabled && !followDragEnabled) { - targetX = baseRotationX + interaction.targetRotationX; - targetY = baseRotationY + interaction.targetRotationY; - - if (interaction.dragging) { - targetX = interaction.targetRotationX; - targetY = interaction.targetRotationY; - } - - easing = 0.08; - } - - if (resolvedAnimation.springReturnEnabled) { - const springX = applySpringStep( - interaction.rotationX, - targetX, - interaction.rotationVelocityX, - resolvedAnimation.springStrength, - resolvedAnimation.springDamping, - ); - const springY = applySpringStep( - interaction.rotationY, - targetY, - interaction.rotationVelocityY, - resolvedAnimation.springStrength, - resolvedAnimation.springDamping, - ); - const springZ = applySpringStep( - interaction.rotationZ, - baseRotationZ, - interaction.rotationVelocityZ, - resolvedAnimation.springStrength, - resolvedAnimation.springDamping, - ); - - interaction.rotationX = springX.value; - interaction.rotationY = springY.value; - interaction.rotationZ = springZ.value; - interaction.rotationVelocityX = springX.velocity; - interaction.rotationVelocityY = springY.velocity; - interaction.rotationVelocityZ = springZ.velocity; - } else { - interaction.rotationX += (targetX - interaction.rotationX) * easing; - interaction.rotationY += (targetY - interaction.rotationY) * easing; - interaction.rotationZ += - (baseRotationZ - interaction.rotationZ) * - (resolvedAnimation.rotatePingPong ? 0.18 : 0.12); - } - - mesh.rotation.set( - interaction.rotationX, - interaction.rotationY, - interaction.rotationZ, - ); - mesh.position.y = meshOffsetY; - mesh.scale.setScalar(meshScale); - - if (resolvedAnimation.cameraParallaxEnabled) { - const cameraRange = resolvedAnimation.cameraParallaxAmount; - const cameraEase = resolvedAnimation.cameraParallaxEase; - const centeredX = (interaction.mouseX - 0.5) * 2; - const centeredY = (0.5 - interaction.mouseY) * 2; - const orbitYaw = centeredX * cameraRange; - const orbitPitch = centeredY * cameraRange * 0.7; - const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance; - const targetCameraX = Math.sin(orbitYaw) * horizontalRadius; - const targetCameraY = Math.sin(orbitPitch) * baseCameraDistance * 0.85; - const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius; - - camera.position.x += (targetCameraX - camera.position.x) * cameraEase; - camera.position.y += (targetCameraY - camera.position.y) * cameraEase; - camera.position.z += (targetCameraZ - camera.position.z) * cameraEase; - } else { - camera.position.x += (0 - camera.position.x) * 0.12; - camera.position.y += (0 - camera.position.y) * 0.12; - camera.position.z += (baseCameraDistance - camera.position.z) * 0.12; - } - - const lookAtTarget = new THREE.Vector3(0, meshOffsetY * 0.2, 0); - - camera.lookAt(lookAtTarget); - setPrimaryLightPosition(primaryLight, lightAngle, lightHeight); - halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale( - getVirtualWidth(), - getVirtualHeight(), - lookAtTarget, - ); - - if (!settings.halftone.enabled) { - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(scene3d, camera); - return; - } - - renderer.setRenderTarget(sceneTarget); - renderer.render(scene3d, camera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderFrame(); - - return () => { - window.cancelAnimationFrame(animationFrameId); - clock.dispose(); - resizeObserver.disconnect(); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - canvas.removeEventListener('pointercancel', handlePointerCancel); - window.removeEventListener('pointerup', handlePointerUp); - window.removeEventListener('pointermove', handleWindowPointerMove); - window.removeEventListener('blur', handleWindowBlur); - canvas.removeEventListener('pointerdown', handlePointerDown); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - fullScreenGeometry.dispose(); - material.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - disposeHalftoneMaterialAssets(materialAssets); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; -} +import { + createFallbackGeometry, + HalftoneCanvas, + loadImportedGeometryFromUrl, + type HalftoneAnimationSettings, + type HalftoneExportPose, +} from '@/lib/halftone'; +import { type CSSProperties, useEffect, useState } from 'react'; +import type * as THREE from 'three'; + +import { + buildPartnerThreeCardInitialPose, + buildPartnerThreeCardSettings, + getPartnerThreeCardGeometryOptions, + PARTNER_THREE_CARD_PREVIEW_DISTANCE, +} from './partner-three-card-config'; type PartnerThreeCardProps = { - animationOverrides?: Partial; + animationOverrides?: Partial; initialRotationX?: number; initialRotationY?: number; initialRotationZ?: number; @@ -2125,6 +27,46 @@ type PartnerThreeCardProps = { style?: CSSProperties; }; +const noopFirstInteraction = () => {}; +const noopPoseChange = (_pose: HalftoneExportPose) => {}; + +const loadPartnerThreeCardGeometry = async ({ + meshScaleMultiplier, + modelUrl, +}: { + meshScaleMultiplier: number; + modelUrl: string; +}) => { + let geometry: THREE.BufferGeometry; + + try { + geometry = await loadImportedGeometryFromUrl( + 'glb', + modelUrl, + modelUrl.split('/').pop() ?? 'three-card illustration', + getPartnerThreeCardGeometryOptions(modelUrl), + ); + } catch (error) { + if (process.env.NODE_ENV !== 'production') { + console.error('PartnerThreeCard geometry failed to load:', error); + } + + geometry = createFallbackGeometry(); + } + + if (meshScaleMultiplier !== 1) { + geometry.scale( + meshScaleMultiplier, + meshScaleMultiplier, + meshScaleMultiplier, + ); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + } + + return geometry; +}; + export function PartnerThreeCard({ animationOverrides, initialRotationX, @@ -2134,50 +76,58 @@ export function PartnerThreeCard({ modelUrl, style, }: PartnerThreeCardProps) { - const mountReference = useRef(null); + const [geometry, setGeometry] = useState(null); useEffect(() => { - const container = mountReference.current; + let cancelled = false; + let loadedGeometry: THREE.BufferGeometry | null = null; - if (!container) { - return; - } + setGeometry(null); - const unmount = mountHalftoneCanvas({ - animationOverrides, - container, - initialRotationX, - initialRotationY, - initialRotationZ, - meshScaleMultiplier, - modelUrl, - onError: (error) => { - console.error(error); + void loadPartnerThreeCardGeometry({ meshScaleMultiplier, modelUrl }).then( + (nextGeometry) => { + if (cancelled) { + nextGeometry.dispose(); + return; + } + + loadedGeometry = nextGeometry; + setGeometry(nextGeometry); }, - }); + ); return () => { - void Promise.resolve(unmount).then((dispose) => dispose?.()); + cancelled = true; + loadedGeometry?.dispose(); }; - }, [ - animationOverrides, - initialRotationX, - initialRotationY, - initialRotationZ, - meshScaleMultiplier, - modelUrl, - ]); + }, [meshScaleMultiplier, modelUrl]); return (
+ > + {geometry ? ( + + ) : null} +
); } diff --git a/packages/twenty-website-new/src/sections/ThreeCards/visuals/partner-three-card-config.ts b/packages/twenty-website-new/src/sections/ThreeCards/visuals/partner-three-card-config.ts new file mode 100644 index 0000000000..5260d2fd5c --- /dev/null +++ b/packages/twenty-website-new/src/sections/ThreeCards/visuals/partner-three-card-config.ts @@ -0,0 +1,188 @@ +import { + normalizeHalftoneStudioSettings, + type HalftoneAnimationSettings, + type HalftoneExportPose, + type HalftoneStudioSettings, + type ImportedGeometryNormalizationOptions, +} from '@/lib/halftone'; + +export const PARTNER_THREE_CARD_PREVIEW_DISTANCE = 4.5; + +const DIAMOND_MODEL_URL = '/illustrations/home/three-cards/diamond.glb'; + +const BASE_PARTNER_THREE_CARD_SETTINGS = normalizeHalftoneStudioSettings({ + sourceMode: 'shape', + shapeKey: 'userUpload_1776089370856', + lighting: { + intensity: 1.5, + fillIntensity: 0.48, + ambientIntensity: 0.3, + angleDegrees: 53, + height: 2, + }, + material: { + surface: 'solid', + color: '#d4d0c8', + roughness: 0.42, + metalness: 0.15, + thickness: 150, + refraction: 2, + environmentPower: 5, + }, + halftone: { + enabled: true, + scale: 14, + power: 0.4, + toneTarget: 'light', + width: 0.5, + imageContrast: 1, + dashColor: '#4A38F5', + hoverDashColor: '#4A38F5', + }, + background: { + transparent: true, + color: '#000000', + }, + animation: { + autoRotateEnabled: true, + breatheEnabled: false, + cameraParallaxEnabled: false, + followHoverEnabled: false, + followDragEnabled: true, + floatEnabled: false, + hoverHalftoneEnabled: false, + hoverLightEnabled: false, + dragFlowEnabled: false, + lightSweepEnabled: false, + rotateEnabled: false, + autoSpeed: 0.1, + autoWobble: 0, + breatheAmount: 0.04, + breatheSpeed: 0.8, + cameraParallaxAmount: 0.3, + cameraParallaxEase: 0.08, + driftAmount: 8, + hoverRange: 25, + hoverEase: 0.19, + hoverReturn: true, + dragSens: 0.008, + dragFriction: 0.08, + dragMomentum: true, + rotateAxis: 'y', + rotatePreset: 'axis', + rotateSpeed: 0.1, + rotatePingPong: false, + floatAmplitude: 0.16, + floatSpeed: 0.8, + lightSweepHeightRange: 0.5, + lightSweepRange: 28, + lightSweepSpeed: 0.7, + springDamping: 0.6, + springReturnEnabled: true, + springStrength: 0.06, + hoverHalftonePowerShift: 0.42, + hoverHalftoneRadius: 0.2, + hoverHalftoneWidthShift: -0.18, + hoverLightIntensity: 0.8, + hoverLightRadius: 0.2, + dragFlowDecay: 0.08, + dragFlowRadius: 0.24, + dragFlowStrength: 1.8, + hoverWarpStrength: 3, + hoverWarpRadius: 0.15, + dragWarpStrength: 5, + waveEnabled: false, + waveSpeed: 1, + waveAmount: 2, + }, +}); + +const BASE_PARTNER_THREE_CARD_INITIAL_POSE: HalftoneExportPose = { + autoElapsed: 11.523399999928483, + rotateElapsed: 0, + rotationX: -4.020043134225878e-15, + rotationY: 1.1339840023154435, + rotationZ: 0, + targetRotationX: 0, + targetRotationY: 0, + timeElapsed: 11.523399999928476, +}; + +type PartnerThreeCardModelOverrides = { + geometryOptions?: ImportedGeometryNormalizationOptions; + initialPose?: Partial; +}; + +const getPartnerThreeCardModelOverrides = ( + modelUrl: string, +): PartnerThreeCardModelOverrides => { + if (modelUrl !== DIAMOND_MODEL_URL) { + return {}; + } + + return { + geometryOptions: { + postRotateZ: 1, + useLegacyNormalization: true, + }, + initialPose: { + autoElapsed: 0, + rotateElapsed: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + targetRotationX: 0, + targetRotationY: 0, + timeElapsed: 0, + }, + }; +}; + +export const buildPartnerThreeCardSettings = ( + animationOverrides?: Partial, +): HalftoneStudioSettings => ({ + ...BASE_PARTNER_THREE_CARD_SETTINGS, + animation: { + ...BASE_PARTNER_THREE_CARD_SETTINGS.animation, + ...animationOverrides, + }, +}); + +export const buildPartnerThreeCardInitialPose = ({ + initialRotationX, + initialRotationY, + initialRotationZ, + modelUrl, +}: { + initialRotationX?: number; + initialRotationY?: number; + initialRotationZ?: number; + modelUrl: string; +}): HalftoneExportPose => { + const modelOverrides = getPartnerThreeCardModelOverrides(modelUrl); + const initialPose = { + ...BASE_PARTNER_THREE_CARD_INITIAL_POSE, + ...modelOverrides.initialPose, + }; + + return { + ...initialPose, + rotationX: + initialRotationX ?? + modelOverrides.initialPose?.rotationX ?? + BASE_PARTNER_THREE_CARD_INITIAL_POSE.rotationX, + rotationY: + initialRotationY ?? + modelOverrides.initialPose?.rotationY ?? + BASE_PARTNER_THREE_CARD_INITIAL_POSE.rotationY, + rotationZ: + initialRotationZ ?? + modelOverrides.initialPose?.rotationZ ?? + BASE_PARTNER_THREE_CARD_INITIAL_POSE.rotationZ, + }; +}; + +export const getPartnerThreeCardGeometryOptions = ( + modelUrl: string, +): ImportedGeometryNormalizationOptions | undefined => + getPartnerThreeCardModelOverrides(modelUrl).geometryOptions; diff --git a/packages/twenty-website-new/src/sections/TrustedBy/components/ClientCount/ClientCountShape.tsx b/packages/twenty-website-new/src/sections/TrustedBy/components/ClientCount/ClientCountShape.tsx deleted file mode 100644 index 6741e672a5..0000000000 --- a/packages/twenty-website-new/src/sections/TrustedBy/components/ClientCount/ClientCountShape.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { styled } from '@linaria/react'; - -interface ClientCountShapeProps { - strokeColor: string; -} - -const LEFT_OUTLINE = 'M4 0.5 A3.5 3.5 0 0 0 0.5 4 V44 A3.5 3.5 0 0 0 4 47.5'; - -const RIGHT_OUTLINE = - 'M0 0.5 h11 a3.5 3.5 0 0 1 3.5 3.5 v28.523 a5.5 5.5 0 0 1 -1.416 3.684 l-8.547 9.477 a5.5 5.5 0 0 1 -4.084 1.816 H0'; - -const ShapeContainer = styled.div` - display: flex; - height: 48px; - inset: 0; - pointer-events: none; - position: absolute; - width: 100%; - z-index: 0; -`; - -const LeftCap = styled.svg` - display: block; - flex-shrink: 0; -`; - -const MiddleSegment = styled.svg` - display: block; - flex-grow: 1; - margin: 0 -1px; - min-width: 0; -`; - -const RightCap = styled.svg` - display: block; - flex-shrink: 0; -`; - -export function ClientCountShape({ strokeColor }: ClientCountShapeProps) { - return ( - - - - - - - - - - - - - - - ); -} diff --git a/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Content.tsx b/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Content.tsx deleted file mode 100644 index 468d1743e0..0000000000 --- a/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Content.tsx +++ /dev/null @@ -1,150 +0,0 @@ -'use client'; - -import { Body, Heading, StepperProgressRail } from '@/design-system/components'; -import type { BodyType } from '@/design-system/components/Body'; -import type { HeadingType } from '@/design-system/components/Heading'; -import { StepperSwipeDeck } from '@/lib/stepper'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; - -const ContentRoot = styled.div` - display: grid; - grid-template-columns: auto 1fr; - gap: ${theme.spacing(6)}; - min-width: 0; - - @media (min-width: ${theme.breakpoints.md}px) { - align-self: stretch; - gap: ${theme.spacing(20)}; - margin-left: calc(-1 * ${theme.spacing(4)}); - position: sticky; - top: 0; - height: 100vh; - align-items: center; - } -`; - -const StepsColumn = styled.div` - display: grid; - gap: ${theme.spacing(6)}; - grid-template-columns: 1fr; - min-width: 0; - - @media (min-width: ${theme.breakpoints.md}px) { - height: max-content; - max-width: 556px; - } -`; - -const HeadingBlock = styled.div` - display: grid; - grid-template-columns: 1fr; - row-gap: ${theme.spacing(4)}; -`; - -const StepBlock = styled.div<{ $opacity: number; $translateY: number }>` - display: grid; - grid-template-columns: 1fr; - opacity: 1; - row-gap: ${theme.spacing(4)}; - transition: - opacity 0.4s ease, - transform 0.4s ease; - - @media (min-width: ${theme.breakpoints.md}px) { - opacity: ${({ $opacity }) => $opacity}; - row-gap: ${theme.spacing(6)}; - transform: translateY(${({ $translateY }) => `${$translateY}px`}); - } -`; - -const SwipeStepBlock = styled.div` - display: grid; - grid-template-columns: 1fr; - row-gap: ${theme.spacing(4)}; -`; - -export type WhyTwentyStepperLayoutMode = 'scroll' | 'swipe'; - -export type WhyTwentyStepperContentProps = { - activeStepIndex: number; - body: BodyType[]; - heading: HeadingType; - layoutMode: WhyTwentyStepperLayoutMode; - localProgress: number; - onMobileStepIndexChange: (nextIndex: number) => void; -}; - -export function Content({ - activeStepIndex, - body, - heading, - layoutMode, - localProgress, - onMobileStepIndexChange, -}: WhyTwentyStepperContentProps) { - const stepCount = body.length; - - return ( - - - - - - - {layoutMode === 'swipe' ? ( - - {(stepIndex) => ( - - - - )} - - ) : ( - body.map((bodyItem, index) => { - let opacity = 1; - let translateY = 0; - - if (index > activeStepIndex + 1) { - opacity = 0; - translateY = 300; - } else if (index === activeStepIndex + 1) { - opacity = 0.4; - translateY = 300 * (1 - localProgress); - } - - return ( - - - - ); - }) - )} - - - ); -} diff --git a/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Flow.tsx b/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Flow.tsx deleted file mode 100644 index 53e7461692..0000000000 --- a/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Flow.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import { ScrollProgressEffect } from '@/lib/scroll'; -import { useStepperMdUp } from '@/lib/stepper'; -import { WebGlMount } from '@/lib/visual-runtime'; -import { Logo as WhyTwentyStepperLogo } from '@/sections/WhyTwentyStepper/visuals/Logo'; -import type { WhyTwentyStepperDataType } from '@/sections/WhyTwentyStepper/types'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import { useEffect, useRef, useState } from 'react'; -import { Content } from './Content'; -import { Root } from './Root'; - -const IllustrationColumn = styled.div` - min-width: 0; - width: 100%; - - @media (max-width: ${theme.breakpoints.md - 1}px) { - order: -1; - } - - @media (min-width: ${theme.breakpoints.md}px) { - align-items: center; - align-self: start; - display: flex; - height: calc(100vh - 4.5rem); - justify-content: center; - position: sticky; - top: 4.5rem; - } -`; - -const IllustrationFrame = styled.div` - min-width: 0; - width: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - max-width: 672px; - } -`; - -type FlowProps = WhyTwentyStepperDataType; - -export function Flow({ body, heading }: FlowProps) { - const isMdUp = useStepperMdUp(); - const [scrollProgress, setScrollProgress] = useState(0); - const [mobileStepIndex, setMobileStepIndex] = useState(0); - const scrollContainerRef = useRef(null); - const previousMdUpRef = useRef(isMdUp); - - const stepCount = body.length; - - useEffect(() => { - if (previousMdUpRef.current && !isMdUp) { - const scrollDerivedIndex = Math.min( - stepCount - 1, - Math.floor(scrollProgress * stepCount), - ); - setMobileStepIndex(scrollDerivedIndex); - } - previousMdUpRef.current = isMdUp; - }, [isMdUp, scrollProgress, stepCount]); - - const activeStepIndex = isMdUp - ? Math.min(stepCount - 1, Math.floor(scrollProgress * stepCount)) - : mobileStepIndex; - - const localProgress = isMdUp - ? scrollProgress * stepCount - activeStepIndex - : 0; - - return ( - - - - - - - - - - - - ); -} diff --git a/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Root.tsx b/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Root.tsx deleted file mode 100644 index 645c7ebc25..0000000000 --- a/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Root.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Container } from '@/design-system/components'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import type { ReactNode, RefObject } from 'react'; - -const StyledSection = styled.section` - background-color: ${theme.colors.primary.text[10]}; - width: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - height: 300vh; - } -`; - -const Grid = styled(Container)` - display: grid; - grid-template-columns: 1fr; - min-width: 0; - padding-bottom: ${theme.spacing(12)}; - padding-left: ${theme.spacing(4)}; - padding-right: ${theme.spacing(4)}; - padding-top: ${theme.spacing(12)}; - row-gap: ${theme.spacing(10)}; - width: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - align-items: start; - column-gap: ${theme.spacing(10)}; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - min-height: 100%; - padding-bottom: ${theme.spacing(20)}; - padding-left: ${theme.spacing(10)}; - padding-right: ${theme.spacing(10)}; - padding-top: ${theme.spacing(20)}; - row-gap: ${theme.spacing(12)}; - } -`; - -type RootProps = { - children: ReactNode; - scrollContainerRef?: RefObject; -}; - -export function Root({ children, scrollContainerRef }: RootProps) { - return ( - - {children} - - ); -} diff --git a/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/index.ts b/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/index.ts deleted file mode 100644 index 838cb3b539..0000000000 --- a/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Content } from './Content'; -import { Flow } from './Flow'; -import { Root } from './Root'; - -export const WhyTwentyStepper = { - Content, - Flow, - Root, -}; diff --git a/packages/twenty-website-new/src/sections/WhyTwentyStepper/types/WhyTwentyStepperData.ts b/packages/twenty-website-new/src/sections/WhyTwentyStepper/types/WhyTwentyStepperData.ts deleted file mode 100644 index 2f8a8e38e0..0000000000 --- a/packages/twenty-website-new/src/sections/WhyTwentyStepper/types/WhyTwentyStepperData.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { BodyType } from '@/design-system/components/Body'; -import type { HeadingType } from '@/design-system/components/Heading'; - -export type WhyTwentyStepperDataType = { - heading: HeadingType; - body: BodyType[]; -}; diff --git a/packages/twenty-website-new/src/sections/WhyTwentyStepper/types/index.ts b/packages/twenty-website-new/src/sections/WhyTwentyStepper/types/index.ts deleted file mode 100644 index 1f5c0c3a0c..0000000000 --- a/packages/twenty-website-new/src/sections/WhyTwentyStepper/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { WhyTwentyStepperDataType } from './WhyTwentyStepperData'; diff --git a/packages/twenty-website-new/src/sections/WhyTwentyStepper/visuals/Logo.tsx b/packages/twenty-website-new/src/sections/WhyTwentyStepper/visuals/Logo.tsx deleted file mode 100644 index 6ca8f8dee1..0000000000 --- a/packages/twenty-website-new/src/sections/WhyTwentyStepper/visuals/Logo.tsx +++ /dev/null @@ -1,377 +0,0 @@ -'use client'; - -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import { useLayoutEffect, useRef } from 'react'; -import * as THREE from 'three'; -import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import { - createFrameTimer, - createSiteWebGlRenderer, -} from '@/lib/visual-runtime'; -import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; - -const GLB_URL = '/illustrations/why-twenty/stepper/logo.glb'; - -const VisualColumn = styled.div` - min-width: 0; - width: 100%; -`; - -const VisualContainer = styled.div` - background-color: transparent; - border-radius: ${theme.radius(1)}; - height: min(705px, 70vw); - min-height: ${theme.spacing(80)}; - overflow: hidden; - position: relative; - width: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - aspect-ratio: 672 / 705; - height: auto; - max-height: 705px; - min-height: 0; - } -`; - -const GlbMount = styled.div` - display: block; - height: 100%; - inset: 0; - min-width: 0; - position: absolute; - width: 100%; -`; - -const retroVertexShader = /* glsl */ ` - varying vec3 vLocalPosition; - varying vec3 vWorldPosition; - - void main() { - vLocalPosition = position; - vec4 worldPosition = modelMatrix * vec4(position, 1.0); - vWorldPosition = worldPosition.xyz; - gl_Position = projectionMatrix * viewMatrix * worldPosition; - } -`; - -const retroFragmentShader = /* glsl */ ` - uniform vec3 uColor; - uniform vec2 uDigitCenterXY; - uniform vec2 uDigitRadiusXY; - uniform float uDigitZMin; - uniform float uStripeScale; - - varying vec3 vLocalPosition; - varying vec3 vWorldPosition; - - void main() { - vec2 delta = - (vLocalPosition.xy - uDigitCenterXY) / max(uDigitRadiusXY, vec2(0.0001)); - float inDigitXY = 1.0 - smoothstep(0.88, 1.42, length(delta)); - float inDigitZ = smoothstep(uDigitZMin - 2.5, uDigitZMin + 1.2, vLocalPosition.z); - - float digitMask = clamp(inDigitXY * mix(0.55, 1.0, inDigitZ), 0.0, 1.0); - - if (digitMask > 0.62) { - discard; - } - - // Left-to-right gradient based on world X - float xGrad = smoothstep(-1.2, 1.2, vWorldPosition.x); - - // Horizontal stripes based on world Y - float y = vWorldPosition.y * uStripeScale; - float cell = fract(y); - - float lineWidth = mix(0.85, 0.0, xGrad); - float edge = 0.04; - float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell); - - // Dash effect increases from left to right - float dashPhase = vWorldPosition.x * 25.0 + vWorldPosition.y * 12.0; - float dash = fract(dashPhase); - - float dashStrength = smoothstep(0.1, 0.9, xGrad); - - float dashMask = mix( - 1.0, - smoothstep(0.1, 0.3, dash) * (1.0 - smoothstep(0.4, 0.9, dash)), - dashStrength - ); - - // Add speckle noise for the retro feel - float speckle = fract(sin(dot(vWorldPosition.xy, vec2(12.9898, 78.233))) * 43758.5453); - float speckleMask = mix(1.0, step(0.3, speckle), dashStrength * 0.8); - - band *= dashMask * speckleMask; - - if (band < 0.05) { - discard; - } - - gl_FragColor = vec4(uColor, 1.0); - } -`; - -function disposeObjectSubtree(root: THREE.Object3D) { - root.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - sceneObject.geometry?.dispose(); - const material = sceneObject.material; - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - } else { - material?.dispose(); - } - }); -} - -function createRetroScreenStripeMaterial() { - return new THREE.ShaderMaterial({ - uniforms: { - uColor: { value: new THREE.Color('#000000') }, - uDigitCenterXY: { value: new THREE.Vector2(0, 0) }, - uDigitRadiusXY: { value: new THREE.Vector2(1, 1) }, - uDigitZMin: { value: 0 }, - uStripeScale: { value: 22.0 }, - }, - vertexShader: retroVertexShader, - fragmentShader: retroFragmentShader, - transparent: true, - depthWrite: true, - depthTest: true, - side: THREE.DoubleSide, - }); -} - -function applyRetroScreenStripeMaterials(modelRoot: THREE.Object3D) { - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - - const geometry = sceneObject.geometry; - if (!geometry.getAttribute('position')) { - return; - } - - geometry.computeBoundingBox(); - const box = geometry.boundingBox; - if (!box) { - return; - } - - const centerX = (box.min.x + box.max.x) / 2; - const centerY = (box.min.y + box.max.y) / 2; - const sizeX = box.max.x - box.min.x; - const sizeY = box.max.y - box.min.y; - const sizeZ = box.max.z - box.min.z; - - const material = createRetroScreenStripeMaterial(); - material.uniforms.uDigitCenterXY.value.set(centerX, centerY); - material.uniforms.uDigitRadiusXY.value.set( - Math.max(sizeX * 0.135, 0.01), - Math.max(sizeY * 0.105, 0.01), - ); - material.uniforms.uDigitZMin.value = box.max.z - sizeZ * 0.14; - - sceneObject.material = material; - }); -} - -export function Logo() { - const glbMountReference = useRef(null); - - useLayoutEffect(() => { - const container = glbMountReference.current; - if (!container) { - return; - } - - let cancelled = false; - let animationFrameId = 0; - - let modelHalfX = 0.55; - let modelHalfY = 0.55; - - const scene = new THREE.Scene(); - const width = container.clientWidth; - const height = container.clientHeight; - const aspect = width / Math.max(height, 1); - - const camera = new THREE.OrthographicCamera( - -aspect, - aspect, - 1, - -1, - 0.1, - 100, - ); - camera.position.set(0, 0, 10); - camera.lookAt(0, 0, 0); - - const updateOrthoFrustum = () => { - const w = container.clientWidth; - const h = Math.max(container.clientHeight, 1); - const asp = w / h; - const halfH = Math.max(modelHalfY, modelHalfX / asp); - const halfW = halfH * asp; - camera.left = -halfW; - camera.right = halfW; - camera.top = halfH; - camera.bottom = -halfH; - camera.updateProjectionMatrix(); - }; - - updateOrthoFrustum(); - - const renderer = createSiteWebGlRenderer({ alpha: true, antialias: true }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.setSize(width, height); - renderer.setClearColor(0x000000, 0); - renderer.outputColorSpace = THREE.SRGBColorSpace; - - const canvas = renderer.domElement; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const frameTimer = createFrameTimer(); - - const pivot = new THREE.Group(); - scene.add(pivot); - - let targetRotationX = 0; - let targetRotationY = 0; - - const syncResolutionUniforms = () => { - const rw = canvas.width; - const rh = canvas.height; - pivot.traverse((sceneObject) => { - if ( - sceneObject instanceof THREE.Mesh && - sceneObject.material instanceof THREE.ShaderMaterial && - sceneObject.material.uniforms.uResolution - ) { - sceneObject.material.uniforms.uResolution.value.set(rw, rh); - } - }); - }; - - syncResolutionUniforms(); - - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath(DRACO_DECODER_PATH); - - const loader = new GLTFLoader(); - loader.setDRACOLoader(dracoLoader); - - loader.load(GLB_URL, (gltf) => { - if (cancelled) { - disposeObjectSubtree(gltf.scene); - return; - } - - const modelRoot = gltf.scene; - const bounds = new THREE.Box3().setFromObject(modelRoot); - const center = bounds.getCenter(new THREE.Vector3()); - const size = bounds.getSize(new THREE.Vector3()); - const maxAxis = Math.max(size.x, size.y, size.z, 0.001); - - const scale = 2.72 / maxAxis; - - modelRoot.position.sub(center); - modelRoot.scale.setScalar(scale); - modelRoot.rotation.set(0, -0.2, -0.2); - - applyRetroScreenStripeMaterials(modelRoot); - - pivot.add(modelRoot); - - const fitBox = new THREE.Box3().setFromObject(pivot); - const fitSize = new THREE.Vector3(); - fitBox.getSize(fitSize); - const fitPad = 1.1; - modelHalfX = (fitSize.x * fitPad) / 2; - modelHalfY = (fitSize.y * fitPad) / 2; - - updateOrthoFrustum(); - syncResolutionUniforms(); - - const renderFrame = () => { - if (cancelled) { - return; - } - - animationFrameId = window.requestAnimationFrame(renderFrame); - const time = frameTimer.getElapsed(); - - pivot.position.y = Math.sin(time * 1.2) * 0.025; - - pivot.rotation.x += (targetRotationX - pivot.rotation.x) * 0.06; - pivot.rotation.y += (targetRotationY - pivot.rotation.y) * 0.06; - - renderer.render(scene, camera); - }; - - renderFrame(); - }); - - const handlePointerMove = (event: PointerEvent) => { - const rect = canvas.getBoundingClientRect(); - const x = ((event.clientX - rect.left) / rect.width) * 2 - 1; - const y = -(((event.clientY - rect.top) / rect.height) * 2 - 1); - - targetRotationY = x * 0.1; - targetRotationX = y * 0.08; - }; - - const handlePointerLeave = () => { - targetRotationX = 0; - targetRotationY = 0; - }; - - const handleResize = () => { - if (!container || cancelled) { - return; - } - const nextWidth = container.clientWidth; - const nextHeight = container.clientHeight; - renderer.setSize(nextWidth, nextHeight); - updateOrthoFrustum(); - syncResolutionUniforms(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - window.addEventListener('resize', handleResize); - - return () => { - cancelled = true; - window.removeEventListener('resize', handleResize); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - window.cancelAnimationFrame(animationFrameId); - disposeObjectSubtree(scene); - renderer.dispose(); - dracoLoader.dispose(); - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; - }, []); - - return ( - - - - - - ); -}