diff --git a/src/App.tsx b/src/App.tsx index cfffc708..a881df41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,18 @@ import { Box, ChakraProvider } from '@chakra-ui/react'; -import React, { useEffect, useMemo } from 'react'; import { useActor, useInterpret, useSelector } from '@xstate/react'; -import { useAuth } from './authContext'; +import router, { useRouter } from 'next/router'; +import React, { useEffect, useMemo } from 'react'; import { AppHead } from './AppHead'; +import { useAuth } from './authContext'; import { CanvasProvider } from './CanvasContext'; -import { EmbedProvider } from './embedContext'; import { CanvasView } from './CanvasView'; +import { EmbedProvider } from './embedContext'; import { isOnClientSide } from './isOnClientSide'; import { MachineNameChooserModal } from './MachineNameChooserModal'; import { PaletteProvider } from './PaletteContext'; import { paletteMachine } from './paletteMachine'; import { PanelsView } from './PanelsView'; +import { registryLinks } from './registryLinks'; import { SimulationProvider } from './SimulationContext'; import { simulationMachine } from './simulationMachine'; import { getSourceActor, useSourceRegistryData } from './sourceMachine'; @@ -18,9 +20,7 @@ import { theme } from './theme'; import { EditorThemeProvider } from './themeContext'; import { EmbedContext, EmbedMode } from './types'; import { useInterpretCanvas } from './useInterpretCanvas'; -import router, { useRouter } from 'next/router'; import { parseEmbedQuery, withoutEmbedQueryParams } from './utils'; -import { registryLinks } from './registryLinks'; const defaultHeadProps = { title: 'XState Visualizer', @@ -88,6 +88,7 @@ function App({ isEmbedded = false }: { isEmbedded?: boolean }) { const paletteService = useInterpret(paletteMachine); // don't use `devTools: true` here as it would freeze your browser const simService = useInterpret(simulationMachine); + const machine = useSelector(simService, (state) => { return state.context.currentSessionId ? state.context.serviceDataMap[state.context.currentSessionId!]?.machine @@ -111,8 +112,6 @@ function App({ isEmbedded = false }: { isEmbedded?: boolean }) { }); }, [machine?.id, sendToSourceService]); - // TODO: Subject to refactor into embedActor - const sourceID = sourceState!.context.sourceID; const canvasService = useInterpretCanvas({ @@ -120,6 +119,8 @@ function App({ isEmbedded = false }: { isEmbedded?: boolean }) { embed, }); + const canShowWelcomeMessage = sourceState.hasTag('canShowWelcomeMessage'); + // This is because we're doing loads of things on client side anyway if (!isOnClientSide()) return ; @@ -142,7 +143,9 @@ function App({ isEmbedded = false }: { isEmbedded?: boolean }) { > {!(embed?.isEmbedded && embed.mode === EmbedMode.Panels) && ( - + )} diff --git a/src/CanvasView.tsx b/src/CanvasView.tsx index ed43da9d..e603eb63 100644 --- a/src/CanvasView.tsx +++ b/src/CanvasView.tsx @@ -31,17 +31,15 @@ import { CanvasHeader } from './CanvasHeader'; import { Overlay } from './Overlay'; import { useEmbed } from './embedContext'; import { CompressIcon, HandIcon } from './Icons'; -import { useSourceActor } from './sourceMachine'; import { WelcomeArea } from './WelcomeArea'; -export const CanvasView: React.FC = () => { +export const CanvasView = (props: { canShowWelcomeMessage?: boolean }) => { // TODO: refactor this so an event can be explicitly sent to a machine // it isn't straightforward to do at the moment cause the target machine lives in a child component const [panModeEnabled, setPanModeEnabled] = React.useState(false); const embed = useEmbed(); const simService = useSimulation(); const canvasService = useCanvas(); - const [sourceState] = useSourceActor(); const machine = useSelector(simService, (state) => { return state.context.currentSessionId ? state.context.serviceDataMap[state.context.currentSessionId!]?.machine @@ -68,21 +66,12 @@ export const CanvasView: React.FC = () => { const simulationMode = useSimulationMode(); - const canShowWelcomeMessage = sourceState.hasTag('canShowWelcomeMessage'); + const showControls = !embed?.isEmbedded || embed.controls; - const showControls = useMemo( - () => !embed?.isEmbedded || embed.controls, - [embed], - ); - - const showZoomButtonsInEmbed = useMemo( - () => !embed?.isEmbedded || (embed.controls && embed.zoom), - [embed], - ); - const showPanButtonInEmbed = useMemo( - () => !embed?.isEmbedded || (embed.controls && embed.pan), - [embed], - ); + const showZoomButtonsInEmbed = + !embed?.isEmbedded || (embed.controls && embed.zoom); + const showPanButtonInEmbed = + !embed?.isEmbedded || (embed.controls && embed.pan); return ( { )} - {isEmpty && canShowWelcomeMessage && } + {isEmpty && props.canShowWelcomeMessage && } {showControls && ( diff --git a/src/embedContext.ts b/src/embedContext.ts index 2c0ed483..37406d9c 100644 --- a/src/embedContext.ts +++ b/src/embedContext.ts @@ -1,6 +1,8 @@ +import { createContext, useContext } from 'react'; import { EmbedContext } from './types'; -import { createRequiredContext } from './utils'; -export const [EmbedProvider, useEmbed] = createRequiredContext< - EmbedContext | undefined ->('Embed'); +const EmbedReactContext = createContext(null as EmbedContext); + +export const EmbedProvider = EmbedReactContext.Provider; + +export const useEmbed = () => useContext(EmbedReactContext); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c80d15d5..ee72a95d 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -14,6 +14,7 @@ import '../InvokeViz.scss'; import '../monacoPatch'; import '../StateNodeViz.scss'; import '../TransitionViz.scss'; +import { NextComponentWithMeta } from '../types'; // import { isOnClientSide } from '../isOnClientSide'; @@ -32,7 +33,7 @@ if ( }); } -const MyApp = ({ pageProps, Component }: AppProps) => { +const AuthWrapper = ({ pageProps, Component }: AppProps) => { const router = useRouter(); const authService = useInterpret( @@ -50,4 +51,16 @@ const MyApp = ({ pageProps, Component }: AppProps) => { ); }; +const MyApp = ( + props: AppProps & { + Component: NextComponentWithMeta; + }, +) => { + if (props.Component.preventAuth) { + return ; + } + + return ; +}; + export default MyApp; diff --git a/src/pages/view-only.tsx b/src/pages/view-only.tsx new file mode 100644 index 00000000..f18af358 --- /dev/null +++ b/src/pages/view-only.tsx @@ -0,0 +1,154 @@ +import { Box, ChakraProvider } from '@chakra-ui/react'; +import { useInterpret, useMachine } from '@xstate/react'; +import Head from 'next/head'; +import { NextRouter, useRouter } from 'next/router'; +import React, { useEffect, useMemo } from 'react'; +import { createMachine, MachineConfig, sendParent } from 'xstate'; +import { CanvasProvider } from '../CanvasContext'; +import { CanvasView } from '../CanvasView'; +import { EmbedProvider } from '../embedContext'; +import { SimulationProvider } from '../SimulationContext'; +import { simModel, simulationMachine } from '../simulationMachine'; +import { theme } from '../theme'; +import { NextComponentWithMeta } from '../types'; +import { useInterpretCanvas } from '../useInterpretCanvas'; +import { parseEmbedQuery, withoutEmbedQueryParams } from '../utils'; +import { withReadyRouter } from '../withReadyRouter'; +import lzString from 'lz-string'; +import { AppHead } from '../AppHead'; + +const parseMachineFromQuery = (query: NextRouter['query']) => { + if (!query.machine) { + throw new Error('`machine` query param is required'); + } + + if (Array.isArray(query.machine)) { + throw new Error("`machine` query param can't be an array"); + } + + const lzResult = lzString.decompressFromEncodedURIComponent(query.machine); + + if (!lzResult) + throw new Error("`machine` query param couldn't be decompressed"); + + const machineConfig = JSON.parse(lzResult); + + // Tests that the machine is valid + try { + return createMachine(machineConfig); + } catch { + throw new Error( + "decompressed `machine` couldn't be used to `createMachine`", + ); + } +}; + +const viewOnlyPageMachine = createMachine<{ + query: NextRouter['query']; +}>({ + initial: 'checkingIfMachineIsValid', + states: { + checkingIfMachineIsValid: { + invoke: { + src: async (context) => { + return parseMachineFromQuery(context.query); + }, + onDone: { + target: 'valid', + }, + onError: { + target: 'notValid', + }, + }, + }, + notValid: { + type: 'final', + entry: (context, event) => { + console.error('Could not parse machine.', event); + }, + }, + valid: { + type: 'final', + entry: sendParent((context) => + simModel.events['MACHINES.REGISTER']([ + parseMachineFromQuery(context.query), + ]), + ), + }, + }, +}); + +/** + * Displays a view-only page which can render a machine + * to the canvas from the URL + * + * Use this example URL: http://localhost:3000/viz/view-only?machine=N4IglgdmAuYIYBsQC4QHcD2aQBoQGdo5oBTfFUTbZYAXzwhOrttqA + * + * This is for loading OG images quickly, and for many other applications + * + * To create the machine hash, use the lzString.compressToEncodedURIComponent + * function on a JSON.stringified machine config. + * + * You can also use the typical embed controls + */ +const ViewOnlyPage = withReadyRouter(() => { + const canvasService = useInterpretCanvas({ + sourceID: null, + }); + const simulationService = useInterpret(simulationMachine); + const router = useRouter(); + + useMachine(viewOnlyPageMachine, { + context: { + query: router.query, + }, + parent: simulationService, + }); + + const embed = useMemo( + () => ({ + ...parseEmbedQuery(router.query), + isEmbedded: true, + originalUrl: withoutEmbedQueryParams(router.query), + }), + [router.query], + ); + + return ( + + + + + + + + + + + + ); +}); + +const ViewOnlyPageParent: NextComponentWithMeta = () => { + return ( + <> + + + + ); +}; + +ViewOnlyPageParent.preventAuth = true; + +export default ViewOnlyPageParent; diff --git a/src/types.ts b/src/types.ts index 68df4587..89add870 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ import type { import { SourceFileFragment } from './graphql/SourceFileFragment.generated'; import { Model } from 'xstate/lib/model.types'; import type { editor } from 'monaco-editor'; +import { NextPage } from 'next'; export type AnyStateMachine = StateMachine; @@ -84,3 +85,7 @@ export interface Point { x: number; y: number; } + +export type NextComponentWithMeta = NextPage & { + preventAuth?: boolean; +}; diff --git a/src/utils.ts b/src/utils.ts index 75ee4093..4d9c918f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -214,17 +214,18 @@ export const DEFAULT_EMBED_PARAMS: ParsedEmbed = { zoom: false, controls: false, }; -export const parseEmbedQuery = (query?: NextRouter['query']): ParsedEmbed => { - const parsedEmbed = DEFAULT_EMBED_PARAMS; - const getQueryParamValue = (qParamValue: string | string[]) => { - return Array.isArray(qParamValue) ? qParamValue[0] : qParamValue; - }; +const getQueryParamValue = (qParamValue: string | string[]) => { + return Array.isArray(qParamValue) ? qParamValue[0] : qParamValue; +}; - const computeBooleanQParamValue = (qParamValue: string) => { - // Parse to number to treat "0" as false - return !!+qParamValue; - }; +const computeBooleanQParamValue = (qParamValue: string) => { + // Parse to number to treat "0" as false + return !!+qParamValue; +}; + +export const parseEmbedQuery = (query?: NextRouter['query']): ParsedEmbed => { + const parsedEmbed = DEFAULT_EMBED_PARAMS; if (query?.mode) { const parsedMode = getQueryParamValue(query?.mode); diff --git a/src/withReadyRouter.tsx b/src/withReadyRouter.tsx new file mode 100644 index 00000000..723ab778 --- /dev/null +++ b/src/withReadyRouter.tsx @@ -0,0 +1,25 @@ +import { useRouter } from 'next/router'; +import React, { useEffect, useState } from 'react'; + +/** + * Ensures that Next's router is always ready (i.e. has + * query params loaded) + */ +export const withReadyRouter = (WrappedComponent: any) => { + WrappedComponent.displayName = `WithReadyRouter(${WrappedComponent.displayName || WrappedComponent.name})`; + + const WithReadyRouter = () => { + const router = useRouter(); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + setIsReady(router.isReady); + }, [router.isReady]); + + if (!isReady) return null; + + return ; + }; + + return WithReadyRouter; +};