Message APINPM Package/docs/workflows/revision/react-setup

React Revision Workflow

Build a React + TypeScript revision workflow with the Rasterex NPM package. The example keeps each file separate so teams can copy the pieces into an application without unpacking tabbed snippets.

What you will build

This workflow creates one page where a user can enter a background drawing URL, an overlay drawing URL, choose DPI, and run either direct comparison or interactive alignment.

For NPM, the Revision API exposes terminal events for comparisonComplete and comparisonError. Keep local pending state around the action you invoke, then clear it from those terminal events.

1. Create the React project

Start with Vite, then install the Rasterex viewer package.

bash
npm create vite@latest rasterex-revision-react -- --template react-ts
cd rasterex-revision-react
npm install @rasterex/viewer
1. Create the React project

2. src/types/revision.ts

Keep the UI state and viewer result shape explicit. The result payload can be empty during the first interactive align stage, so the UI treats it as optional.

ts
export type RevisionMode = 'compare' | 'align';

export type RevisionStatus = 'idle' | 'mounting' | 'ready' | 'processing' | 'complete' | 'error';

export interface RevisionInputs {
  backgroundUrl: string;
  overlayUrl: string;
  dpi: number;
  outputName: string;
}

export interface RevisionResult {
  url?: string;
  relativePath?: string;
  fileName?: string;
}
2. src/types/revision.ts

3. src/hooks/useRevisionViewer.ts

Mount the viewer once, subscribe only to documented NPM revision events, and expose simple actions for compare and align.

tsx
import { useCallback, useEffect, useRef, useState } from 'react';
import { createViewer } from '@rasterex/viewer';
import type { RevisionInputs, RevisionResult, RevisionStatus } from '../types/revision';

const LICENSE_KEY = import.meta.env.VITE_RASTEREX_LICENSE_KEY;

export function useRevisionViewer() {
  const hostRef = useRef<HTMLDivElement | null>(null);
  const viewerRef = useRef<Awaited<ReturnType<typeof createViewer>> | null>(null);
  const [status, setStatus] = useState<RevisionStatus>('idle');
  const [message, setMessage] = useState('Preparing viewer');
  const [result, setResult] = useState<RevisionResult | null>(null);

  useEffect(() => {
    let disposed = false;
    const unsubscribers: Array<() => void> = [];

    async function mountViewer() {
      if (!hostRef.current) return;

      setStatus('mounting');
      setMessage('Loading Rasterex viewer');

      try {
        const viewer = await createViewer({
          container: hostRef.current,
          licenseKey: LICENSE_KEY,
          features: ['compare']
        });

        if (disposed) {
          viewer.destroy?.();
          return;
        }

        viewerRef.current = viewer;

        unsubscribers.push(
          viewer.compare.on('comparisonComplete', (payload: RevisionResult) => {
            setResult(payload ?? null);
            setStatus('complete');
            setMessage(payload?.relativePath ? 'Revision output is ready' : 'Alignment stage is ready');
          }),
          viewer.compare.on('comparisonError', (error: Error) => {
            setStatus('error');
            setMessage(error.message || 'Revision workflow failed');
          })
        );

        setStatus('ready');
        setMessage('Viewer ready');
      } catch (error) {
        setStatus('error');
        setMessage(error instanceof Error ? error.message : 'Viewer failed to load');
      }
    }

    mountViewer();

    return () => {
      disposed = true;
      unsubscribers.forEach((unsubscribe) => unsubscribe());
      viewerRef.current?.destroy?.();
      viewerRef.current = null;
    };
  }, []);

  const compare = useCallback(async (inputs: RevisionInputs) => {
    if (!viewerRef.current) return;

    setStatus('processing');
    setMessage('Comparing revisions');
    setResult(null);

    await viewerRef.current.compare.compare({
      backgroundUrl: inputs.backgroundUrl,
      overlayUrl: inputs.overlayUrl,
      dpi: inputs.dpi,
      outputName: inputs.outputName
    });
  }, []);

  const align = useCallback(async (inputs: RevisionInputs) => {
    if (!viewerRef.current) return;

    setStatus('processing');
    setMessage('Starting interactive alignment');
    setResult(null);

    await viewerRef.current.compare.align({
      backgroundUrl: inputs.backgroundUrl,
      overlayUrl: inputs.overlayUrl,
      dpi: inputs.dpi,
      outputName: inputs.outputName
    });
  }, []);

  return {
    hostRef,
    status,
    message,
    result,
    compare,
    align,
    isBusy: status === 'mounting' || status === 'processing'
  };
}
3. src/hooks/useRevisionViewer.ts

