feat(fish-eye): implement fisheye effect for text with customizable scale and frequency feat(irregular-text): create irregular text container components for dynamic shape wrapping feat(positional-scroller): add positional scroller component to track child elements' positions during scroll feat(scroll-progress-tracker): implement scroll progress tracker to monitor scroll position and update CSS variables
223 lines
7.1 KiB
TypeScript
223 lines
7.1 KiB
TypeScript
"use client";
|
|
|
|
import type { CSSProperties, ReactNode } from "react";
|
|
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import cn from "@bureaudouble/outils/cn.ts";
|
|
import polygonClipping, {
|
|
type MultiPolygon,
|
|
type Polygon,
|
|
} from "polygon-clipping";
|
|
|
|
// A single polygon is an array of points [ [x, y], [x, y], ... ]
|
|
type SimplePolygon = [number, number][];
|
|
|
|
/**
|
|
* Splits a polygon into its left and right halves based on a vertical line.
|
|
* The coordinate system is assumed to be 0-100 for both X and Y.
|
|
*/
|
|
function splitPolygonVertically(
|
|
polygon: Polygon,
|
|
splitX = 50,
|
|
minY = 0,
|
|
maxY = 100,
|
|
): { left: MultiPolygon; right: MultiPolygon } {
|
|
// Define the clipping areas for the left and right halves
|
|
const leftClipper: MultiPolygon = [[[[0, minY], [splitX, minY], [
|
|
splitX,
|
|
maxY,
|
|
], [0, maxY]]]];
|
|
const rightClipper: MultiPolygon = [[[[splitX, minY], [100, minY], [
|
|
100,
|
|
maxY,
|
|
], [splitX, maxY]]]];
|
|
|
|
const inputMultiPolygon: MultiPolygon = [polygon];
|
|
|
|
// Perform the intersection
|
|
const left = polygonClipping.intersection(inputMultiPolygon, leftClipper);
|
|
const right = polygonClipping.intersection(inputMultiPolygon, rightClipper);
|
|
|
|
return { left, right };
|
|
}
|
|
|
|
/**
|
|
* Takes a single polygon definition and computes the left and right CSS `polygon()` strings
|
|
* required for the `shape-outside` property.
|
|
*
|
|
* @param polygon The input polygon, with coordinates from 0 to 100.
|
|
* @returns An object with `left` and `right` CSS polygon strings.
|
|
*/
|
|
export function generateShapeOutsidePolygons(
|
|
polygon: SimplePolygon,
|
|
): { left: string; right: string } {
|
|
// The full container area to subtract from
|
|
const fullContainer: Polygon = [[[0, 0], [100, 0], [100, 100], [0, 100]]];
|
|
|
|
// Split both the container and the input shape into left/right halves
|
|
const { left: leftContainer, right: rightContainer } = splitPolygonVertically(
|
|
fullContainer,
|
|
);
|
|
const { left: leftShape, right: rightShape } = splitPolygonVertically([
|
|
polygon,
|
|
]);
|
|
|
|
// Calculate the difference: Area = ContainerHalf - ShapeHalf
|
|
const leftDifference = polygonClipping.difference(leftContainer, leftShape);
|
|
const rightDifference = polygonClipping.difference(
|
|
rightContainer,
|
|
rightShape,
|
|
);
|
|
|
|
// Format the resulting coordinates into a CSS polygon() string.
|
|
// This assumes the difference operation results in a single continuous polygon.
|
|
const leftPolygonString = leftDifference[0]?.[0]
|
|
?.map(([x, y]) => `${x * 2}% ${y}%`) // Scale X from 0-50 range to 0-100%
|
|
.join(", ");
|
|
|
|
const rightPolygonString = rightDifference[0]?.[0]
|
|
?.map(([x, y]) => `${(x - 50) * 2}% ${y}%`) // Scale X from 50-100 range to 0-100%
|
|
.join(", ");
|
|
|
|
return {
|
|
left: leftPolygonString || "",
|
|
right: rightPolygonString || "",
|
|
};
|
|
}
|
|
|
|
type IrregularShapeProps = {
|
|
/** A single, closed polygon defining the desired inner shape. Coordinates should be 0-100. */
|
|
polygon: SimplePolygon;
|
|
/** The content to be wrapped inside the shape. */
|
|
children: ReactNode;
|
|
/** Optional custom className for the wrapper element. */
|
|
className?: string;
|
|
ref: React.RefObject<HTMLDivElement>;
|
|
style?: React.CSSProperties;
|
|
};
|
|
|
|
export const IrregularShape = ({
|
|
polygon,
|
|
children,
|
|
className,
|
|
ref,
|
|
style,
|
|
}: IrregularShapeProps) => {
|
|
// Memoize the expensive polygon calculation.
|
|
// It will only re-run if the `polygon` prop changes.
|
|
const { left, right } = useMemo(
|
|
() => generateShapeOutsidePolygons(polygon),
|
|
[polygon],
|
|
);
|
|
|
|
// Dynamically create the style objects for the shaper divs.
|
|
const leftShapeStyle: CSSProperties = {
|
|
shapeOutside: `polygon(${left})`,
|
|
// Prefix for Safari compatibility
|
|
WebkitShapeOutside: `polygon(${left})`,
|
|
};
|
|
|
|
const rightShapeStyle: CSSProperties = {
|
|
shapeOutside: `polygon(${right})`,
|
|
WebkitShapeOutside: `polygon(${right})`,
|
|
};
|
|
|
|
return (
|
|
<div className={cn("relative", className)} ref={ref} style={style}>
|
|
{/* These two divs create the text boundaries. They are invisible. */}
|
|
{/* We use inline styles because the polygon values are dynamic. */}
|
|
<div
|
|
className="float-left h-full w-1/2"
|
|
style={left ? leftShapeStyle : undefined}
|
|
/>
|
|
<div
|
|
className="float-right h-full w-1/2"
|
|
style={right ? rightShapeStyle : undefined}
|
|
/>
|
|
|
|
{/* The actual text content flows into the space between them. */}
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type AutoSizingIrregularShapeProps = {
|
|
polygon: SimplePolygon;
|
|
children: ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
export function AutoSizingIrregularShape({
|
|
polygon,
|
|
children,
|
|
className,
|
|
}: AutoSizingIrregularShapeProps) {
|
|
// State to hold the final, calculated height.
|
|
const [optimalHeight, setOptimalHeight] = useState<number | null>(null);
|
|
|
|
// Ref to the inner div that wraps the children, used for measurement.
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Ref to the main IrregularShape container.
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// This effect runs the binary search to find the optimal height.
|
|
useLayoutEffect(() => {
|
|
if (!containerRef.current || !contentRef.current) return;
|
|
|
|
// --- The Binary Search for Optimal Height ---
|
|
|
|
let minHeight = 0;
|
|
let maxHeight = 3000; // A reasonable upper bound for the search.
|
|
let bestHeight = maxHeight;
|
|
let iterations = 0;
|
|
|
|
while (minHeight <= maxHeight && iterations < 20) { // Failsafe with iteration limit
|
|
const midHeight = Math.floor((minHeight + maxHeight) / 2);
|
|
|
|
// Apply the test height to the container
|
|
containerRef.current.style.height = `${midHeight}px`;
|
|
|
|
// Measure the resulting height of the content
|
|
const measuredContentHeight = contentRef.current.offsetHeight;
|
|
|
|
// Apply your core logic:
|
|
if (measuredContentHeight > midHeight) {
|
|
// Text is overflowing. Container is too short.
|
|
// The optimal height must be in the upper half of the search range.
|
|
minHeight = midHeight + 1;
|
|
} else {
|
|
// Text fits. This height is a potential answer.
|
|
// The optimal height could be this or something smaller.
|
|
// We record this as the best height found so far and search the lower half.
|
|
bestHeight = midHeight;
|
|
maxHeight = midHeight - 1;
|
|
}
|
|
iterations++;
|
|
}
|
|
|
|
// After the loop, `bestHeight` holds the smallest height that worked.
|
|
// We set it in state to trigger a final, clean render.
|
|
if (Number.isFinite(bestHeight)) setOptimalHeight(bestHeight);
|
|
|
|
// Cleanup: remove the inline style to let the state-driven height take over.
|
|
// containerRef.current.style.height = "";
|
|
|
|
// This effect should re-run whenever the content, shape, or styling changes.
|
|
}, [children, polygon, className]);
|
|
|
|
return (
|
|
<IrregularShape
|
|
ref={containerRef}
|
|
polygon={polygon}
|
|
className={className}
|
|
// Apply the final calculated height. Use a large initial height to prevent flash of unstyled content.
|
|
style={{
|
|
height: optimalHeight === null ? "3000px" : `${optimalHeight}px`,
|
|
}}
|
|
>
|
|
<div ref={contentRef}>
|
|
{children}
|
|
</div>
|
|
</IrregularShape>
|
|
);
|
|
}
|