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.
Create the React app and install the Rasterex Viewer package.
npm create vite@latest issue-management-react -- --template react-ts cd issue-management-react npm install @rasterex/viewer npm run dev
Keep the Viewer lifecycle, issue state, task panel, editor dialog, and viewer shell separate.
src/
App.tsx
App.css
config/
issueConfig.ts
types/
issue.ts
hooks/
useIssueManagementViewer.ts
components/
IssueEditorDialog.tsx
IssueTaskPanel.tsx
IssueStatusBar.tsx
ViewerHost.tsxSet the drawing URL that Canvas can load.
VITE_ISSUE_DRAWING_URL=https://files.example.com/site-plan.pdf VITE_ISSUE_DRAWING_NAME=site-plan.pdf
Create task, draft, and Viewer state types.
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;
}Centralize default task values, drawing file settings, and the issue marker style.
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,
};Mount the NPM Viewer, open the drawing, activate the issue marker tool, capture created annotation IDs, and keep task selection synced with Canvas.
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;
}Show workflow state above the task panel.
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>;
}Render the task list and keep Add Issue disabled until the drawing is ready.
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>;
}Open the editor after viewer.annotations.on("created", ...) returns the marker ID, or when an existing task is selected.
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>;
}Render the NPM Viewer mount point.
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>;
}Compose the Viewer, issue task queue, editor dialog, and status summary.
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>;
}Use a task-management layout: issue queue on the left, Viewer on the right, and modal editor for marker details.
* { 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; }
}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(...)withSHAPE_ELLIPSEstyling. - The editor opens only after
viewer.annotations.on("created", ...)returns a marker ID. - Cancel on a new marker calls
viewer.annotations.delete(...)andviewer.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.
