Skip to content

Commit 96eff1d

Browse files
feat: uniform card height via upfront measurement, resetAllCardAnchors
1 parent 8186c2a commit 96eff1d

6 files changed

Lines changed: 67 additions & 28 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "next-easytour",
3-
"version": "0.3.0-alpha.27",
3+
"version": "0.3.0-alpha.28",
44
"description": "Generalizable interactive tutorial overlay library for React/Next.js. CSS-selector targeting, step actions, waitFor conditions, auto-scroll, highlight effects, animated transitions — all composable and declarative.",
55
"keywords": [
66
"tutorial",

src/core/Tutorial.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ export function Tutorial<Meta = never>(props: TutorialProps<Meta>) {
428428
const api = useMemo<TutorialApi<Meta>>(
429429
() => ({
430430
...snapshot,
431+
steps,
431432
next,
432433
prev,
433434
goto,
@@ -436,7 +437,7 @@ export function Tutorial<Meta = never>(props: TutorialProps<Meta>) {
436437
defaultCardAnchor,
437438
cardPositioning,
438439
}),
439-
[snapshot, next, prev, goto, close, getTargetElement, defaultCardAnchor, cardPositioning],
440+
[snapshot, steps, next, prev, goto, close, getTargetElement, defaultCardAnchor, cardPositioning],
440441
);
441442

442443
// ── Card rect store ───────────────────────────────────────────────────

src/core/state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface SnapshotInput<Meta> {
1818

1919
export function computeSnapshot<Meta>(
2020
input: SnapshotInput<Meta>,
21-
): Omit<TutorialApi<Meta>, "next" | "prev" | "goto" | "close" | "getTargetElement" | "defaultCardAnchor" | "cardPositioning"> {
21+
): Omit<TutorialApi<Meta>, "next" | "prev" | "goto" | "close" | "getTargetElement" | "defaultCardAnchor" | "cardPositioning" | "steps"> {
2222
const { steps, stepId, transitioning, canAdvance, isWaiting } = input;
2323

2424
const total = steps.length;

src/overlay/Card.tsx

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
6565
} = props;
6666
const api = useTutorial<Meta>();
6767
const {
68-
step, index, total, isFirst, isLast, canAdvance, isWaiting,
68+
step, steps: allSteps, index, total, isFirst, isLast, canAdvance, isWaiting,
6969
next, prev, close, defaultCardAnchor, cardPositioning,
7070
} = api;
7171

@@ -74,20 +74,32 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
7474
const bodyId = `${baseId}-body`;
7575

7676
// ── Card rect publication ─────────────────────────────────────────────
77-
// Publish the card's live bounding rect so <Arrow> and <EditorHandles>
78-
// can read it. Uses a rAF loop instead of ResizeObserver alone because
79-
// editor drags change the card's position without changing its size,
80-
// and ResizeObserver only fires on size changes.
8177
const cardRef = useRef<HTMLDivElement>(null);
78+
const measureRef = useRef<HTMLDivElement>(null);
8279
const setCardRect = useCardRectSetter();
8380
const lastRectRef = useRef<Rect | null>(null);
8481
const rafRef = useRef<number>(0);
8582

86-
// ── Stable card sizing ────────────────────────────────────────────────
87-
// Track the maximum height the card reaches across all steps.
88-
// Apply as minHeight so the card never shrinks → no visual jump.
89-
const maxHeightRef = useRef<number>(0);
83+
// ── Uniform card sizing ───────────────────────────────────────────────
84+
// Pre-measure ALL steps in a hidden off-screen container on mount.
85+
// The max height becomes `stableMinHeight` — applied from step 1.
9086
const [stableMinHeight, setStableMinHeight] = useState<number>(0);
87+
const measuredRef = useRef(false);
88+
89+
useLayoutEffect(() => {
90+
if (measuredRef.current || !measureRef.current) return;
91+
const container = measureRef.current;
92+
const children = container.querySelectorAll<HTMLElement>("[data-eto-measure]");
93+
let max = 0;
94+
children.forEach((el) => {
95+
const h = el.getBoundingClientRect().height;
96+
if (h > max) max = h;
97+
});
98+
if (max > 0) {
99+
setStableMinHeight(max);
100+
measuredRef.current = true;
101+
}
102+
});
91103

92104
useLayoutEffect(() => {
93105
if (!cardRef.current) return;
@@ -97,13 +109,6 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
97109
const read = () => {
98110
const r = el.getBoundingClientRect();
99111
const next: Rect = { left: r.left, top: r.top, width: r.width, height: r.height };
100-
101-
// Grow-only: update stable height when content is taller
102-
if (r.height > maxHeightRef.current + 0.5) {
103-
maxHeightRef.current = r.height;
104-
setStableMinHeight(r.height);
105-
}
106-
107112
const prev = lastRectRef.current;
108113
if (
109114
prev &&
@@ -200,17 +205,15 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
200205

201206
if (!step) return null;
202207

208+
const isRenderProp = typeof children === "function";
209+
const renderFn = isRenderProp ? (children as (a: CardRenderArgs<Meta>) => React.ReactNode) : null;
210+
211+
// Build render args for the active step
203212
const renderArgs: CardRenderArgs<Meta> = {
204213
step, index, total, isFirst, isLast, canAdvance, isWaiting, stableMinHeight, next, prev, close,
205214
};
206-
const isRenderProp = typeof children === "function";
207-
const rendered = isRenderProp
208-
? (children as (a: CardRenderArgs<Meta>) => React.ReactNode)(renderArgs)
209-
: null;
215+
const rendered = renderFn ? renderFn(renderArgs) : null;
210216

211-
// When render-prop mode is active, the wrapper is just a positioning
212-
// container — the host's render-prop provides all visual styling.
213-
// .eto-card--custom strips border/shadow/bg from the wrapper.
214217
const className = [
215218
"eto-card",
216219
isRenderProp ? "eto-card--custom" : "",
@@ -222,8 +225,41 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
222225
...(stableMinHeight > 0 ? { minHeight: stableMinHeight } : {}),
223226
};
224227

228+
// ── Hidden measurement container ────────────────────────────────────
229+
// Renders every step's content off-screen once to find the tallest.
230+
// After measurement, the container is removed (measuredRef = true).
231+
const measureContainer = !measuredRef.current ? (
232+
<div
233+
ref={measureRef}
234+
aria-hidden="true"
235+
style={{
236+
position: "fixed",
237+
left: -9999,
238+
top: -9999,
239+
visibility: "hidden",
240+
pointerEvents: "none",
241+
width: "min(420px, calc(100vw - 2rem))",
242+
}}
243+
>
244+
{allSteps.map((s, i) => {
245+
const mockArgs: CardRenderArgs<Meta> = {
246+
step: s, index: i, total, isFirst: i === 0, isLast: i === total - 1,
247+
canAdvance: true, isWaiting: false, stableMinHeight: 0, next, prev, close,
248+
};
249+
return (
250+
<div key={s.id} data-eto-measure>
251+
{renderFn ? renderFn(mockArgs) : (
252+
<DefaultCardBody api={{ ...api, step: s, index: i } as TutorialApi<unknown>} titleId="" bodyId="" />
253+
)}
254+
</div>
255+
);
256+
})}
257+
</div>
258+
) : null;
259+
225260
return (
226261
<OverlayPortal>
262+
{measureContainer}
227263
<div
228264
ref={cardRef}
229265
className={className}

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ export interface TutorialProps<Meta = never> {
227227
export interface TutorialApi<Meta = never> {
228228
status: TutorialStatus;
229229
step: Step<Meta> | null;
230+
/** All steps in the tutorial. */
231+
steps: Step<Meta>[];
230232
index: number;
231233
total: number;
232234
isFirst: boolean;

0 commit comments

Comments
 (0)