Message APINPM Package/docs/workflows/issue-management/react-setup

React Issue Management Setup

Build a React issue task workflow with the Rasterex NPM package. Users place issue markers in the Viewer and manage linked task metadata in the host UI.

1. Create And Install

Create the React app and install the Rasterex Viewer package.

bash
npm create vite@latest issue-management-react -- --template react-ts
cd issue-management-react
npm install @rasterex/viewer
npm run dev
1. Create And Install

2. File Structure

Keep the Viewer lifecycle, issue state, task panel, editor dialog, and viewer shell separate.

text
src/
  App.tsx
  App.css
  config/
    issueConfig.ts
  types/
    issue.ts
  hooks/
    useIssueManagementViewer.ts
  components/
    IssueEditorDialog.tsx
    IssueTaskPanel.tsx
    IssueStatusBar.tsx
    ViewerHost.tsx
2. File Structure

3. Environment Values

Set the drawing URL that Canvas can load.

bash
VITE_ISSUE_DRAWING_URL=https://files.example.com/site-plan.pdf
VITE_ISSUE_DRAWING_NAME=site-plan.pdf
3. Environment Values

4. types/issue.ts

Create task, draft, and Viewer state types.

typescript
export type IssueStatus = 'open' | 'resolved';
export type ViewerStatus = 'mounting' | 'ready' | 'placing' | 'editing' | 'error';

export interface IssueTask {
  id: string;
  annotationId: string;
  title: string;
  assignee: string;
  priority: string;
  dueDate: string;
  status: IssueStatus;
  notes: string;
}

export interface IssueDraft {
  title: string;
  assignee: string;
  priority: string;
  dueDate: string;
  notes: string;
}

export interface IssueAnnotationEvent {
  guid?: string;
  uniqueId?: string;
  id?: string;
  markupId?: string;
  markupNumber?: string | number;
}

export interface IssueMarkerStyle {
  strokeColor: string;
  fillColor: string;
  color: string;
  opacity: number;
  fillOpacity: number;
  strokeWidth: number;
}
4. types/issue.ts

5. config/issueConfig.ts

Centralize default task values, drawing file settings, and the issue marker style.

typescript
import type { IssueDraft, IssueMarkerStyle } from '../types/issue';

export const issueDrawing = {
  url: import.meta.env.VITE_ISSUE_DRAWING_URL || 'https://files.example.com/site-plan.pdf',
  displayName: import.meta.env.VITE_ISSUE_DRAWING_NAME || 'site-plan.pdf',
};

export const initialIssueDraft: IssueDraft = {
  title: 'Review clash at marked area',
  assignee: 'Site coordinator',
  priority: 'High',
  dueDate: '2026-05-22',
  notes: 'Confirm drawing condition and assign next action.',
};

export const assigneeOptions = ['Site coordinator', 'Project manager', 'QA reviewer', 'Facilities lead'];
export const priorityOptions = ['Low', 'Normal', 'High', 'Critical'];

export const issueMarkerStyle: IssueMarkerStyle = {
  color: '#dc2626',
  strokeColor: '#b91c1c',
  fillColor: '#fca5a5',
  opacity: 0.9,
  fillOpacity: 0.14,
  strokeWidth: 2,
};
5. config/issueConfig.ts

6. hooks/useIssueManagementViewer.ts

Mount the NPM Viewer, open the drawing, activate the issue marker tool, capture created annotation IDs, and keep task selection synced with Canvas.

tsx
import { useCallback, useEffect, useRef, useState } from 'react';
import { createViewer, type RasterexViewer } from '@rasterex/viewer';
import { initialIssueDraft, issueDrawing, issueMarkerStyle } from '../config/issueConfig';
import type { IssueAnnotationEvent, IssueDraft, IssueStatus, IssueTask, ViewerStatus } from '../types/issue';