4. src/components/RevisionForm.tsx

The form makes common actions easy: swap background and overlay, choose compare or align, and keep inputs editable after failures.

tsx
import type { FormEvent } from 'react';
import type { RevisionInputs, RevisionMode } from '../types/revision';

interface RevisionFormProps {
  inputs: RevisionInputs;
  mode: RevisionMode;
  disabled: boolean;
  onInputsChange: (inputs: RevisionInputs) => void;
  onModeChange: (mode: RevisionMode) => void;
  onSubmit: () => void;
}

export function RevisionForm({
  inputs,
  mode,
  disabled,
  onInputsChange,
  onModeChange,
  onSubmit
}: RevisionFormProps) {
  function updateInput<Key extends keyof RevisionInputs>(key: Key, value: RevisionInputs[Key]) {
    onInputsChange({ ...inputs, [key]: value });
  }

  function swapFiles() {
    onInputsChange({
      ...inputs,
      backgroundUrl: inputs.overlayUrl,
      overlayUrl: inputs.backgroundUrl
    });
  }

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    onSubmit();
  }

  return (
    <form className="revision-panel" onSubmit={submit}>
      <div className="mode-switch" role="radiogroup" aria-label="Revision mode">
        <button
          type="button"
          className={mode === 'compare' ? 'is-active' : ''}
          onClick={() => onModeChange('compare')}
          disabled={disabled}
        >
          Compare
        </button>
        <button
          type="button"
          className={mode === 'align' ? 'is-active' : ''}
          onClick={() => onModeChange('align')}
          disabled={disabled}
        >
          Align
        </button>
      </div>

      <label>
        Background URL
        <input
          value={inputs.backgroundUrl}
          onChange={(event) => updateInput('backgroundUrl', event.target.value)}
          placeholder="https://example.com/revision-a.pdf"
          required
        />
      </label>

      <label>
        Overlay URL
        <input
          value={inputs.overlayUrl}
          onChange={(event) => updateInput('overlayUrl', event.target.value)}
          placeholder="https://example.com/revision-b.pdf"
          required
        />
      </label>

      <div className="form-row">
        <label>
          DPI
          <input
            type="number"
            min={72}
            max={600}
            value={inputs.dpi}
            onChange={(event) => updateInput('dpi', Number(event.target.value))}
          />
        </label>
        <label>
          Output name
          <input
            value={inputs.outputName}
            onChange={(event) => updateInput('outputName', event.target.value)}
            required
          />
        </label>
      </div>

      <div className="actions">
        <button type="button" className="secondary" onClick={swapFiles} disabled={disabled}>
          Swap files
        </button>
        <button type="submit" disabled={disabled}>
          {mode === 'align' ? 'Start alignment' : 'Compare revisions'}
        </button>
      </div>
    </form>
  );
}
4. src/components/RevisionForm.tsx

5. src/App.tsx

Compose the form, live status, result link, and viewer surface. Button disabling is driven by local action state, not progress events.

tsx
import { useState } from 'react';
import { RevisionForm } from './components/RevisionForm';
import { useRevisionViewer } from './hooks/useRevisionViewer';
import type { RevisionInputs, RevisionMode } from './types/revision';
import './App.css';

const initialInputs: RevisionInputs = {
  backgroundUrl: '',
  overlayUrl: '',
  dpi: 300,
  outputName: 'revision-comparison.pdf'
};

