JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 16
  • Score
    100M100P100Q86035F
  • License MIT

Portable tutorial player component for React applications with interactive step-by-step walkthroughs, screenshots, and annotations

Package Exports

  • @tutorial-maker/react-player
  • @tutorial-maker/react-player/dist/react-player.css
  • @tutorial-maker/react-player/styles.css

Readme

@tutorial-maker/react-player

A portable, customizable tutorial player component for React applications. Display interactive step-by-step tutorials with screenshots and annotations in your React app.

Features

  • 🎯 Interactive Tutorial Playback - Step through tutorials with smooth scrolling and navigation
  • 🖼️ Screenshot Support - Display screenshots with annotations (arrows, text balloons, highlights, numbered badges)
  • 📱 Responsive Design - Works seamlessly across different screen sizes
  • 🎨 Customizable - Flexible image loading and path resolution
  • 🌐 Universal - Works in both web and Tauri environments
  • Lightweight - Minimal dependencies, optimized bundle size
  • 🔧 TypeScript - Full TypeScript support with type definitions

Installation

npm install @tutorial-maker/react-player
# or
yarn add @tutorial-maker/react-player
# or
pnpm add @tutorial-maker/react-player

Quick Start

import { TutorialPlayer } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';
import projectData from './tutorials/my-project/project.json';

function App() {
  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <TutorialPlayer
        tutorials={projectData.tutorials}
        onClose={() => console.log('Tutorial closed')}
      />
    </div>
  );
}

Table of Contents

Embedding Tutorial Projects

Project Structure

When you create tutorials with the tutorial-maker app, it generates a project with this structure:

my-project/
├── project.json          # Main project file
└── screenshots/          # Screenshots folder
    ├── step1.png
    ├── step2.png
    └── ...

Project JSON Format

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "My Tutorial Project",
  "projectFolder": "my-project",
  "tutorials": [
    {
      "id": "tutorial-1",
      "title": "Getting Started",
      "description": "Learn the basics",
      "steps": [...],
      "createdAt": "2025-01-01T00:00:00.000Z",
      "updatedAt": "2025-01-01T00:00:00.000Z"
    }
  ],
  "createdAt": "2025-01-01T00:00:00.000Z",
  "updatedAt": "2025-01-01T00:00:00.000Z"
}

Important: Pass projectData.tutorials to the player, not the entire project object.

Tutorial Formats

The tutorial-maker app supports two formats for distributing tutorials:

1. Folder-Based Format (Default)

The standard format with separate files:

my-project/
├── project.json          # Main project file
└── screenshots/          # Screenshots as separate files
    ├── step1.png
    ├── step2.png
    └── ...

Best for: Development, version control, smaller file sizes

2. Embedded Format (Single File)

A self-contained format where screenshots are embedded as base64 data URLs:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "My Tutorial",
  "projectFolder": "my-project",
  "embedded": true,
  "tutorials": [
    {
      "steps": [
        {
          "screenshotPath": "data:image/png;base64,iVBORw0KGgoAAAANS..."
        }
      ]
    }
  ]
}

Best for: Simple distribution, single-file deployment, no build configuration needed

Size Note: Embedded format is ~33% larger than folder-based due to base64 encoding.

Export Embedded Format: Use the "Export" button in the tutorial-maker app to create a .tutorial.json file.

Embedding in Your App

Copy the entire tutorial project folder into your React app:

your-app/
├── src/
│   ├── App.tsx
│   └── tutorials/
│       └── my-project/          ← Copy tutorial project here
│           ├── project.json
│           └── screenshots/
│               ├── step1.png
│               └── step2.png
└── package.json

Or use the embedded format by importing a single .tutorial.json file:

your-app/
├── src/
│   ├── App.tsx
│   └── tutorials/
│       └── my-tutorial.tutorial.json  ← Single embedded file
└── package.json

Complete Integration Examples

This example bundles the tutorial project with your app using Vite's glob import feature.

import React from 'react';
import { TutorialPlayer } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';

// Import the project JSON
import projectData from './tutorials/my-project/project.json';

// Import all screenshots using Vite's glob import
const screenshots = import.meta.glob(
  './tutorials/**/screenshots/*.{png,jpg}',
  { eager: true, as: 'url' }
);