export function useIssueManagementViewer() {
  const hostRef = useRef<HTMLDivElement | null>(null);
  const viewerRef = useRef<RasterexViewer | null>(null);
  const isMarkerPendingRef = useRef(false);
  const pendingAnnotationIdRef = useRef<string | null>(null);
  const [viewerStatus, setViewerStatus] = useState<ViewerStatus>('mounting');
  const [message, setMessage] = useState('Loading drawing');
  const [issues, setIssues] = useState<IssueTask[]>([]);
  const [selectedIssueId, setSelectedIssueId] = useState<string | null>(null);
  const [draft, setDraft] = useState<IssueDraft>(initialIssueDraft);
  const [pendingAnnotationId, setPendingAnnotationId] = useState<string | null>(null);
  const [editingIssueId, setEditingIssueId] = useState<string | null>(null);
  const [isEditorOpen, setIsEditorOpen] = useState(false);
  const [isMarkerPending, setIsMarkerPending] = useState(false);

  useEffect(() => {
    if (!hostRef.current) return undefined;

    let disposed = false;
    const viewer = createViewer({ container: hostRef.current });
    const cleanups: Array<() => void> = [];
    viewerRef.current = viewer;

    cleanups.push(
      viewer.annotations.on('created', (event: IssueAnnotationEvent) => {
        if (disposed || !isMarkerPendingRef.current) return;
        const annotationId = getAnnotationId(event);
        if (!annotationId) {
          isMarkerPendingRef.current = false;
          setIsMarkerPending(false);
          setViewerStatus('error');
          setMessage('Marker was created, but Canvas did not return an annotation ID.');
          return;
        }

        pendingAnnotationIdRef.current = annotationId;
        isMarkerPendingRef.current = false;
        setPendingAnnotationId(annotationId);
        setIsMarkerPending(false);
        setIsEditorOpen(true);
        setViewerStatus('editing');
        setMessage('Issue marker captured. Add task details to save it.');
      }),
      viewer.annotations.on('createdWithDetail', (event: IssueAnnotationEvent) => {
        if (disposed || pendingAnnotationIdRef.current || !isMarkerPendingRef.current) return;
        const annotationId = getAnnotationId(event);
        if (annotationId) {
          pendingAnnotationIdRef.current = annotationId;
          setPendingAnnotationId(annotationId);
        }
      }),
      viewer.annotations.on('selected', (event: IssueAnnotationEvent) => {
        const annotationId = getAnnotationId(event);
        if (!annotationId) return;

        setIssues((current) => {
          const selected = current.find((issue) => issue.annotationId === annotationId);
          if (selected) openIssueEditor(selected, 'Canvas');
          return current;
        });
      })
    );

    async function start() {
      await viewer.mount();
      await viewer.ready();
      await viewer.documents.open({
        url: issueDrawing.url,
        displayName: issueDrawing.displayName,
      });

      if (!disposed) {
        setViewerStatus('ready');
        setMessage('Drawing ready. Add an issue marker.');
      }
    }

    void start().catch((error) => {
      setViewerStatus('error');
      setMessage(error instanceof Error ? error.message : 'Viewer failed to load.');
    });

    return () => {
      disposed = true;
      cleanups.forEach((cleanup) => cleanup());
      viewer.destroy();
      viewerRef.current = null;
    };
  }, []);

  const startIssueMarker = useCallback(async () => {
    if (!viewerRef.current || viewerStatus === 'mounting') return;

    pendingAnnotationIdRef.current = null;
    isMarkerPendingRef.current = true;
    setPendingAnnotationId(null);
    setEditingIssueId(null);
    setIsEditorOpen(false);
    setIsMarkerPending(true);
    setDraft({
      ...initialIssueDraft,
      title: issues.length === 0 ? initialIssueDraft.title : 'Field issue ' + (issues.length + 1),
    });

    const result = await viewerRef.current.tools.set({
      action: 'SHAPE_ELLIPSE',
      enabled: true,
      style: issueMarkerStyle,
    });

    if (result?.success === false) {
      isMarkerPendingRef.current = false;
      setIsMarkerPending(false);
      setViewerStatus('error');
      setMessage(result.error ?? 'Issue marker tool failed.');
      return;
    }

    setViewerStatus('placing');
    setMessage('Issue marker is active. Place the marker on Canvas.');
  }, [issues.length, viewerStatus]);

  const saveIssue = useCallback(() => {
    const title = draft.title.trim();
    if (!title) {
      setMessage('Issue title is required.');
      return;
    }

    if (editingIssueId) {
      setIssues((current) => current.map((issue) => issue.id === editingIssueId ? {
        ...issue,
        title,
        assignee: draft.assignee.trim() || 'Unassigned',
        priority: draft.priority.trim() || 'Normal',
        dueDate: draft.dueDate.trim() || 'Not set',
        notes: draft.notes.trim(),
      } : issue));
      setIsEditorOpen(false);
      setEditingIssueId(null);
      setViewerStatus('ready');
      setMessage(title + ' updated.');
      return;
    }

    const annotationId = pendingAnnotationIdRef.current ?? pendingAnnotationId;
    if (!annotationId) {
      setMessage('Place an issue marker before saving the task.');
      return;
    }

    const nextIssue: IssueTask = {
      id: annotationId,
      annotationId,
      title,
      assignee: draft.assignee.trim() || 'Unassigned',
      priority: draft.priority.trim() || 'Normal',
      dueDate: draft.dueDate.trim() || 'Not set',
      status: 'open',
      notes: draft.notes.trim(),
    };

    setIssues((current) => [nextIssue, ...current]);
    setSelectedIssueId(nextIssue.id);
    pendingAnnotationIdRef.current = null;
    isMarkerPendingRef.current = false;
    setIsEditorOpen(false);
    setPendingAnnotationId(null);
    setEditingIssueId(null);
    setIsMarkerPending(false);
    viewerRef.current?.tools.clear();
    setViewerStatus('ready');
    setMessage(nextIssue.title + ' saved as an open task.');
  }, [draft, editingIssueId, pendingAnnotationId]);

  const cancelIssue = useCallback(() => {
    const annotationId = pendingAnnotationIdRef.current ?? pendingAnnotationId;
    if (annotationId && !editingIssueId) {
      viewerRef.current?.annotations.delete({ guid: annotationId });
    }

    viewerRef.current?.tools.clear();
    pendingAnnotationIdRef.current = null;
    isMarkerPendingRef.current = false;
    setIsEditorOpen(false);
    setPendingAnnotationId(null);
    setEditingIssueId(null);
    setIsMarkerPending(false);
    setViewerStatus('ready');
    setMessage(editingIssueId ? 'Issue edit canceled.' : 'Issue marker canceled.');
  }, [editingIssueId, pendingAnnotationId]);

  const selectIssue = useCallback((issue: IssueTask) => {
    openIssueEditor(issue, 'list');
    viewerRef.current?.annotations.select({ guid: issue.annotationId });
  }, []);

  const toggleStatus = useCallback((issue: IssueTask) => {
    const nextStatus: IssueStatus = issue.status === 'open' ? 'resolved' : 'open';
    setIssues((current) => current.map((candidate) => candidate.id === issue.id ? { ...candidate, status: nextStatus } : candidate));
    setMessage(issue.title + ' marked ' + nextStatus + '.');
  }, []);

  function openIssueEditor(issue: IssueTask, source: string) {
    setSelectedIssueId(issue.id);
    setEditingIssueId(issue.id);
    pendingAnnotationIdRef.current = null;
    isMarkerPendingRef.current = false;
    setPendingAnnotationId(null);
    setIsMarkerPending(false);
    setDraft({
      title: issue.title,
      assignee: issue.assignee,
      priority: issue.priority,
      dueDate: issue.dueDate,
      notes: issue.notes,
    });
    setIsEditorOpen(true);
    setViewerStatus('editing');
    setMessage(issue.title + ' selected from ' + source + '.');
  }

  return {
    hostRef,
    viewerStatus,
    message,
    issues,
    selectedIssueId,
    draft,
    isEditorOpen,
    isMarkerPending,
    isReady: viewerStatus === 'ready',
    setDraft,
    startIssueMarker,
    saveIssue,
    cancelIssue,
    selectIssue,
    toggleStatus,
  };
}

