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
40 changes: 40 additions & 0 deletions src/components/CustomConnectionLine.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,44 @@ describe('CustomConnectionLine', () => {
expect(path?.getAttribute('stroke-dasharray')).toBe('8 6');
expect(pulsingCircle).not.toBeNull();
});

it('publishes one dash period per cycle so the dash loop has no snap-back', () => {
function renderAt(pointer: { x: number; y: number }): SVGPathElement {
const { container } = render(
<svg>
<CustomConnectionLine
connectionLineType={ConnectionLineType.Bezier}
connectionStatus="valid"
fromNode={null}
fromHandle={null}
fromX={0}
fromY={0}
fromPosition={Position.Right}
pointer={pointer}
toNode={null}
toHandle={null}
toX={pointer.x}
toY={pointer.y}
toPosition={Position.Left}
/>
</svg>
);
return container.querySelector('path') as SVGPathElement;
}

// Both patterns are 8 + 6 in some order, so both loop over 14 units.
const near = renderAt({ x: 110, y: 110 });
expect(near.getAttribute('stroke-dasharray')).toBe('8 6');
expect(near.style.getPropertyValue('--flow-connection-dash-period')).toBe('14');

const far = renderAt({ x: 900, y: 900 });
expect(far.getAttribute('stroke-dasharray')).toBe('6 8');
expect(far.style.getPropertyValue('--flow-connection-dash-period')).toBe('14');

// Publishing the period is useless unless the keyframes read it, so pin that too.
const keyframes = far.closest('g')?.querySelector('style')?.textContent ?? '';
expect(keyframes).toContain('var(--flow-connection-dash-period');
// The travelled distance is the `from` value; a hard-coded one is the bug.
expect(keyframes).not.toMatch(/from\s*\{\s*stroke-dashoffset:\s*\d/);
});
});
13 changes: 10 additions & 3 deletions src/components/CustomConnectionLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import { ConnectionLineComponentProps, getBezierPath, useNodes } from '@/lib/reactflowCompat';
import { NODE_WIDTH, NODE_HEIGHT } from '../constants';
import type { FlowNode } from '@/lib/types';
import { getDashPatternPeriod } from './custom-edge/dashPattern';

const SNAP_PADDING = 56;

Expand Down Expand Up @@ -41,19 +42,25 @@ const CustomConnectionLine = ({
targetPosition: toPosition,
});
const connectionStroke = 'var(--brand-primary, #6366f1)';
const dashArray = isNearNode ? '8 6' : '6 8';
// Travel exactly one dash period per cycle, or the pattern snaps back on every loop.
// Left unset if it cannot be derived, so the keyframes' own fallback applies — a 0
// here would satisfy `var()` and freeze the animation instead.
const dashPeriod = getDashPatternPeriod(dashArray);

return (
<g>
<path
fill="none"
stroke={connectionStroke}
strokeWidth={2.5}
strokeDasharray={isNearNode ? '8 6' : '6 8'}
strokeDasharray={dashArray}
strokeLinecap="round"
style={{
filter: 'drop-shadow(0 1px 3px rgba(99,102,241,0.25))',
animation: 'flow-connection-dash 0.8s linear infinite',
}}
...(dashPeriod === null ? {} : { '--flow-connection-dash-period': dashPeriod }),
} as React.CSSProperties}
d={edgePath}
/>