function TutorialApp() {
  // Custom loader for bundled images
  const imageLoader = async (path: string): Promise<string> => {
    // Already a data URL? Return as-is
    if (path.startsWith('data:')) {
      return path;
    }

    // Find the image in our imports
    const projectFolder = projectData.projectFolder;
    const imageUrl = screenshots[`./tutorials/${projectFolder}/${path}`];

    if (!imageUrl) {
      console.error(`Screenshot not found: ${path}`);
      throw new Error(`Screenshot not found: ${path}`);
    }

    return imageUrl;
  };

  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <TutorialPlayer
        tutorials={projectData.tutorials}
        imageLoader={imageLoader}
        onClose={() => console.log('Tutorial closed')}
      />
    </div>
  );
}

export default TutorialApp;

File Structure:

src/
├── App.tsx
└── tutorials/
    ├── onboarding/
    │   ├── project.json
    │   └── screenshots/
    │       ├── welcome.png
    │       └── step1.png
    └── advanced/
        ├── project.json
        └── screenshots/
            └── ...

Example 2: Loading from Server/API

Load tutorial projects dynamically from a server or API endpoint.

import React, { useState, useEffect } from 'react';
import { TutorialPlayer, type Tutorial, defaultWebImageLoader } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';

function TutorialApp() {
  const [tutorials, setTutorials] = useState<Tutorial[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function loadTutorials() {
      try {
        // Fetch project JSON from server
        const response = await fetch('/api/tutorials/my-project.json');
        if (!response.ok) {
          throw new Error('Failed to load tutorials');
        }

        const projectData = await response.json();
        setTutorials(projectData.tutorials);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error');
        console.error('Failed to load tutorials:', err);
      } finally {
        setLoading(false);
      }
    }

    loadTutorials();
  }, []);

  if (loading) {
    return (
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
        Loading tutorials...
      </div>
    );
  }

  if (error) {
    return (
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
        Error: {error}
      </div>
    );
  }

  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <TutorialPlayer
        tutorials={tutorials}
        imageLoader={defaultWebImageLoader}
        resolveImagePath={(relativePath) =>
          `/api/tutorials/my-project/${relativePath}`
        }
        onClose={() => window.history.back()}
      />
    </div>
  );
}

export default TutorialApp;

Example 3: Multiple Tutorial Projects

Switch between different tutorial projects with a selector.

import React, { useState } from 'react';
import { TutorialPlayer, type Tutorial, type Project } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';

// Import multiple projects
import onboardingProject from './tutorials/onboarding/project.json';
import advancedProject from './tutorials/advanced/project.json';

const screenshots = import.meta.glob(
  './tutorials/**/screenshots/*.{png,jpg}',
  { eager: true, as: 'url' }
);

type ProjectKey = 'onboarding' | 'advanced';

function MultiTutorialApp() {
  const [currentProject, setCurrentProject] = useState<ProjectKey>('onboarding');

  const projectMap: Record<ProjectKey, Project> = {
    onboarding: onboardingProject as Project,
    advanced: advancedProject as Project,
  };

  const currentProjectData = projectMap[currentProject];
  const tutorials = currentProjectData.tutorials;

  const imageLoader = async (path: string): Promise<string> => {
    if (path.startsWith('data:')) return path;

    const projectFolder = currentProjectData.projectFolder;
    const imageUrl = screenshots[`./tutorials/${projectFolder}/${path}`];

    if (!imageUrl) {
      throw new Error(`Screenshot not found: ${path}`);
    }

    return imageUrl;
  };

  return (
    <div style={{ width: '100vw', height: '100vh', position: 'relative' }}>
      {/* Project Selector */}
      <div style={{
        position: 'fixed',
        top: '10px',
        left: '10px',
        zIndex: 100,
        backgroundColor: 'white',
        padding: '8px',
        borderRadius: '4px',
        boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
      }}>
        <select
          value={currentProject}
          onChange={(e) => setCurrentProject(e.target.value as ProjectKey)}
          style={{ padding: '4px 8px' }}
        >
          <option value="onboarding">Onboarding Tutorial</option>
          <option value="advanced">Advanced Tutorial</option>
        </select>
      </div>

      <TutorialPlayer
        key={currentProject} // Force re-render on project change
        tutorials={tutorials}
        imageLoader={imageLoader}
      />
    </div>
  );
}

export default MultiTutorialApp;

