diff --git a/src/components/CustomConnectionLine.test.tsx b/src/components/CustomConnectionLine.test.tsx index c7b5a989..dc8d77a9 100644 --- a/src/components/CustomConnectionLine.test.tsx +++ b/src/components/CustomConnectionLine.test.tsx @@ -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( + + + + ); + 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/); + }); }); diff --git a/src/components/CustomConnectionLine.tsx b/src/components/CustomConnectionLine.tsx index 66a69ee1..86b7a313 100644 --- a/src/components/CustomConnectionLine.tsx +++ b/src/components/CustomConnectionLine.tsx @@ -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; @@ -41,6 +42,11 @@ 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 ( @@ -48,12 +54,13 @@ const CustomConnectionLine = ({ 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} /> @@ -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; diff --git a/src/components/custom-edge/CustomEdgeWrapper.tsx b/src/components/custom-edge/CustomEdgeWrapper.tsx index 0dcd3f0b..68536d50 100644 --- a/src/components/custom-edge/CustomEdgeWrapper.tsx +++ b/src/components/custom-edge/CustomEdgeWrapper.tsx @@ -9,7 +9,7 @@ import { toMarkerUrl, } from './classRelationSemantics'; import { resolveStandardEdgeMarkers } from './standardEdgeMarkers'; -import { resolveAnimatedEdgePresentation } from './animatedEdgePresentation'; +import { resolveAnimatedEdgePresentation, withDashPeriodVar } from './animatedEdgePresentation'; import { buildEdgeLabelUpdates, getEditableEdgeLabel, @@ -115,7 +115,10 @@ export const CustomEdgeWrapper = memo(function CustomEdgeWrapper({ ); const resolvedStyle = useMemo( - () => ({ + // 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, @@ -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)} diff --git a/src/components/custom-edge/animatedEdgePresentation.test.ts b/src/components/custom-edge/animatedEdgePresentation.test.ts index 0111757b..10355e29 100644 --- a/src/components/custom-edge/animatedEdgePresentation.test.ts +++ b/src/components/custom-edge/animatedEdgePresentation.test.ts @@ -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', () => { @@ -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)[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)[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)[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)[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)[DASH_PERIOD_CSS_VAR], strokeDasharray) + .toBe(getDashPatternPeriod(strokeDasharray)); + } + }); + }); }); diff --git a/src/components/custom-edge/animatedEdgePresentation.ts b/src/components/custom-edge/animatedEdgePresentation.ts index 4f61f117..c12d8d4a 100644 --- a/src/components/custom-edge/animatedEdgePresentation.ts +++ b/src/components/custom-edge/animatedEdgePresentation.ts @@ -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; @@ -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 { diff --git a/src/components/custom-edge/dashPattern.test.ts b/src/components/custom-edge/dashPattern.test.ts new file mode 100644 index 00000000..d599a5d6 --- /dev/null +++ b/src/components/custom-edge/dashPattern.test.ts @@ -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(); + }); +}); diff --git a/src/components/custom-edge/dashPattern.ts b/src/components/custom-edge/dashPattern.ts new file mode 100644 index 00000000..b45c8872 --- /dev/null +++ b/src/components/custom-edge/dashPattern.ts @@ -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; +} diff --git a/src/index.css b/src/index.css index 5342b81f..47b1b34b 100644 --- a/src/index.css +++ b/src/index.css @@ -687,12 +687,43 @@ body { } .flow-edge-animated-overlay { + /* Period of this overlay's own default `8 8` pattern; overridden inline per edge. */ + --flow-edge-dash-period: 16; animation: flow-edge-dash 0.6s linear infinite; } +/* + * The overlay only ever renders inside an `.animated` edge, where React Flow's + * `.react-flow__edge.animated path` (0,2,1) would otherwise outrank the rule above and + * hijack it back to `dashdraw`'s fixed travel of 10. Three classes wins. + */ +.react-flow__edge.animated .flow-edge-animated-overlay { + animation: flow-edge-dash 0.6s linear infinite; +} + +/* + * React Flow animates every path in an `.animated` edge with its own `dashdraw`, which + * travels a fixed 10 — right for its own `stroke-dasharray: 5`, wrong for any pattern + * the app sets inline (the dash-dot preset has period 18, so each cycle snapped back + * by 8). Replace it with the period-aware animation. Three classes outweighs React + * Flow's `.react-flow__edge.animated path`, so this wins without `!important`. + */ +.react-flow__edge.animated .react-flow__edge-path { + /* Period of React Flow's `stroke-dasharray: 5`, used when the edge sets no pattern. */ + --flow-edge-dash-period: 10; + animation: flow-edge-dash 0.5s linear infinite; +} + +/* + * The travelled distance must equal the dash pattern's period, otherwise every cycle + * boundary snaps the pattern back by the remainder — very visible on an irregular + * pattern such as dash-dot, where the eye tracks individual dots. + * `--flow-edge-dash-period` is published per element; each consumer above declares the + * default for its own pattern. + */ @keyframes flow-edge-dash { from { - stroke-dashoffset: 16; + stroke-dashoffset: var(--flow-edge-dash-period, 10); } to {