function getAnnotationId(event: IssueAnnotationEvent): string | null {
  const resolvedId = event.guid ?? event.uniqueId ?? event.id ?? event.markupId ?? event.markupNumber;
  if (typeof resolvedId === 'string' && resolvedId.trim()) return resolvedId;
  if (typeof resolvedId === 'number') return String(resolvedId);
  return null;
}
6. hooks/useIssueManagementViewer.ts

7. components/IssueStatusBar.tsx

Show workflow state above the task panel.

tsx
type Props = {
  message: string;
  openCount: number;
  resolvedCount: number;
};

export default function IssueStatusBar({ message, openCount, resolvedCount }: Props) {
  return <section className="status-bar" role="status" aria-live="polite">
    <div>
      <span>Issue Management</span>
      <p>{message}</p>
    </div>
    <dl>
      <div><dt>Open</dt><dd>{openCount}</dd></div>
      <div><dt>Resolved</dt><dd>{resolvedCount}</dd></div>
    </dl>
  </section>;
}
7. components/IssueStatusBar.tsx

8. components/IssueTaskPanel.tsx

Render the task list and keep Add Issue disabled until the drawing is ready.

tsx
import type { IssueTask } from '../types/issue';

type Props = {
  issues: IssueTask[];
  selectedIssueId: string | null;
  isReady: boolean;
  isMarkerPending: boolean;
  onStartMarker: () => Promise<void>;
  onSelectIssue: (issue: IssueTask) => void;
  onToggleStatus: (issue: IssueTask) => void;
};