Example 4: Webpack/Create React App

For apps using Webpack or Create React App:

import React from 'react';
import { TutorialPlayer } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';
import projectData from './tutorials/my-project/project.json';

// Import screenshots individually
import welcome from './tutorials/my-project/screenshots/welcome.png';
import step1 from './tutorials/my-project/screenshots/step1.png';
import step2 from './tutorials/my-project/screenshots/step2.png';

function TutorialApp() {
  const imageMap: Record<string, string> = {
    'screenshots/welcome.png': welcome,
    'screenshots/step1.png': step1,
    'screenshots/step2.png': step2,
  };

  const imageLoader = async (path: string): Promise<string> => {
    if (path.startsWith('data:')) return path;

    const imageUrl = imageMap[path];
    if (!imageUrl) {
      throw new Error(`Screenshot not found: ${path}`);
    }

    return imageUrl;
  };

  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <TutorialPlayer
        tutorials={projectData.tutorials}
        imageLoader={imageLoader}
      />
    </div>
  );
}

export default TutorialApp;

Example 5: Embedded Format (No Build Configuration)

The simplest way to integrate tutorials - just import a single .tutorial.json file with embedded screenshots. Perfect for quick integrations and CDN deployments.

Step 1: Export your tutorial using the "Export" button in the tutorial-maker app to create a .tutorial.json file.

Step 2: Import and use it:

import React from 'react';
import { TutorialPlayer, defaultWebImageLoader } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';

// Import the embedded tutorial file
import embeddedTutorial from './tutorials/my-tutorial.tutorial.json';

function TutorialApp() {
  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <TutorialPlayer
        tutorials={embeddedTutorial.tutorials}
        imageLoader={defaultWebImageLoader}
        onClose={() => console.log('Tutorial closed')}
      />
    </div>
  );
}

export default TutorialApp;

Why use embedded format?

Zero configuration - No glob imports, no image mapping, no bundler setup ✅ Single file - Easy to distribute, version, and deploy ✅ Works everywhere - CDN, static hosting, any bundler ✅ Type-safe - Full TypeScript support out of the box

Trade-off: File size is ~33% larger due to base64 encoding.

File Structure:

src/
├── App.tsx
└── tutorials/
    └── onboarding.tutorial.json   ← Single embedded file (~2-3 MB)

Converting existing projects: Open your folder-based project in the tutorial-maker app and click "Export" to generate the embedded version.

Props API

TutorialPlayerProps

Prop Type Required Default Description
tutorials Tutorial[] Yes - Array of tutorial objects (extract from project.tutorials)
initialTutorialId string No First tutorial ID of the initially selected tutorial
imageLoader (path: string) => Promise<string> No defaultWebImageLoader Custom function to load images and return data URLs
resolveImagePath (relativePath: string) => string No Identity function Function to resolve relative image paths to absolute paths/URLs
onClose () => void No - Callback when the close button is clicked (if not provided, close button is hidden)
className string No '' Additional CSS class names for the root container

Tutorial Data Format

Complete Type Definitions

interface Project {
  id: string;
  name: string;
  projectFolder: string;        // Folder name only
  tutorials: Tutorial[];
  createdAt: string;             // ISO date string
  updatedAt: string;             // ISO date string
  embedded?: boolean;            // True if screenshots are embedded as base64 data URLs
  annotationDefaults?: {
    arrowColor: string;
    highlightColor: string;
    balloonBackgroundColor: string;
    balloonTextColor: string;
    badgeBackgroundColor: string;
    badgeTextColor: string;
    rectColor: string;
    circleColor: string;
  };
}

interface Tutorial {
  id: string;
  title: string;
  description: string;
  steps: Step[];
  createdAt: string;
  updatedAt: string;
}

interface Step {
  id: string;
  title: string;
  description: string;
  screenshotPath: string | null;  // Relative path like "screenshots/step1.png"
                                   // OR data URL like "data:image/png;base64,..." for embedded format
  subSteps: SubStep[];
  order: number;
}

interface SubStep {
  id: string;
  title: string;
  description: string;
  order: number;

  // Legacy format (still supported)
  annotations?: Annotation[];

  // New format (recommended)
  annotationActions?: AnnotationAction[];
  clearPreviousAnnotations?: boolean;
  showAnnotationsSequentially?: boolean;
}

Annotation Types

