From a6ebb3d5097ac204750e6703f98476b274e7c716 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Mon, 24 Aug 2026 13:39:40 +0200 Subject: [PATCH 1/2] fix(edges): loop the dash animation over one dash period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An animated edge snapped its dash pattern backwards on every cycle. Very visible on the dash-dot preset, where the eye tracks individual dots. A `stroke-dashoffset` loop only joins up when the distance it travels per cycle equals the dash pattern's period. React Flow animates every path in an `.animated` edge with its own `dashdraw`, which travels a fixed 10 — correct for its own `stroke-dasharray: 5`, wrong for any pattern the app sets inline. Measured on a dash-dot edge: period 18, travelling 10, so each cycle boundary jumped back by exactly 8. Every preset was affected: dashed `8 4` period 12 jumped 4 dotted `2 4` period 6 jumped 4 dashdot `8 4 2 4` period 18 jumped 8 Replace it with a period-aware animation. `getDashPatternPeriod` derives the period (handling SVG's odd-length doubling, and declining `%`/`em` patterns it cannot resolve without the path length), `CustomEdgeWrapper` publishes it on the edge path as `--flow-edge-dash-period`, and the keyframes travel that far. `.react-flow__edge.animated .react-flow__edge-path` carries three classes, so it outranks React Flow's `.react-flow__edge.animated path` without `!important`. Each consumer declares the default for its own pattern in CSS. The drag connection line had the same mismatch: a `6 8` pattern, period 14, animated over 20. Also stop React Flow's rule from dashing the invisible hover hit target, which left hover on an animated edge working only on the dashes. --- src/components/CustomConnectionLine.test.tsx | 34 +++++++++++ src/components/CustomConnectionLine.tsx | 11 +++- .../custom-edge/CustomEdgeWrapper.tsx | 28 ++++++--- .../animatedEdgePresentation.test.ts | 61 ++++++++++++++++++- .../custom-edge/animatedEdgePresentation.ts | 23 +++++-- .../custom-edge/dashPattern.test.ts | 51 ++++++++++++++++ src/components/custom-edge/dashPattern.ts | 39 ++++++++++++ src/index.css | 24 +++++++- 8 files changed, 254 insertions(+), 17 deletions(-) create mode 100644 src/components/custom-edge/dashPattern.test.ts create mode 100644 src/components/custom-edge/dashPattern.ts diff --git a/src/components/CustomConnectionLine.test.tsx b/src/components/CustomConnectionLine.test.tsx index c7b5a989..d77dd03b 100644 --- a/src/components/CustomConnectionLine.test.tsx +++ b/src/components/CustomConnectionLine.test.tsx @@ -49,4 +49,38 @@ 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'); + }); }); diff --git a/src/components/CustomConnectionLine.tsx b/src/components/CustomConnectionLine.tsx index 66a69ee1..a4cfe8b2 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,9 @@ 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. + const dashPeriod = getDashPatternPeriod(dashArray) ?? 0; return ( @@ -48,12 +52,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', - }} + '--flow-connection-dash-period': dashPeriod, + } as React.CSSProperties} d={edgePath} /> @@ -87,7 +92,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..df711dd2 100644 --- a/src/components/custom-edge/CustomEdgeWrapper.tsx +++ b/src/components/custom-edge/CustomEdgeWrapper.tsx @@ -9,7 +9,8 @@ import { toMarkerUrl, } from './classRelationSemantics'; import { resolveStandardEdgeMarkers } from './standardEdgeMarkers'; -import { resolveAnimatedEdgePresentation } from './animatedEdgePresentation'; +import { DASH_PERIOD_CSS_VAR, resolveAnimatedEdgePresentation } from './animatedEdgePresentation'; +import { getDashPatternPeriod } from './dashPattern'; import { buildEdgeLabelUpdates, getEditableEdgeLabel, @@ -115,12 +116,22 @@ export const CustomEdgeWrapper = memo(function CustomEdgeWrapper({ ); const resolvedStyle = useMemo( - () => ({ - stroke: designSystem.colors.edge, - strokeWidth: designSystem.components.edge.strokeWidth, - ...style, - ...relationStyle, - }), + () => { + const merged: React.CSSProperties = { + stroke: designSystem.colors.edge, + strokeWidth: designSystem.components.edge.strokeWidth, + ...style, + ...relationStyle, + }; + + // Animated edges loop `stroke-dashoffset`, which only joins up seamlessly when it + // travels one dash period per cycle. Publish the period of whatever pattern this + // edge ended up with; the CSS default covers edges that set none. + const dashPeriod = getDashPatternPeriod(merged.strokeDasharray); + return dashPeriod === null + ? merged + : ({ ...merged, [DASH_PERIOD_CSS_VAR]: dashPeriod } as React.CSSProperties); + }, [designSystem.colors.edge, designSystem.components.edge.strokeWidth, style, relationStyle] ); @@ -376,6 +387,9 @@ export const CustomEdgeWrapper = memo(function CustomEdgeWrapper({ fill="none" stroke="rgba(15,23,42,0.001)" strokeWidth={20} + // React Flow dashes every path in an `.animated` edge; a dashed hit target + // would only respond to hover on the dashes. + strokeDasharray="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..c20fb75c 100644 --- a/src/components/custom-edge/animatedEdgePresentation.test.ts +++ b/src/components/custom-edge/animatedEdgePresentation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { resolveAnimatedEdgePresentation } from './animatedEdgePresentation'; +import { DASH_PERIOD_CSS_VAR, resolveAnimatedEdgePresentation } from './animatedEdgePresentation'; +import { getDashPatternPeriod } from './dashPattern'; describe('animated edge presentation', () => { it('preserves hover and selection overlays when animated export is disabled', () => { @@ -50,4 +51,62 @@ 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(); + }); + }); }); diff --git a/src/components/custom-edge/animatedEdgePresentation.ts b/src/components/custom-edge/animatedEdgePresentation.ts index 4f61f117..38c9c69d 100644 --- a/src/components/custom-edge/animatedEdgePresentation.ts +++ b/src/components/custom-edge/animatedEdgePresentation.ts @@ -1,5 +1,13 @@ 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'; interface ResolveAnimatedEdgePresentationParams { animatedExportEnabled: boolean; @@ -23,14 +31,19 @@ export function resolveAnimatedEdgePresentation({ animationConfig, baseStyle, }: ResolveAnimatedEdgePresentationParams): AnimatedEdgePresentation { + const strokeDasharray = animationConfig?.dashArray + ?? (typeof baseStyle.strokeDasharray === 'string' && baseStyle.strokeDasharray.length > 0 + ? baseStyle.strokeDasharray + : '8 8'); + const dashPeriod = getDashPatternPeriod(strokeDasharray); + const overlayStyle: CSSProperties = { 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, + // Unresolvable patterns leave the var unset so the keyframes fall back. + ...(dashPeriod === null ? {} : { [DASH_PERIOD_CSS_VAR]: dashPeriod }), + } as CSSProperties; 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..154cd14b --- /dev/null +++ b/src/components/custom-edge/dashPattern.test.ts @@ -0,0 +1,51 @@ +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(); + }); +}); diff --git a/src/components/custom-edge/dashPattern.ts b/src/components/custom-edge/dashPattern.ts new file mode 100644 index 00000000..69546121 --- /dev/null +++ b/src/components/custom-edge/dashPattern.ts @@ -0,0 +1,39 @@ +/** + * 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. + */ + +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..0805fd10 100644 --- a/src/index.css +++ b/src/index.css @@ -687,12 +687,34 @@ 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; } +/* + * 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 { From 5124b8ff216900f11a217aa9df4bff7f0a6438ac Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Mon, 24 Aug 2026 14:01:16 +0200 Subject: [PATCH 2/2] fix(edges): apply review findings on the dash loop fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the previous commit's claims did not hold. The hover hit target was still dashed and still animating. `strokeDasharray` in JSX renders as a presentation attribute, which ranks below every author stylesheet rule, so React Flow's `.react-flow__edge.animated path` kept winning — confirmed in the browser: the attribute was present while the computed value stayed `5px` with `dashdraw` running. Overriding inline (which does beat a non-important rule) now yields `stroke-dasharray: none` and `animation: none`. The animated overlay kept the snap-back. It only renders inside an `.animated` edge, where React Flow's `.react-flow__edge.animated path` (0,2,1) outranks `.flow-edge-animated-overlay` (0,1,0) and hijacked it back to `dashdraw`'s fixed travel of 10 against a default period of 16. Added the three-class override, so it runs `flow-edge-dash` and consumes the period the component publishes. Also: - Extract `withDashPeriodVar` so the visible edge path's period publication is covered by unit tests. Nothing pinned it before: deleting the call from `CustomEdgeWrapper` re-broke every preset with a green suite. - Assert the connection line's keyframes actually read the custom property, not just that the component publishes it. - Reject numbers CSS itself rejects. `8.` matched the old pattern, so an invalid declaration the browser drops could still publish a period, which is the silent mismatch this helper exists to prevent. - Drop the `?? 0` on the connection line's period. A custom property of 0 satisfies `var()`, so the keyframes' fallback would never fire and the animation would freeze instead. --- src/components/CustomConnectionLine.test.tsx | 6 +++ src/components/CustomConnectionLine.tsx | 6 ++- .../custom-edge/CustomEdgeWrapper.tsx | 35 +++++-------- .../animatedEdgePresentation.test.ts | 51 ++++++++++++++++++- .../custom-edge/animatedEdgePresentation.ts | 19 ++++--- .../custom-edge/dashPattern.test.ts | 7 +++ src/components/custom-edge/dashPattern.ts | 5 +- src/index.css | 9 ++++ 8 files changed, 107 insertions(+), 31 deletions(-) diff --git a/src/components/CustomConnectionLine.test.tsx b/src/components/CustomConnectionLine.test.tsx index d77dd03b..dc8d77a9 100644 --- a/src/components/CustomConnectionLine.test.tsx +++ b/src/components/CustomConnectionLine.test.tsx @@ -82,5 +82,11 @@ describe('CustomConnectionLine', () => { 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 a4cfe8b2..86b7a313 100644 --- a/src/components/CustomConnectionLine.tsx +++ b/src/components/CustomConnectionLine.tsx @@ -44,7 +44,9 @@ const CustomConnectionLine = ({ 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. - const dashPeriod = getDashPatternPeriod(dashArray) ?? 0; + // 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 ( @@ -57,7 +59,7 @@ const CustomConnectionLine = ({ style={{ filter: 'drop-shadow(0 1px 3px rgba(99,102,241,0.25))', animation: 'flow-connection-dash 0.8s linear infinite', - '--flow-connection-dash-period': dashPeriod, + ...(dashPeriod === null ? {} : { '--flow-connection-dash-period': dashPeriod }), } as React.CSSProperties} d={edgePath} /> diff --git a/src/components/custom-edge/CustomEdgeWrapper.tsx b/src/components/custom-edge/CustomEdgeWrapper.tsx index df711dd2..68536d50 100644 --- a/src/components/custom-edge/CustomEdgeWrapper.tsx +++ b/src/components/custom-edge/CustomEdgeWrapper.tsx @@ -9,8 +9,7 @@ import { toMarkerUrl, } from './classRelationSemantics'; import { resolveStandardEdgeMarkers } from './standardEdgeMarkers'; -import { DASH_PERIOD_CSS_VAR, resolveAnimatedEdgePresentation } from './animatedEdgePresentation'; -import { getDashPatternPeriod } from './dashPattern'; +import { resolveAnimatedEdgePresentation, withDashPeriodVar } from './animatedEdgePresentation'; import { buildEdgeLabelUpdates, getEditableEdgeLabel, @@ -116,22 +115,15 @@ export const CustomEdgeWrapper = memo(function CustomEdgeWrapper({ ); const resolvedStyle = useMemo( - () => { - const merged: React.CSSProperties = { - stroke: designSystem.colors.edge, - strokeWidth: designSystem.components.edge.strokeWidth, - ...style, - ...relationStyle, - }; - - // Animated edges loop `stroke-dashoffset`, which only joins up seamlessly when it - // travels one dash period per cycle. Publish the period of whatever pattern this - // edge ended up with; the CSS default covers edges that set none. - const dashPeriod = getDashPatternPeriod(merged.strokeDasharray); - return dashPeriod === null - ? merged - : ({ ...merged, [DASH_PERIOD_CSS_VAR]: dashPeriod } as 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, + ...relationStyle, + }), [designSystem.colors.edge, designSystem.components.edge.strokeWidth, style, relationStyle] ); @@ -387,9 +379,10 @@ export const CustomEdgeWrapper = memo(function CustomEdgeWrapper({ fill="none" stroke="rgba(15,23,42,0.001)" strokeWidth={20} - // React Flow dashes every path in an `.animated` edge; a dashed hit target - // would only respond to hover on the dashes. - strokeDasharray="none" + // 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 c20fb75c..10355e29 100644 --- a/src/components/custom-edge/animatedEdgePresentation.test.ts +++ b/src/components/custom-edge/animatedEdgePresentation.test.ts @@ -1,5 +1,10 @@ +import type { CSSProperties } from 'react'; import { describe, expect, it } from 'vitest'; -import { DASH_PERIOD_CSS_VAR, resolveAnimatedEdgePresentation } from './animatedEdgePresentation'; +import { + DASH_PERIOD_CSS_VAR, + resolveAnimatedEdgePresentation, + withDashPeriodVar, +} from './animatedEdgePresentation'; import { getDashPatternPeriod } from './dashPattern'; describe('animated edge presentation', () => { @@ -109,4 +114,48 @@ describe('animated edge presentation', () => { 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 38c9c69d..c12d8d4a 100644 --- a/src/components/custom-edge/animatedEdgePresentation.ts +++ b/src/components/custom-edge/animatedEdgePresentation.ts @@ -9,6 +9,17 @@ import { getDashPatternPeriod } from './dashPattern'; */ 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; selected: boolean; @@ -35,15 +46,11 @@ export function resolveAnimatedEdgePresentation({ ?? (typeof baseStyle.strokeDasharray === 'string' && baseStyle.strokeDasharray.length > 0 ? baseStyle.strokeDasharray : '8 8'); - const dashPeriod = getDashPatternPeriod(strokeDasharray); - - const overlayStyle: CSSProperties = { + const overlayStyle: CSSProperties = withDashPeriodVar({ stroke: baseStyle.stroke, strokeWidth: Math.max(Number(baseStyle.strokeWidth ?? 2), 2), strokeDasharray, - // Unresolvable patterns leave the var unset so the keyframes fall back. - ...(dashPeriod === null ? {} : { [DASH_PERIOD_CSS_VAR]: dashPeriod }), - } as CSSProperties; + }); if (!animatedExportEnabled) { return { diff --git a/src/components/custom-edge/dashPattern.test.ts b/src/components/custom-edge/dashPattern.test.ts index 154cd14b..d599a5d6 100644 --- a/src/components/custom-edge/dashPattern.test.ts +++ b/src/components/custom-edge/dashPattern.test.ts @@ -48,4 +48,11 @@ describe('getDashPatternPeriod', () => { 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 index 69546121..b45c8872 100644 --- a/src/components/custom-edge/dashPattern.ts +++ b/src/components/custom-edge/dashPattern.ts @@ -5,7 +5,10 @@ * pattern such as dash-dot, where the eye tracks individual dots. */ -const NUMBER_WITH_OPTIONAL_PX = /^(-?(?:\d+\.?\d*|\.\d+))(?:px)?$/; +// 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 diff --git a/src/index.css b/src/index.css index 0805fd10..47b1b34b 100644 --- a/src/index.css +++ b/src/index.css @@ -692,6 +692,15 @@ body { 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