chore(website): rename twenty-website-new → twenty-website (#20745)
## Summary Follow-up to the Cloudflare/OpenNext migration (#20741). Now that the legacy `twenty-website` package was already removed in #20270, the `-new` suffix on the marketing site package is no longer meaningful. ## What changes - **Directory rename**: `git mv packages/twenty-website-new packages/twenty-website` (1213 files moved, no content change) - **Package + nx config**: `package.json` and `project.json` name fields updated, `sourceRoot` repointed - **Source refs**: `load-local-articles.ts` and `load-local-release-notes.ts` had a hardcoded `'twenty-website-new'` segment in their monorepo-root fallback path; `app/[locale]/releases/page.tsx` had display strings showing where to add content - **External refs**: root `package.json` workspaces, root `CLAUDE.md` / `README.md`, `twenty-sdk` + `create-twenty-app` READMEs, `.vscode/twenty.code-workspace`, `.cursor/rules/changelog-process.mdc`, Crowdin config + the three `website-i18n-*` CI workflows + `ci-website.yaml` - **Docker cleanup**: `packages/twenty-docker/twenty-website-new/Dockerfile` deleted; the two Makefile targets (`prod-website-new-build` / `prod-website-new-run`) that referenced it removed — EKS deploy was retired in the Cloudflare migration - **`yarn.lock`** regenerated against the new workspace path ## What's deliberately not in this PR The dev hostname `website-new.twenty-main.com` in `wrangler.jsonc` stays for now. Migrating it to `website.twenty-main.com` needs coordinated DNS deletion (current CNAME points at the legacy Docusaurus NLB and serves 503s) and removal of the matching legacy `website` Helm chart in `twenty-infra`. Flagged as a separate cleanup. Companion infra PR: https://github.com/twentyhq/twenty-infra/pull/682 (workflow paths + Terraform ECR + docs) ## Test plan - [x] `yarn install --immutable` resolves clean against the new path - [x] `npx nx typecheck twenty-website` passes - [x] `npx nx lint twenty-website` passes - [ ] CI on this PR confirms the same on a fresh checkout - [ ] After merge: trigger `Deploy Website` workflow against `environment=dev` to confirm the renamed working-directory deploys correctly
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { Container } from '@/design-system/components';
|
||||
import {
|
||||
Heading as BaseHeading,
|
||||
type HeadingProps,
|
||||
} from '@/design-system/components/Heading';
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Points } from './ProblemPoints';
|
||||
import { Visual } from './components/Visual/Visual';
|
||||
|
||||
const StyledSection = styled.section`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled(Container)`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: ${theme.spacing(12)};
|
||||
padding-bottom: ${theme.spacing(12)};
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
padding-top: ${theme.spacing(6)};
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
column-gap: ${theme.spacing(4)};
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding-left: ${theme.spacing(10)};
|
||||
padding-right: ${theme.spacing(10)};
|
||||
padding-top: ${theme.spacing(6)};
|
||||
padding-bottom: ${theme.spacing(10)};
|
||||
row-gap: ${theme.spacing(20)};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: ${theme.spacing(10)};
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
padding-bottom: ${theme.spacing(15)};
|
||||
padding-left: ${theme.spacing(15)};
|
||||
padding-right: ${theme.spacing(15)};
|
||||
padding-top: ${theme.spacing(15)};
|
||||
row-gap: ${theme.spacing(20)};
|
||||
}
|
||||
`;
|
||||
|
||||
const problemHeadingClassName = css`
|
||||
margin-top: calc(${theme.spacing(2)} - ${theme.spacing(10)});
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
margin-top: calc(${theme.spacing(2)} - ${theme.spacing(16)});
|
||||
}
|
||||
`;
|
||||
|
||||
type RootProps = { children: ReactNode };
|
||||
|
||||
function Root({ children }: RootProps) {
|
||||
return (
|
||||
<StyledSection>
|
||||
<StyledContainer>{children}</StyledContainer>
|
||||
</StyledSection>
|
||||
);
|
||||
}
|
||||
|
||||
type ContentProps = { children: ReactNode };
|
||||
|
||||
function Content({ children }: ContentProps) {
|
||||
return <StyledContent>{children}</StyledContent>;
|
||||
}
|
||||
|
||||
function Heading({
|
||||
as = 'h2',
|
||||
children,
|
||||
className,
|
||||
size = 'md',
|
||||
weight = 'light',
|
||||
}: HeadingProps & { children?: ReactNode }) {
|
||||
return (
|
||||
<BaseHeading
|
||||
as={as}
|
||||
className={[problemHeadingClassName, className].filter(Boolean).join(' ')}
|
||||
size={size}
|
||||
weight={weight}
|
||||
>
|
||||
{children}
|
||||
</BaseHeading>
|
||||
);
|
||||
}
|
||||
|
||||
export const Problem = {
|
||||
Root,
|
||||
Visual,
|
||||
Content,
|
||||
Heading,
|
||||
Points,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Body } from '@/design-system/components/Body';
|
||||
import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { ProblemPointType } from './problem-point-type';
|
||||
|
||||
const StyledPoints = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: ${theme.spacing(6)};
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 454px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDivider = styled.div`
|
||||
height: 0;
|
||||
border-top: 0.75px dashed ${theme.colors.primary.border[80]};
|
||||
width: 100%;
|
||||
opacity: 50%;
|
||||
`;
|
||||
|
||||
const StyledPoint = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: ${theme.spacing(2)};
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
type PointsProps = {
|
||||
points: ProblemPointType[];
|
||||
};
|
||||
|
||||
export function Points({ points }: PointsProps) {
|
||||
const i18n = getServerI18n();
|
||||
return (
|
||||
<StyledPoints>
|
||||
{points.map((point, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<StyledDivider />
|
||||
<StyledPoint>
|
||||
{point.heading}
|
||||
<Body size="sm">{i18n._(point.body)}</Body>
|
||||
</StyledPoint>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</StyledPoints>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, type CSSProperties } from 'react';
|
||||
import { DEFAULT_TUNING } from './monolith-halftone-mount';
|
||||
import { useMonolithHalftone } from './use-monolith-halftone';
|
||||
|
||||
const IMAGE_SRC = '/images/home/problem/monolith-problem.webp';
|
||||
|
||||
type MonolithProps = {
|
||||
imageUrl?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export default function Monolith({
|
||||
imageUrl = IMAGE_SRC,
|
||||
style,
|
||||
}: MonolithProps) {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useMonolithHalftone({ imageUrl, mountRef: mountReference });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mountReference}
|
||||
style={{
|
||||
background: DEFAULT_TUNING.backgroundColor,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { WebGlMount } from '@/lib/visual-runtime';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import Monolith from './Monolith';
|
||||
|
||||
const DESKTOP_PATH =
|
||||
'M672 395.498V701a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h664a4 4 0 0 1 4 4v65.155c0 2.363-.837 4.65-2.361 6.454l-23.603 27.936a10 10 0 0 0-2.362 6.453v242.614c0 2.245.756 4.424 2.145 6.188l24.036 30.51a10 10 0 0 1 2.145 6.188';
|
||||
|
||||
const MOBILE_PATH =
|
||||
'M360 214.218v165.689a2.093 2.093 0 0 1-2.093 2.093H2.093A2.093 2.093 0 0 1 0 379.907V2.093C0 .937.937 0 2.093 0h355.814C359.063 0 360 .937 360 2.093v35.463a5.23 5.23 0 0 1-1.217 3.355l-12.741 15.252a5.23 5.23 0 0 0-1.216 3.354v131.624a5.23 5.23 0 0 0 1.104 3.215l12.966 16.646a5.24 5.24 0 0 1 1.104 3.216';
|
||||
|
||||
const desktopMask = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 672 705' preserveAspectRatio='none'%3E%3Cpath d='${encodeURIComponent(DESKTOP_PATH)}' fill='black'/%3E%3C/svg%3E")`;
|
||||
|
||||
const mobileMask = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 382' preserveAspectRatio='none'%3E%3Cpath d='${encodeURIComponent(MOBILE_PATH)}' fill='black'/%3E%3C/svg%3E")`;
|
||||
|
||||
const StyledVisual = styled.div`
|
||||
min-height: 382px;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledMasked = styled.div`
|
||||
background-color: #1c1c1c;
|
||||
height: 100%;
|
||||
isolation: isolate;
|
||||
-webkit-mask-image: ${mobileMask};
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-image: ${mobileMask};
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
-webkit-mask-image: ${desktopMask};
|
||||
mask-image: ${desktopMask};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledHalftoneLayer = styled.div`
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
export function Visual() {
|
||||
return (
|
||||
<StyledVisual>
|
||||
<StyledMasked>
|
||||
<StyledHalftoneLayer>
|
||||
<WebGlMount>
|
||||
<Monolith />
|
||||
</WebGlMount>
|
||||
</StyledHalftoneLayer>
|
||||
</StyledMasked>
|
||||
</StyledVisual>
|
||||
);
|
||||
}
|
||||
+819
@@ -0,0 +1,819 @@
|
||||
import * as THREE from 'three';
|
||||
import { observeElementSize } from '@/lib/dom/observe-element-size';
|
||||
import {
|
||||
createVisualRenderLoop,
|
||||
loadVisualImage,
|
||||
tryCreateSiteWebGlRenderer,
|
||||
type VisualRenderLoop,
|
||||
type VisualRenderLoopFrame,
|
||||
} from '@/lib/visual-runtime';
|
||||
|
||||
const PREVIEW_DISTANCE = 4;
|
||||
const VIRTUAL_RENDER_HEIGHT = 768;
|
||||
const REFERENCE_PREVIEW_DISTANCE = 4;
|
||||
const MIN_FOOTPRINT_SCALE = 0.001;
|
||||
const IMAGE_POINTER_FOLLOW = 0.38;
|
||||
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
|
||||
|
||||
export type HalftoneTuning = {
|
||||
dashColor: string;
|
||||
imageContrast: number;
|
||||
power: number;
|
||||
scale: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export type AnimationTuning = {
|
||||
hoverLightEnabled: boolean;
|
||||
hoverLightIntensity: number;
|
||||
hoverLightRadius: number;
|
||||
waveSpeed: number;
|
||||
};
|
||||
|
||||
export type MonolithTuning = {
|
||||
animation: AnimationTuning;
|
||||
backgroundColor: string;
|
||||
halftone: HalftoneTuning;
|
||||
};
|
||||
|
||||
export const DEFAULT_TUNING: MonolithTuning = {
|
||||
halftone: {
|
||||
dashColor: '#BABABA',
|
||||
imageContrast: 1,
|
||||
power: 0.21,
|
||||
scale: 10,
|
||||
width: 0.65,
|
||||
},
|
||||
animation: {
|
||||
hoverLightEnabled: true,
|
||||
hoverLightIntensity: 0.13,
|
||||
hoverLightRadius: 1,
|
||||
waveSpeed: 1,
|
||||
},
|
||||
backgroundColor: '#1C1C1C',
|
||||
};
|
||||
|
||||
const passThroughVertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const blurFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tInput;
|
||||
uniform vec2 dir;
|
||||
uniform vec2 res;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 sum = vec4(0.0);
|
||||
vec2 px = dir / res;
|
||||
|
||||
float w[5];
|
||||
w[0] = 0.227027;
|
||||
w[1] = 0.1945946;
|
||||
w[2] = 0.1216216;
|
||||
w[3] = 0.054054;
|
||||
w[4] = 0.016216;
|
||||
|
||||
sum += texture2D(tInput, vUv) * w[0];
|
||||
|
||||
for (int i = 1; i < 5; i++) {
|
||||
float fi = float(i) * 3.0;
|
||||
sum += texture2D(tInput, vUv + px * fi) * w[i];
|
||||
sum += texture2D(tInput, vUv - px * fi) * w[i];
|
||||
}
|
||||
|
||||
gl_FragColor = sum;
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tScene;
|
||||
uniform sampler2D tGlow;
|
||||
uniform vec2 effectResolution;
|
||||
uniform vec2 logicalResolution;
|
||||
uniform float tile;
|
||||
uniform float s_3;
|
||||
uniform float s_4;
|
||||
uniform vec3 dashColor;
|
||||
uniform float time;
|
||||
uniform float waveAmount;
|
||||
uniform float waveSpeed;
|
||||
uniform float footprintScale;
|
||||
uniform vec2 interactionUv;
|
||||
uniform vec2 interactionVelocity;
|
||||
uniform vec2 dragOffset;
|
||||
uniform float hoverLightStrength;
|
||||
uniform float hoverLightRadius;
|
||||
uniform float hoverFlowStrength;
|
||||
uniform float hoverFlowRadius;
|
||||
uniform float dragFlowStrength;
|
||||
uniform float cropToBounds;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
|
||||
vec2 pa = p - a;
|
||||
vec2 ba = b - a;
|
||||
float denom = max(dot(ba, ba), 0.000001);
|
||||
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
|
||||
return length(pa - ba * h);
|
||||
}
|
||||
|
||||
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
|
||||
vec2 a = vec2(0.5) + vec2(-r, 0.0);
|
||||
vec2 b = vec2(0.5) + vec2(r, 0.0);
|
||||
float distToSegment = distSegment(p, a, b);
|
||||
float halfThickness = thickness * r;
|
||||
return distToSegment - halfThickness;
|
||||
}
|
||||
|
||||
void main() {
|
||||
if (cropToBounds > 0.5) {
|
||||
vec4 boundsCheck = texture2D(tScene, vUv);
|
||||
if (boundsCheck.a < 0.01) {
|
||||
gl_FragColor = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
vec2 fragCoord =
|
||||
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
|
||||
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
|
||||
vec2 pointerPx = interactionUv * logicalResolution;
|
||||
vec2 fragDelta = fragCoord - pointerPx;
|
||||
float fragDist = length(fragDelta);
|
||||
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
|
||||
float velocityMagnitude = length(interactionVelocity);
|
||||
vec2 motionDir = velocityMagnitude > 0.001
|
||||
? interactionVelocity / velocityMagnitude
|
||||
: vec2(0.0, 0.0);
|
||||
float motionBias = velocityMagnitude > 0.001
|
||||
? dot(-radialDir, motionDir) * 0.5 + 0.5
|
||||
: 0.5;
|
||||
|
||||
float hoverLightMask = 0.0;
|
||||
if (hoverLightStrength > 0.0) {
|
||||
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
|
||||
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
float hoverFlowMask = 0.0;
|
||||
if (hoverFlowStrength > 0.0) {
|
||||
float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;
|
||||
hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
vec2 hoverDisplacement =
|
||||
radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +
|
||||
motionDir * hoverFlowStrength * hoverFlowMask *
|
||||
(0.4 + motionBias) * halftoneSize * 1.15;
|
||||
vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;
|
||||
vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;
|
||||
|
||||
float bandRow = floor(effectCoord.y / halftoneSize);
|
||||
float waveOffset =
|
||||
waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;
|
||||
effectCoord.x += waveOffset;
|
||||
|
||||
vec2 cellIndex = floor(effectCoord / halftoneSize);
|
||||
vec2 sampleUv = clamp(
|
||||
(cellIndex + 0.5) * halftoneSize / logicalResolution,
|
||||
vec2(0.0),
|
||||
vec2(1.0)
|
||||
);
|
||||
vec2 cellUv = fract(effectCoord / halftoneSize);
|
||||
|
||||
vec4 sceneSample = texture2D(tScene, sampleUv);
|
||||
float mask = smoothstep(0.02, 0.08, sceneSample.a);
|
||||
float lightLift =
|
||||
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
|
||||
float bandRadius = clamp(
|
||||
(
|
||||
(
|
||||
sceneSample.r +
|
||||
sceneSample.g +
|
||||
sceneSample.b +
|
||||
s_3 * length(vec2(0.5))
|
||||
) * (1.0 / 3.0)
|
||||
) + lightLift,
|
||||
0.0,
|
||||
1.0
|
||||
) * 1.86 * 0.5;
|
||||
|
||||
float alpha = 0.0;
|
||||
if (bandRadius > 0.0001) {
|
||||
float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4);
|
||||
float edge = 0.02;
|
||||
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
|
||||
}
|
||||
|
||||
vec3 color = dashColor * alpha;
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
const imagePassthroughFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tImage;
|
||||
uniform vec2 imageSize;
|
||||
uniform vec2 viewportSize;
|
||||
uniform float zoom;
|
||||
uniform float contrast;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float imageAspect = imageSize.x / imageSize.y;
|
||||
float viewAspect = viewportSize.x / viewportSize.y;
|
||||
|
||||
vec2 uv = vUv;
|
||||
|
||||
// Cover: fill the full masked area and crop overflow.
|
||||
if (imageAspect > viewAspect) {
|
||||
float scale = viewAspect / imageAspect;
|
||||
uv.x = (uv.x - 0.5) * scale + 0.5;
|
||||
} else {
|
||||
float scale = imageAspect / viewAspect;
|
||||
uv.y = (uv.y - 0.5) * scale + 0.5;
|
||||
}
|
||||
|
||||
uv = (uv - 0.5) / zoom + 0.5;
|
||||
|
||||
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
|
||||
* step(0.0, uv.y) * step(uv.y, 1.0);
|
||||
|
||||
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
|
||||
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
|
||||
|
||||
gl_FragColor = vec4(contrastColor, inBounds);
|
||||
}
|
||||
`;
|
||||
|
||||
type Rect = {
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type InteractionState = {
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
pointerInside: boolean;
|
||||
pointerVelocityX: number;
|
||||
pointerVelocityY: number;
|
||||
smoothedMouseX: number;
|
||||
smoothedMouseY: number;
|
||||
};
|
||||
|
||||
export type MountHalftoneCanvasOptions = {
|
||||
container: HTMLDivElement;
|
||||
imageUrl: string;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
function clampRectToViewport(
|
||||
rect: Rect,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
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: Rect | null) {
|
||||
if (!rect) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
|
||||
}
|
||||
|
||||
function createRenderTarget(width: number, height: number) {
|
||||
return new THREE.WebGLRenderTarget(width, height, {
|
||||
format: THREE.RGBAFormat,
|
||||
magFilter: THREE.LinearFilter,
|
||||
minFilter: THREE.LinearFilter,
|
||||
});
|
||||
}
|
||||
|
||||
function createInteractionState(): InteractionState {
|
||||
return {
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
pointerInside: false,
|
||||
pointerVelocityX: 0,
|
||||
pointerVelocityY: 0,
|
||||
smoothedMouseX: 0.5,
|
||||
smoothedMouseY: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function getImagePreviewZoom(previewDistance: number) {
|
||||
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
|
||||
}
|
||||
|
||||
function getContainedImageRect(options: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
zoom: number;
|
||||
}) {
|
||||
const { imageHeight, imageWidth, viewportHeight, viewportWidth, zoom } =
|
||||
options;
|
||||
|
||||
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: Rect | null,
|
||||
referenceRect: Rect | null,
|
||||
) {
|
||||
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(options: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
previewDistance: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
}) {
|
||||
const {
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
previewDistance,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
} = options;
|
||||
|
||||
const currentRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: getImagePreviewZoom(previewDistance),
|
||||
});
|
||||
const referenceRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
return getFootprintScaleFromRects(currentRect, referenceRect);
|
||||
}
|
||||
|
||||
function createAbortError() {
|
||||
return new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function createAbortTask(signal: AbortSignal) {
|
||||
let cleanup = () => {};
|
||||
const promise = new Promise<never>((_, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
const handleAbort = () => {
|
||||
reject(createAbortError());
|
||||
};
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
cleanup = () => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
};
|
||||
});
|
||||
|
||||
return { cleanup, promise };
|
||||
}
|
||||
|
||||
export async function mountHalftoneCanvas(options: MountHalftoneCanvasOptions) {
|
||||
const { container, imageUrl, signal } = options;
|
||||
const tuning = DEFAULT_TUNING;
|
||||
|
||||
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 abortTask = createAbortTask(signal);
|
||||
const image = await Promise.race([
|
||||
loadVisualImage(imageUrl, { label: 'problem monolith image' }),
|
||||
abortTask.promise,
|
||||
]).finally(abortTask.cleanup);
|
||||
|
||||
if (signal.aborted) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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);
|
||||
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: {
|
||||
contrast: { value: tuning.halftone.imageContrast },
|
||||
imageSize: { value: new THREE.Vector2(image.width, image.height) },
|
||||
tImage: { value: imageTexture },
|
||||
viewportSize: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
zoom: { value: getImagePreviewZoom(PREVIEW_DISTANCE) },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: imagePassthroughFragmentShader,
|
||||
});
|
||||
|
||||
const imageScene = new THREE.Scene();
|
||||
imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
|
||||
|
||||
const blurHorizontalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
dir: { value: new THREE.Vector2(1, 0) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
tInput: { value: null },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: blurFragmentShader,
|
||||
});
|
||||
|
||||
const blurVerticalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
dir: { value: new THREE.Vector2(0, 1) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
tInput: { value: null },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: blurFragmentShader,
|
||||
});
|
||||
|
||||
const halftoneMaterial = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
cropToBounds: { value: 1 },
|
||||
dashColor: { value: new THREE.Color(tuning.halftone.dashColor) },
|
||||
dragFlowStrength: { value: 0 },
|
||||
dragOffset: { value: new THREE.Vector2(0, 0) },
|
||||
effectResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
footprintScale: { value: 1 },
|
||||
hoverFlowRadius: { value: 0.18 },
|
||||
hoverFlowStrength: { value: 0 },
|
||||
hoverLightRadius: { value: tuning.animation.hoverLightRadius },
|
||||
hoverLightStrength: { value: 0 },
|
||||
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
interactionVelocity: { value: new THREE.Vector2(0, 0) },
|
||||
logicalResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
s_3: { value: tuning.halftone.power },
|
||||
s_4: { value: tuning.halftone.width },
|
||||
tGlow: { value: blurTargetB.texture },
|
||||
tile: { value: tuning.halftone.scale },
|
||||
time: { value: 0 },
|
||||
tScene: { value: sceneTarget.texture },
|
||||
waveAmount: { value: 0 },
|
||||
waveSpeed: { value: tuning.animation.waveSpeed },
|
||||
},
|
||||
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: number,
|
||||
logicalHeight: number,
|
||||
effectWidth: number,
|
||||
effectHeight: number,
|
||||
) => {
|
||||
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: PREVIEW_DISTANCE,
|
||||
viewportHeight: getVirtualHeight(),
|
||||
viewportWidth: getVirtualWidth(),
|
||||
});
|
||||
|
||||
const interaction = createInteractionState();
|
||||
|
||||
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 stopObservingSize = observeElementSize(container, syncSize);
|
||||
|
||||
const updatePointerPosition = (
|
||||
event: PointerEvent,
|
||||
options?: { resetVelocity?: boolean },
|
||||
) => {
|
||||
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 =
|
||||
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;
|
||||
return;
|
||||
}
|
||||
|
||||
interaction.pointerVelocityX = deltaX;
|
||||
interaction.pointerVelocityY = deltaY;
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
updatePointerPosition(event, { resetVelocity: true });
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
updatePointerPosition(event, {
|
||||
resetVelocity: !interaction.pointerInside,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
interaction.pointerInside = false;
|
||||
interaction.pointerVelocityX = 0;
|
||||
interaction.pointerVelocityY = 0;
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
updatePointerPosition(event, { resetVelocity: true });
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
interaction.pointerInside = false;
|
||||
interaction.pointerVelocityX = 0;
|
||||
interaction.pointerVelocityY = 0;
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointerdown', handlePointerDown);
|
||||
canvas.addEventListener('pointerleave', handlePointerLeave);
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
canvas.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
|
||||
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;
|
||||
halftoneMaterial.uniforms.tile.value = tuning.halftone.scale;
|
||||
halftoneMaterial.uniforms.waveSpeed.value = tuning.animation.waveSpeed;
|
||||
imageMaterial.uniforms.contrast.value = tuning.halftone.imageContrast;
|
||||
|
||||
interaction.smoothedMouseX +=
|
||||
(interaction.mouseX - interaction.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
|
||||
interaction.smoothedMouseY +=
|
||||
(interaction.mouseY - interaction.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
|
||||
interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
|
||||
interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
|
||||
|
||||
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.hoverLightStrength.value =
|
||||
interaction.pointerInside && tuning.animation.hoverLightEnabled
|
||||
? tuning.animation.hoverLightIntensity
|
||||
: 0;
|
||||
halftoneMaterial.uniforms.hoverLightRadius.value =
|
||||
tuning.animation.hoverLightRadius;
|
||||
halftoneMaterial.uniforms.hoverFlowStrength.value = 0;
|
||||
halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18;
|
||||
halftoneMaterial.uniforms.dragFlowStrength.value = 0;
|
||||
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(PREVIEW_DISTANCE);
|
||||
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);
|
||||
};
|
||||
|
||||
renderLoop = createVisualRenderLoop({
|
||||
renderFrame,
|
||||
shouldRender: () => !signal.aborted,
|
||||
target: container,
|
||||
targetVisibilityOptions: { rootMargin: '100px' },
|
||||
});
|
||||
renderLoop.start();
|
||||
|
||||
return () => {
|
||||
renderLoop?.dispose();
|
||||
stopObservingSize();
|
||||
canvas.removeEventListener('pointerdown', handlePointerDown);
|
||||
canvas.removeEventListener('pointerleave', handlePointerLeave);
|
||||
canvas.removeEventListener('pointermove', handlePointerMove);
|
||||
canvas.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
import { mountHalftoneCanvas } from './monolith-halftone-mount';
|
||||
|
||||
export function useMonolithHalftone({
|
||||
imageUrl,
|
||||
mountRef,
|
||||
}: {
|
||||
imageUrl: string;
|
||||
mountRef: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const container = mountRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const unmountPromise = mountHalftoneCanvas({
|
||||
container,
|
||||
imageUrl,
|
||||
signal: abortController.signal,
|
||||
}).catch((error) => {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
void unmountPromise.then((dispose) => dispose?.());
|
||||
};
|
||||
}, [imageUrl, mountRef]);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Problem } from './Problem';
|
||||
export type { ProblemPointType } from './problem-point-type';
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type ProblemPointType = {
|
||||
heading: ReactNode;
|
||||
body: MessageDescriptor;
|
||||
};
|
||||
Reference in New Issue
Block a user