export default function IssueTaskPanel({ issues, selectedIssueId, isReady, isMarkerPending, onStartMarker, onSelectIssue, onToggleStatus }: Props) {
  return <aside className="issue-panel">
    <button type="button" className="primary-action" disabled={!isReady || isMarkerPending} onClick={() => void onStartMarker()}>
      {isMarkerPending ? 'Place marker on Canvas' : 'Add issue'}
    </button>

    <div className="issue-list">
      {issues.length === 0 ? <p className="empty-state">Saved issue tasks appear here.</p> : issues.map((issue) => {
        const isSelected = selectedIssueId === issue.id;
        return <article className={isSelected ? 'issue-card selected' : 'issue-card'} key={issue.id}>
          <button type="button" onClick={() => onSelectIssue(issue)}>
            <strong>{issue.title}</strong>
            <span>{issue.assignee} / {issue.dueDate}</span>
            <small>{issue.priority} / {issue.status}</small>
          </button>
          <button type="button" className="secondary-action" onClick={() => onToggleStatus(issue)}>
            {issue.status === 'resolved' ? 'Reopen' : 'Resolve'}
          </button>
        </article>;
      })}
    </div>
  </aside>;
}
8. components/IssueTaskPanel.tsx

9. components/IssueEditorDialog.tsx

Open the editor after viewer.annotations.on("created", ...) returns the marker ID, or when an existing task is selected.

tsx
import { assigneeOptions, priorityOptions } from '../config/issueConfig';
import type { IssueDraft } from '../types/issue';

type Props = {
  open: boolean;
  draft: IssueDraft;
  onDraftChange: (draft: IssueDraft) => void;
  onCancel: () => void;
  onSave: () => void;
};