type Annotation =
  | ArrowAnnotation
  | TextBalloonAnnotation
  | HighlightAnnotation
  | NumberedBadgeAnnotation
  | RectAnnotation
  | CircleAnnotation;

interface ArrowAnnotation {
  id: string;
  type: 'arrow';
  position: Position;
  startPosition: Position;
  endPosition: Position;
  controlPoint?: Position;  // For curved arrows
  color: string;
  thickness: number;
  doubleHeaded?: boolean;
}

interface TextBalloonAnnotation {
  id: string;
  type: 'textBalloon';
  position: Position;
  text: string;
  size: Size;
  backgroundColor: string;
  textColor: string;
  tailPosition?: TailPosition;
}

interface HighlightAnnotation {
  id: string;
  type: 'highlight';
  position: Position;
  size: Size;
  color: string;
  opacity: number;
}

interface NumberedBadgeAnnotation {
  id: string;
  type: 'numberedBadge';
  position: Position;
  number: number;
  size: number;
  backgroundColor: string;
  textColor: string;
}

Image Loading

Understanding Screenshot Paths

Screenshots are stored with relative paths in the tutorial JSON:

{
  "screenshotPath": "screenshots/welcome.png"
}

You must resolve these to absolute paths or data URLs using the imageLoader and resolveImagePath props.

Default Web Loader

The package includes a default image loader for web environments:

import { TutorialPlayer, defaultWebImageLoader } from '@tutorial-maker/react-player';

<TutorialPlayer
  tutorials={tutorials}
  imageLoader={defaultWebImageLoader}
  resolveImagePath={(path) => `/tutorials/my-project/${path}`}
/>

Custom Image Loader with Authentication

const customImageLoader = async (path: string): Promise<string> => {
  const response = await fetch(path, {
    headers: {
      Authorization: `Bearer ${getAuthToken()}`,
    },
  });

  if (!response.ok) {
    throw new Error(`Failed to load image: ${path}`);
  }

  const blob = await response.blob();

  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result as string);
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  });
};

<TutorialPlayer
  tutorials={tutorials}
  imageLoader={customImageLoader}
  resolveImagePath={(path) => `https://cdn.example.com/${path}`}
/>

Tauri/Desktop Applications

For Tauri applications, create a custom loader using Tauri's file system API:

import { readFile } from '@tauri-apps/plugin-fs';
import { TutorialPlayer, bytesToBase64 } from '@tutorial-maker/react-player';

const tauriImageLoader = async (absolutePath: string): Promise<string> => {
  const bytes = await readFile(absolutePath);
  const base64 = bytesToBase64(bytes);
  const mimeType = absolutePath.endsWith('.jpg') ? 'image/jpeg' : 'image/png';
  return `data:${mimeType};base64,${base64}`;
};

<TutorialPlayer
  tutorials={tutorials}
  imageLoader={tauriImageLoader}
  resolveImagePath={(relativePath) =>
    `${projectBasePath}/${relativePath}`
  }
/>

Styling

CSS Import

Always import the styles in your entry component:

import '@tutorial-maker/react-player/styles.css';

Theme Customization

The package uses CSS variables for theming. Override them in your CSS:

:root {
  /* Background and foreground colors */
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;

  /* Primary colors */
  --primary: 222.2 47.4% 11.2%;
  --primary-foreground: 210 40% 98%;

  /* Secondary colors */
  --secondary: 210 40% 96.1%;
  --secondary-foreground: 222.2 47.4% 11.2%;

  /* Muted colors */
  --muted: 210 40% 96.1%;
  --muted-foreground: 215.4 16.3% 46.9%;

  /* Accent colors */
  --accent: 210 40% 96.1%;
  --accent-foreground: 222.2 47.4% 11.2%;

  /* Card colors */
  --card: 0 0% 100%;
  --card-foreground: 222.2 84% 4.9%;

  /* Border and input colors */
  --border: 214.3 31.8% 91.4%;
  --input: 214.3 31.8% 91.4%;
  --ring: 222.2 84% 4.9%;

  /* Border radius */
  --radius: 0.5rem;
}

/* Dark mode */
.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  --primary: 210 40% 98%;
  --primary-foreground: 222.2 47.4% 11.2%;
  /* ... */
}

Custom Container Styling

<TutorialPlayer
  tutorials={tutorials}
  className="my-custom-player"