Expand Down Expand Up @@ -87,7 +94,7 @@ const CustomConnectionLine = ({
{`
@keyframes flow-connection-dash {
from {
stroke-dashoffset: 20;
stroke-dashoffset: var(--flow-connection-dash-period, 14);
}
to {
stroke-dashoffset: 0;
Expand Down
11 changes: 9 additions & 2 deletions src/components/custom-edge/CustomEdgeWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
toMarkerUrl,
} from './classRelationSemantics';
import { resolveStandardEdgeMarkers } from './standardEdgeMarkers';
import { resolveAnimatedEdgePresentation } from './animatedEdgePresentation';
import { resolveAnimatedEdgePresentation, withDashPeriodVar } from './animatedEdgePresentation';
import {
buildEdgeLabelUpdates,
getEditableEdgeLabel,
Expand Down Expand Up @@ -115,7 +115,10 @@ export const CustomEdgeWrapper = memo(function CustomEdgeWrapper({
);

const resolvedStyle = useMemo<React.CSSProperties>(
() => ({
// Animated edges loop `stroke-dashoffset`, which only joins up seamlessly when it
// travels one dash period per cycle, so publish the period of whatever pattern this
// edge ended up with. The CSS default covers edges that set none.
() => withDashPeriodVar({
stroke: designSystem.colors.edge,
strokeWidth: designSystem.components.edge.strokeWidth,
...style,
Expand Down Expand Up @@ -376,6 +379,10 @@ export const CustomEdgeWrapper = memo(function CustomEdgeWrapper({
fill="none"
stroke="rgba(15,23,42,0.001)"
strokeWidth={20}
// React Flow dashes and animates every path in an `.animated` edge, which
// would leave this hit target responding only on the moving dashes. A
// presentation attribute loses to its class rule, so override inline.
style={{ strokeDasharray: 'none', animation: 'none' }}
pointerEvents="stroke"
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
Expand Down
110 changes: 109 additions & 1 deletion src/components/custom-edge/animatedEdgePresentation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { CSSProperties } from 'react';
import { describe, expect, it } from 'vitest';
import { resolveAnimatedEdgePresentation } from './animatedEdgePresentation';
import {
DASH_PERIOD_CSS_VAR,
resolveAnimatedEdgePresentation,
withDashPeriodVar,
} from './animatedEdgePresentation';
import { getDashPatternPeriod } from './dashPattern';

describe('animated edge presentation', () => {
it('preserves hover and selection overlays when animated export is disabled', () => {
Expand Down Expand Up @@ -50,4 +56,106 @@ describe('animated edge presentation', () => {

expect(result.shouldRenderOverlay).toBe(false);
});

describe('dash loop period', () => {
function periodVarFor(dashArray: string | undefined): unknown {
const result = resolveAnimatedEdgePresentation({
animatedExportEnabled: true,
selected: false,
hovered: false,
edgeAnimated: true,
animationConfig: { enabled: true, state: 'active', dashArray },
baseStyle: { stroke: '#000', strokeWidth: 2 },
});

return (result.overlayStyle as Record<string, unknown>)[DASH_PERIOD_CSS_VAR];
}

it('publishes one dash period per cycle for every dash preset', () => {
// The presets offered in EdgeStyleSection — each needs its own travel distance,
// otherwise the loop snaps back by the remainder every cycle.
expect(periodVarFor('8 4')).toBe(12);
expect(periodVarFor('2 4')).toBe(6);
expect(periodVarFor('8 4 2 4')).toBe(18);
});

it('publishes the period of the default pattern when none is configured', () => {
expect(periodVarFor(undefined)).toBe(16);
});

it('derives the period from the edge style when the animation sets no pattern', () => {
const result = resolveAnimatedEdgePresentation({
animatedExportEnabled: true,
selected: false,
hovered: false,
edgeAnimated: true,
animationConfig: { enabled: true, state: 'active' },
baseStyle: { stroke: '#000', strokeWidth: 2, strokeDasharray: '6 4' },
});

expect(result.overlayStyle.strokeDasharray).toBe('6 4');
expect((result.overlayStyle as Record<string, unknown>)[DASH_PERIOD_CSS_VAR]).toBe(10);
});

it('always matches the period helper for whatever pattern it emits', () => {
const result = resolveAnimatedEdgePresentation({
animatedExportEnabled: false,
selected: true,
hovered: false,
edgeAnimated: false,
baseStyle: { stroke: '#000', strokeWidth: 2, strokeDasharray: '8 4 2 4' },
});

expect((result.overlayStyle as Record<string, unknown>)[DASH_PERIOD_CSS_VAR])
.toBe(getDashPatternPeriod(result.overlayStyle.strokeDasharray));
});

it('leaves the var unset when the pattern has no resolvable period', () => {
expect(periodVarFor('10%')).toBeUndefined();
});
});

// This is the helper the visible edge path uses — the one animated edges actually
// render today. Without these, removing the call from CustomEdgeWrapper would
// reintroduce the snap-back on every dash preset with a green suite.
describe('withDashPeriodVar', () => {
function periodOf(style: CSSProperties): unknown {
return (withDashPeriodVar(style) as Record<string, unknown>)[DASH_PERIOD_CSS_VAR];
}

it('publishes the period of the style it is given', () => {
expect(periodOf({ strokeDasharray: '8 4' })).toBe(12);
expect(periodOf({ strokeDasharray: '2 4' })).toBe(6);
expect(periodOf({ strokeDasharray: '8 4 2 4' })).toBe(18);
expect(periodOf({ strokeDasharray: '6 4' })).toBe(10);
});

it('leaves the var unset for an edge with no pattern, so the CSS default applies', () => {
// The "solid" preset writes an empty string, which React drops entirely; React
// Flow's own `stroke-dasharray: 5` then paints and the CSS default of 10 matches.
expect(periodOf({})).toBeUndefined();
expect(periodOf({ strokeDasharray: '' })).toBeUndefined();
});

it('never publishes 0, which would satisfy var() and freeze the animation', () => {
expect(periodOf({ strokeDasharray: '0 0' })).toBeUndefined();
expect(periodOf({ strokeDasharray: '10%' })).toBeUndefined();
});

it('keeps the rest of the style untouched', () => {
const result = withDashPeriodVar({ stroke: '#abc', strokeWidth: 3, strokeDasharray: '8 4' });

expect(result.stroke).toBe('#abc');
expect(result.strokeWidth).toBe(3);
expect(result.strokeDasharray).toBe('8 4');
});

it('always agrees with the period helper for the pattern it emits', () => {
for (const strokeDasharray of ['8 4', '2 4', '8 4 2 4', '6 4', '5', '8 8']) {
const result = withDashPeriodVar({ strokeDasharray });
expect((result as Record<string, unknown>)[DASH_PERIOD_CSS_VAR], strokeDasharray)
.toBe(getDashPatternPeriod(strokeDasharray));
}
});
});
});
32 changes: 26 additions & 6 deletions src/components/custom-edge/animatedEdgePresentation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
import type { CSSProperties } from 'react';
import type { EdgeAnimationConfig } from '@/lib/types';
import { getDashPatternPeriod } from './dashPattern';

/**
* Distance the `flow-edge-dash` keyframes travel per cycle. Must equal the dash
* pattern's period or the loop snaps back by the remainder every cycle, so it is
* published per edge instead of being baked into the keyframes.
*/
export const DASH_PERIOD_CSS_VAR = '--flow-edge-dash-period';

/**
* Publish the dash period of `style`'s own pattern so the loop travels exactly that far
* per cycle. Left unset when the pattern has no resolvable period, so the CSS default
* for the element applies — never set to 0, which would satisfy `var()`'s fallback and
* freeze the animation.
*/
export function withDashPeriodVar(style: CSSProperties): CSSProperties {
const period = getDashPatternPeriod(style.strokeDasharray);
return period === null ? style : ({ ...style, [DASH_PERIOD_CSS_VAR]: period } as CSSProperties);
}

interface ResolveAnimatedEdgePresentationParams {
animatedExportEnabled: boolean;
Expand All @@ -23,14 +42,15 @@ export function resolveAnimatedEdgePresentation({
animationConfig,
baseStyle,
}: ResolveAnimatedEdgePresentationParams): AnimatedEdgePresentation {
const overlayStyle: CSSProperties = {
const strokeDasharray = animationConfig?.dashArray
?? (typeof baseStyle.strokeDasharray === 'string' && baseStyle.strokeDasharray.length > 0
? baseStyle.strokeDasharray
: '8 8');
const overlayStyle: CSSProperties = withDashPeriodVar({
stroke: baseStyle.stroke,
strokeWidth: Math.max(Number(baseStyle.strokeWidth ?? 2), 2),
strokeDasharray: animationConfig?.dashArray
?? (typeof baseStyle.strokeDasharray === 'string' && baseStyle.strokeDasharray.length > 0
? baseStyle.strokeDasharray
: '8 8'),
};
strokeDasharray,
});

if (!animatedExportEnabled) {
return {
Expand Down
58 changes: 58 additions & 0 deletions src/components/custom-edge/dashPattern.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { getDashPatternPeriod } from './dashPattern';

describe('getDashPatternPeriod', () => {
it('sums an even-length pattern', () => {
expect(getDashPatternPeriod('8 8')).toBe(16);
expect(getDashPatternPeriod('8 4')).toBe(12);
expect(getDashPatternPeriod('2 4')).toBe(6);
expect(getDashPatternPeriod('8 4 2 4')).toBe(18);
});

it('doubles an odd-length pattern, as SVG repeats the list to make it even', () => {
expect(getDashPatternPeriod('6')).toBe(12);
expect(getDashPatternPeriod('8 4 2')).toBe(28);
});

it('accepts a bare number as a one-entry list', () => {
expect(getDashPatternPeriod(6)).toBe(12);
});

it('accepts comma separators, extra whitespace and px units', () => {
expect(getDashPatternPeriod('8, 4')).toBe(12);
expect(getDashPatternPeriod(' 8 4 ')).toBe(12);
expect(getDashPatternPeriod('8px 4px')).toBe(12);
});

it('supports fractional values', () => {
expect(getDashPatternPeriod('1.5 2.5')).toBe(4);
});

it('returns null when there is no resolvable dash pattern', () => {
expect(getDashPatternPeriod(undefined)).toBeNull();
expect(getDashPatternPeriod('')).toBeNull();
expect(getDashPatternPeriod(' ')).toBeNull();
expect(getDashPatternPeriod('none')).toBeNull();
expect(getDashPatternPeriod('0 0')).toBeNull();
expect(getDashPatternPeriod(0)).toBeNull();
});

it('returns null for units it cannot resolve without the path length', () => {
expect(getDashPatternPeriod('10%')).toBeNull();
expect(getDashPatternPeriod('8 10%')).toBeNull();
expect(getDashPatternPeriod('2em')).toBeNull();
});

it('returns null for invalid patterns rather than guessing', () => {
expect(getDashPatternPeriod('8 -4')).toBeNull();
expect(getDashPatternPeriod('8 abc')).toBeNull();
expect(getDashPatternPeriod('NaN')).toBeNull();
});

it('rejects numbers CSS itself rejects, so a dropped declaration cannot get a period', () => {
// `8.` is not a valid CSS number, so the browser drops the whole declaration and
// paints something else. Publishing a period for it would reintroduce the snap.
expect(getDashPatternPeriod('8.')).toBeNull();
expect(getDashPatternPeriod('8. 4')).toBeNull();
});
});
42 changes: 42 additions & 0 deletions src/components/custom-edge/dashPattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* A `stroke-dashoffset` animation only loops seamlessly when the distance it travels
* per cycle equals the dash pattern's period. Travel a different distance and every
* cycle boundary snaps the pattern back by the remainder — obvious on an irregular
* pattern such as dash-dot, where the eye tracks individual dots.
*/

// CSS requires a digit after the decimal point, so `8.` is an invalid declaration the
// browser drops. Accepting it here would publish a period for a pattern that never
// paints — the exact silent mismatch this module exists to prevent.
const NUMBER_WITH_OPTIONAL_PX = /^(-?(?:\d+(?:\.\d+)?|\.\d+))(?:px)?$/;

/**
* Distance a `stroke-dashoffset` animation must travel for one seamless loop of
* `dashArray`, or `null` when that cannot be determined — no pattern, a zero-length
* pattern, or units that need the path length (`%`) or a font context (`em`).
*
* Per SVG, a list with an odd number of entries is repeated to yield an even count,
* so its period is twice the sum.
*/
export function getDashPatternPeriod(dashArray: string | number | undefined | null): number | null {
if (dashArray === undefined || dashArray === null) return null;

const entries = String(dashArray)
.trim()
.split(/[\s,]+/)
.filter((entry) => entry.length > 0);
if (entries.length === 0) return null;

let sum = 0;
for (const entry of entries) {
const match = NUMBER_WITH_OPTIONAL_PX.exec(entry);
if (!match) return null;

const value = Number(match[1]);
if (!Number.isFinite(value) || value < 0) return null;
sum += value;
}

if (sum <= 0) return null;
return entries.length % 2 === 0 ? sum : sum * 2;
}
Loading