Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"dev": "pnpm run watch & pnpm run site:dev",
"build": "pnpm --filter=torph build",
"site:dev": "pnpm --filter=site dev",
"site:build": "pnpm run build && pnpm --filter=site build",
"site:build": "pnpm run build && node scripts/bundle-sizes.mjs && pnpm --filter=site build",
"example:react": "pnpm --filter=react-example dev",
"example:svelte": "pnpm --filter=svelte-example dev",
"example:svelte-ssr": "pnpm --filter=svelte-ssr-example dev",
Expand Down
6 changes: 4 additions & 2 deletions packages/torph/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"dev": "tsup --watch",
"lint": "eslint -c .eslintrc.cjs ./src/**/*.{ts,tsx}",
"lint:fix": "eslint --fix -c .eslintrc.cjs ./src/**/*.{ts,tsx}",
"test": "vitest run",
"pre-commit": "lint-staged"
},
"keywords": [
Expand All @@ -70,8 +71,8 @@
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"vue": ">=3",
"svelte": ">=5"
"svelte": ">=5",
"vue": ">=3"
},
"peerDependenciesMeta": {
"react": {
Expand Down Expand Up @@ -106,6 +107,7 @@
"svelte": "^5.0.0",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18",
"vue": "^3.3.0"
}
}
4 changes: 4 additions & 0 deletions packages/torph/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export { DEFAULT_AS, DEFAULT_TEXT_MORPH_OPTIONS, MorphController, TextMorph } from "./lib/text-morph";
export type { TextMorphOptions } from "./lib/text-morph/types";
export type { SpringParams } from "./lib/text-morph/utils/spring";
export { segmentText } from "./lib/text-morph/utils/segment";
export type { Segment } from "./lib/text-morph/utils/segment";
export { diffSegments } from "./lib/text-morph/utils/diff";
export type { DiffResult } from "./lib/text-morph/utils/diff";
95 changes: 78 additions & 17 deletions packages/torph/src/lib/text-morph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
animateEnterOrPersist,
transitionContainerSize,
} from "./utils/animate";
import { detachFromFlow, reconcileChildren } from "./utils/dom";
import { detachFromFlow, splitWordSpans, reconcileChildren } from "./utils/dom";
import { diffSegments } from "./utils/diff";
import { addStyles, removeStyles } from "./utils/styles";
import {
ATTR_ROOT,
Expand Down Expand Up @@ -49,9 +50,10 @@ export class TextMorph {

private currentMeasures: Measures = {};
private prevMeasures: Measures = {};
private previousSegments: Segment[] = [];
private isInitialRender = true;
private reducedMotion: ReducedMotionState | null = null;

private emptyTransitionTimer: ReturnType<typeof setTimeout> | null = null;

constructor(options: TextMorphOptions) {
const { ease: rawEase, ...rest } = { ...DEFAULT_TEXT_MORPH_OPTIONS, ...options };
Expand All @@ -77,9 +79,6 @@ export class TextMorph {

if (!this.isDisabled()) {
this.element.setAttribute(ATTR_ROOT, "");
this.element.style.transitionDuration = `${this.options.duration}ms`;
this.element.style.transitionTimingFunction = this.options.ease!;

if (options.debug) this.element.setAttribute(ATTR_DEBUG, "");
}

Expand All @@ -90,6 +89,10 @@ export class TextMorph {
}

destroy() {
if (this.emptyTransitionTimer !== null) {
clearTimeout(this.emptyTransitionTimer);
this.emptyTransitionTimer = null;
}
this.reducedMotion?.destroy();
this.element.getAnimations().forEach((anim) => anim.cancel());
this.element.removeAttribute(ATTR_ROOT);
Expand Down Expand Up @@ -126,11 +129,40 @@ export class TextMorph {
}

private createTextGroup(value: string, element: HTMLElement) {
// Cancel any pending empty-transition timeout from a previous morph
// and restore the styles it would have cleaned up
if (this.emptyTransitionTimer !== null) {
clearTimeout(this.emptyTransitionTimer);
this.emptyTransitionTimer = null;
element.style.width = "auto";
element.style.height = "auto";
element.style.transitionProperty = "";
}

const oldRect = element.getBoundingClientRect();
const oldWidth = oldRect.width;
const oldHeight = oldRect.height;

const segments = segmentText(value, this.options.locale!);
let segments: Segment[];
let splits: Map<string, Segment[]>;

if (this.previousSegments.length > 0) {
const result = diffSegments(this.previousSegments, value, this.options.locale!);
segments = result.segments;
splits = result.splits;
} else {
segments = segmentText(value, this.options.locale!);
splits = new Map();
}

// Keep a zero-width space segment so the container always has in-flow
// content, preserving the line box height during exit animations.
const isEmptyTransition = segments.length === 0;
if (isEmptyTransition) {
segments = [{ id: "empty", string: "\u200B" }];
}

splitWordSpans(element, splits);

this.prevMeasures = measure(this.element);
const oldChildren = Array.from(element.children) as HTMLElement[];
Expand All @@ -157,10 +189,19 @@ export class TextMorph {
reconcileChildren(element, oldChildren, newIds, segments);

this.currentMeasures = measure(this.element);
this.updateStyles(segments);

// Measure at oldWidth to get actual first-frame positions.
// This correctly handles text-align when content overflows the container
// (text-align has no effect on overflowing content).
element.style.width = `${oldWidth}px`;
void element.offsetWidth;
const firstFrameMeasures = measure(this.element);
element.style.width = "auto";

this.updateStyles(segments, firstFrameMeasures);

exiting.forEach((child) => {
if (this.isInitialRender) {
if (this.isInitialRender || child.getAttribute(ATTR_ID) === "empty") {
child.remove();
return;
}
Expand All @@ -179,23 +220,41 @@ export class TextMorph {
});
});

this.previousSegments = segments;

if (this.isInitialRender) {
this.isInitialRender = false;
element.style.width = "auto";
element.style.height = "auto";
return;
}

transitionContainerSize(
element,
oldWidth,
oldHeight,
this.options.duration!,
this.options.onAnimationComplete,
);
if (isEmptyTransition) {
// Lock container at old size while exits play so the container
// doesn't reposition (e.g. under text-align: center).
element.style.transitionProperty = "none";
element.style.width = `${oldWidth}px`;
element.style.height = `${oldHeight}px`;
this.emptyTransitionTimer = setTimeout(() => {
this.emptyTransitionTimer = null;
element.style.width = "auto";
element.style.height = "auto";
element.style.transitionProperty = "";
this.options.onAnimationComplete?.();
}, this.options.duration!);
} else {
transitionContainerSize(
element,
oldWidth,
oldHeight,
this.options.duration!,
this.options.ease!,
this.options.onAnimationComplete,
);
}
}

private updateStyles(segments: Segment[]) {
private updateStyles(segments: Segment[], firstFrameMeasures: Measures) {
if (this.isInitialRender) return;

const children = Array.from(this.element.children) as HTMLElement[];
Expand All @@ -207,7 +266,9 @@ export class TextMorph {

children.forEach((child, index) => {
if (child.hasAttribute(ATTR_EXITING)) return;
if (child.tagName === "BR") return;
const key = child.getAttribute(ATTR_ID) || `child-${index}`;
if (key === "empty") return;
const isNew = !this.prevMeasures[key];

const deltaKey = isNew
Expand All @@ -219,7 +280,7 @@ export class TextMorph {
: key;

const { dx: deltaX, dy: deltaY } = deltaKey
? computeDelta(this.prevMeasures, this.currentMeasures, deltaKey)
? computeDelta(this.prevMeasures, firstFrameMeasures, deltaKey)
: { dx: 0, dy: 0 };

animateEnterOrPersist(child, {
Expand Down
Loading