/>
.my-custom-player {
  border-radius: 12px;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
  max-width: 1400px;
  margin: 0 auto;
}

TypeScript Support

The package is written in TypeScript and includes full type definitions.

Importing Types

import type {
  Tutorial,
  Project,
  Step,
  SubStep,
  Annotation,
  ArrowAnnotation,
  TextBalloonAnnotation,
  HighlightAnnotation,
  NumberedBadgeAnnotation,
  TutorialPlayerProps,
  Position,
  Size,
  TailPosition,
} from '@tutorial-maker/react-player';

Type-Safe Tutorial Data

import { TutorialPlayer, type Tutorial } from '@tutorial-maker/react-player';

const tutorials: Tutorial[] = [
  {
    id: 'tutorial-1',
    title: 'My Tutorial',
    description: 'A great tutorial',
    steps: [/* ... */],
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  },
];

<TutorialPlayer tutorials={tutorials} />

Best Practices

1. Image Optimization

Optimize screenshots before bundling:

  • Use WebP format for better compression
  • Resize images to appropriate dimensions (1920x1080 max recommended)
  • Use image optimization tools in your build pipeline
# Using sharp-cli
npx sharp-cli -i input.png -o output.webp --quality 85

2. Bundle Size Considerations

For large tutorial projects:

  • Consider loading tutorials on-demand rather than bundling all at once
  • Use code splitting to lazy load the player component
  • Compress project JSON files
// Lazy load the player
const TutorialPlayer = React.lazy(() =>
  import('@tutorial-maker/react-player').then(mod => ({
    default: mod.TutorialPlayer
  }))
);

function App() {
  return (
    <React.Suspense fallback={<div>Loading...</div>}>
      <TutorialPlayer tutorials={tutorials} />
    </React.Suspense>
  );
}

3. Error Handling

Always handle image loading errors gracefully:

const imageLoader = async (path: string): Promise<string> => {
  try {
    const imageUrl = screenshots[`./tutorials/${projectFolder}/${path}`];

    if (!imageUrl) {
      console.error(`Screenshot not found: ${path}`);
      // Return a placeholder or throw error
      return PLACEHOLDER_IMAGE;
    }

    return imageUrl;
  } catch (error) {
    console.error('Failed to load image:', error);
    throw error;
  }
};

4. Loading States

Show loading states while tutorials are being fetched:

function TutorialApp() {
  const [loading, setLoading] = useState(true);
  const [tutorials, setTutorials] = useState<Tutorial[]>([]);

  useEffect(() => {
    loadTutorials().then(data => {
      setTutorials(data.tutorials);
      setLoading(false);
    });
  }, []);

  if (loading) {
    return <LoadingSpinner />;
  }

  return <TutorialPlayer tutorials={tutorials} />;
}

5. Responsive Container

Always wrap the player in a properly sized container:

<div style={{
  width: '100%',
  height: '100vh',
  display: 'flex',
  flexDirection: 'column'
}}>
  <TutorialPlayer tutorials={tutorials} />
</div>

Browser Support

  • Modern browsers (Chrome 90+, Firefox 88+, Safari 14+, Edge 90+)
  • ES2020+ support required
  • CSS Grid and Flexbox support required
  • CSS custom properties (CSS variables) support required

Keyboard Shortcuts

The player includes built-in keyboard navigation:

  • Arrow Up/Down - Navigate between substeps
  • Space - Next substep
  • Home - Go to first substep
  • End - Go to last substep
  • Escape - Close player (if onClose is provided)

Troubleshooting

Images Not Loading

  1. Check that imageLoader is returning data URLs
  2. Verify resolveImagePath is generating correct paths
  3. Check browser console for error messages
  4. Ensure screenshot paths in JSON match actual file paths

TypeScript Errors

  1. Ensure you're importing types from the package
  2. Check that your tsconfig.json includes the package
  3. Verify tutorial data matches the expected type structure

Styling Issues

  1. Ensure you've imported the CSS: import '@tutorial-maker/react-player/styles.css'
  2. Check for CSS specificity conflicts
  3. Verify CSS variables are defined in your theme

License

MIT

Contributing

Contributions are welcome! Please open an issue or submit a pull request on GitHub.

Support

For issues, questions, or feature requests, please open an issue on the GitHub repository.