export default function IssueEditorDialog({ open, draft, onDraftChange, onCancel, onSave }: Props) {
  if (!open) return null;

  return <div className="dialog-backdrop">
    <section className="issue-dialog" role="dialog" aria-modal="true" aria-labelledby="issue-dialog-title">
      <header>
        <span>Marker task</span>
        <h2 id="issue-dialog-title">Issue task</h2>
        <p>Save task details for the marker placed on the drawing.</p>
      </header>

      <label>Title
        <input value={draft.title} onChange={(event) => onDraftChange({ ...draft, title: event.target.value })} />
      </label>

      <div className="field-row">
        <label>Assignee
          <select value={draft.assignee} onChange={(event) => onDraftChange({ ...draft, assignee: event.target.value })}>
            {assigneeOptions.map((option) => <option key={option}>{option}</option>)}
          </select>
        </label>
        <label>Priority
          <select value={draft.priority} onChange={(event) => onDraftChange({ ...draft, priority: event.target.value })}>
            {priorityOptions.map((option) => <option key={option}>{option}</option>)}
          </select>
        </label>
        <label>Due date
          <input type="date" value={draft.dueDate} onChange={(event) => onDraftChange({ ...draft, dueDate: event.target.value })} />
        </label>
      </div>

      <label>Notes
        <textarea rows={4} value={draft.notes} onChange={(event) => onDraftChange({ ...draft, notes: event.target.value })} />
      </label>

      <footer>
        <button type="button" className="secondary-action" onClick={onCancel}>Cancel</button>
        <button type="button" className="primary-action" onClick={onSave}>Save task</button>
      </footer>
    </section>
  </div>;
}
9. components/IssueEditorDialog.tsx

10. components/ViewerHost.tsx

Render the NPM Viewer mount point.

tsx
import type { RefObject } from 'react';

type Props = {
  hostRef: RefObject<HTMLDivElement | null>;
};

export default function ViewerHost({ hostRef }: Props) {
  return <section className="viewer-shell" aria-label="Rasterex viewer">
    <div ref={hostRef} className="viewer-host" />
  </section>;
}
10. components/ViewerHost.tsx

11. App.tsx

Compose the Viewer, issue task queue, editor dialog, and status summary.

tsx
import './App.css';
import IssueEditorDialog from './components/IssueEditorDialog';
import IssueStatusBar from './components/IssueStatusBar';
import IssueTaskPanel from './components/IssueTaskPanel';
import ViewerHost from './components/ViewerHost';
import { useIssueManagementViewer } from './hooks/useIssueManagementViewer';

export default function App() {
  const issueWorkflow = useIssueManagementViewer();
  const openCount = issueWorkflow.issues.filter((issue) => issue.status === 'open').length;
  const resolvedCount = issueWorkflow.issues.length - openCount;

  return <main className="issue-workspace">
    <section className="sidebar">
      <IssueStatusBar message={issueWorkflow.message} openCount={openCount} resolvedCount={resolvedCount} />
      <IssueTaskPanel
        issues={issueWorkflow.issues}
        selectedIssueId={issueWorkflow.selectedIssueId}
        isReady={issueWorkflow.isReady}
        isMarkerPending={issueWorkflow.isMarkerPending}
        onStartMarker={issueWorkflow.startIssueMarker}
        onSelectIssue={issueWorkflow.selectIssue}
        onToggleStatus={issueWorkflow.toggleStatus}
      />
    </section>

    <ViewerHost hostRef={issueWorkflow.hostRef} />

    <IssueEditorDialog
      open={issueWorkflow.isEditorOpen}
      draft={issueWorkflow.draft}
      onDraftChange={issueWorkflow.setDraft}
      onCancel={issueWorkflow.cancelIssue}
      onSave={issueWorkflow.saveIssue}
    />
  </main>;
}
11. App.tsx

12. App.css

Use a task-management layout: issue queue on the left, Viewer on the right, and modal editor for marker details.

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

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

button, input, select, textarea { font: inherit; }

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

.sidebar {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 22px;
  border-right: 1px solid #d7dee8;
  background: #ffffff;
}