export default function App() {
  const [inputs, setInputs] = useState(initialInputs);
  const [mode, setMode] = useState<RevisionMode>('compare');
  const { hostRef, status, message, result, compare, align, isBusy } = useRevisionViewer();

  async function runRevision() {
    if (mode === 'align') {
      await align(inputs);
      return;
    }

    await compare(inputs);
  }

  return (
    <main className="revision-workspace">
      <section className="sidebar" aria-label="Revision controls">
        <div>
          <p className="eyebrow">Rasterex Revision</p>
          <h1>Compare drawing revisions</h1>
        </div>

        <RevisionForm
          inputs={inputs}
          mode={mode}
          disabled={isBusy}
          onInputsChange={setInputs}
          onModeChange={setMode}
          onSubmit={runRevision}
        />

        <section className={`status-card status-${status}`} aria-live="polite">
          <span>{status}</span>
          <p>{message}</p>
          {result?.relativePath ? (
            <a href={result.url ?? result.relativePath} target="_blank" rel="noreferrer">
              Open generated comparison
            </a>
          ) : null}
        </section>
      </section>

      <section className="viewer-shell" aria-label="Rasterex viewer">
        <div ref={hostRef} className="viewer-host" />
      </section>
    </main>
  );
}
5. src/App.tsx

6. src/App.css

Use a two-column operations layout on desktop and stack the controls above the viewer on smaller screens.

css
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  background: #f4f6f8;
  color: #18212f;
}

button,
input {
  font: inherit;
}

.revision-workspace {
  display: grid;
  grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
  min-height: 100vh;
}

.sidebar {
  display: flex;
  flex-direction: column;
  gap: 20px;
  padding: 28px;
  border-right: 1px solid #d9e0e8;
  background: #ffffff;
}

.eyebrow {
  margin: 0 0 6px;
  color: #526173;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

h1 {
  margin: 0;
  font-size: clamp(1.7rem, 2vw, 2.3rem);
  line-height: 1.1;
}

.revision-panel,
.status-card {
  display: grid;
  gap: 14px;
  padding: 16px;
  border: 1px solid #d9e0e8;
  border-radius: 8px;
}

.mode-switch {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 6px;
  padding: 4px;
  border-radius: 8px;
  background: #eef2f6;
}

.mode-switch button,
.actions button {
  min-height: 42px;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
}

.mode-switch button {
  background: transparent;
  color: #526173;
}

.mode-switch button.is-active,
.actions button[type="submit"] {
  background: #1f6feb;
  color: #ffffff;
}

label {
  display: grid;
  gap: 6px;
  color: #526173;
  font-size: 0.88rem;
  font-weight: 700;
}

input {
  width: 100%;
  min-height: 42px;
  border: 1px solid #c8d2dc;
  border-radius: 6px;
  padding: 0 12px;
  color: #18212f;
}

.form-row,
.actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

.actions .secondary {
  border: 1px solid #c8d2dc;
  background: #ffffff;
  color: #18212f;
}

button:disabled,
input:disabled {
  cursor: not-allowed;
  opacity: 0.58;
}

.status-card span {
  width: fit-content;
  border-radius: 999px;
  padding: 4px 10px;
  background: #e8eef5;
  color: #526173;
  font-size: 0.75rem;
  font-weight: 800;
  text-transform: uppercase;
}

.status-card p {
  margin: 0;
}

.status-error {
  border-color: #f2a6a6;
  background: #fff7f7;
}

.status-complete {
  border-color: #93d2aa;
  background: #f3fbf6;
}

.viewer-shell {
  min-width: 0;
  padding: 18px;
}

.viewer-host {
  width: 100%;
  height: calc(100vh - 36px);
  overflow: hidden;
  border: 1px solid #c8d2dc;
  border-radius: 8px;
  background: #1c2430;
}

@media (max-width: 900px) {
  .revision-workspace {
    grid-template-columns: 1fr;
  }

  .sidebar {
    border-right: 0;
    border-bottom: 1px solid #d9e0e8;
  }

  .viewer-host {
    height: 68vh;
  }
}

@media (max-width: 560px) {
  .form-row,
  .actions {
    grid-template-columns: 1fr;
  }

  .sidebar,
  .viewer-shell {
    padding: 16px;
  }
}
6. src/App.css

7. Verify the workflow

Run the app, open two compatible documents, and confirm compare emits comparisonComplete with an output path.

For align, confirm the first comparisonComplete can represent the interactive alignment stage and the final comparisonComplete returns the generated result.

Force an invalid URL and confirm comparisonError unlocks the UI while preserving the user inputs.

bash
npm run dev
7. Verify the workflow