.status-bar, .issue-panel, .issue-card, .issue-dialog {
  border: 1px solid #d7dee8;
  border-radius: 8px;
  background: #ffffff;
}

.status-bar { display: grid; gap: 12px; padding: 16px; }
.status-bar span { color: #536174; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
.status-bar p { margin: 4px 0 0; }
.status-bar dl { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 0; }
.status-bar dl div { border-radius: 6px; padding: 10px; background: #eef2f6; }
.status-bar dt { color: #536174; font-size: 0.74rem; font-weight: 800; text-transform: uppercase; }
.status-bar dd { margin: 2px 0 0; font-size: 1.4rem; font-weight: 800; }

.issue-panel { display: grid; gap: 12px; padding: 14px; }
.primary-action, .secondary-action { min-height: 38px; border-radius: 6px; padding: 0 12px; cursor: pointer; font-weight: 800; }
.primary-action { border: 0; background: #1f6feb; color: #ffffff; }
.secondary-action { border: 1px solid #c6d0db; background: #ffffff; color: #17202d; }
button:disabled { cursor: not-allowed; opacity: 0.55; }

.issue-list { display: grid; gap: 10px; }
.empty-state { margin: 0; border: 1px dashed #c6d0db; border-radius: 8px; padding: 28px 14px; color: #536174; text-align: center; }
.issue-card { display: grid; gap: 8px; padding: 12px; background: #f8fafc; }
.issue-card.selected { border-color: #1f6feb; box-shadow: 0 0 0 1px #1f6feb; }
.issue-card > button:first-child { display: grid; gap: 4px; width: 100%; border: 0; padding: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; }
.issue-card span, .issue-card small { color: #536174; }

.viewer-shell { min-width: 0; padding: 18px; }
.viewer-host { width: 100%; height: calc(100vh - 36px); border: 1px solid #c6d0db; border-radius: 8px; background: #1c2430; }

.dialog-backdrop { position: fixed; inset: 0; display: grid; place-items: center; padding: 18px; background: rgba(15, 23, 42, 0.45); }
.issue-dialog { display: grid; width: min(620px, 100%); gap: 14px; padding: 18px; box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28); }
.issue-dialog header span { color: #536174; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
.issue-dialog h2, .issue-dialog p { margin: 4px 0 0; }
.issue-dialog label { display: grid; gap: 6px; color: #536174; font-size: 0.88rem; font-weight: 700; }
.issue-dialog input, .issue-dialog select, .issue-dialog textarea { width: 100%; border: 1px solid #c6d0db; border-radius: 6px; padding: 9px 10px; color: #17202d; }
.field-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.issue-dialog footer { display: flex; justify-content: flex-end; gap: 10px; }

@media (max-width: 900px) {
  .issue-workspace { grid-template-columns: 1fr; }
  .sidebar { border-right: 0; border-bottom: 1px solid #d7dee8; }
  .viewer-host { height: 68vh; }
}

@media (max-width: 560px) {
  .sidebar, .viewer-shell { padding: 14px; }
  .field-row { grid-template-columns: 1fr; }
  .issue-dialog footer { display: grid; }
}
12. App.css

13. Verification Checklist

Verify the workflow against real NPM Viewer annotation events.

  • The drawing opens through viewer.documents.open(...) before Add issue is enabled.
  • Add issue calls viewer.tools.set(...) with SHAPE_ELLIPSE styling.
  • The editor opens only after viewer.annotations.on("created", ...) returns a marker ID.
  • Cancel on a new marker calls viewer.annotations.delete(...) and viewer.tools.clear().
  • Save creates an open task linked to the marker annotation ID.
  • Selecting a task calls viewer.annotations.select(...).
  • Selecting a marker in Canvas opens the matching task editor through viewer.annotations.on("selected", ...).
  • Resolve/Reopen updates only task status and leaves the marker in place.
  • The Viewer is destroyed and annotation listeners are removed on unmount.