Skip to content

Copyleaks/visual-report

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

copyleaks-visual-report

An interactive AI image-detection report rendered as a single-file web component — framework-agnostic, zero configuration.

Drop <copyleaks-visual-report> into any page and get a fully interactive viewer: a zoomable / pannable scanned image with AI-detection overlay masks, an opacity slider, multiple mask views, and a detailed results side-panel — all from one script tag.

Works natively in Angular, React, Vue 3, and plain HTML.


Features

  • Two usage modes — pass data in manually, or hand the component a set of API endpoints and let it fetch and poll automatically
  • AI overlay masks — standard binary highlight (green = human, red = AI) and a gradual intensity heatmap
  • Zoom & pan — mouse wheel, pinch-to-zoom, drag-to-pan, and zoom buttons (1×–4×); cursor-aware zoom keeps the hovered image point fixed
  • Mask opacity control — slide the opacity of the overlay via the header popover
  • View-type switcher — Standard AI View, AI Intensity Map, or Hide Mask
  • Auto-polling — when given a progressUrl, the component polls every 2 seconds until the scan is complete, then fetches results
  • Custom request headers — pass Authorization or any other header for authenticated endpoints
  • Loading skeletons — every data row in the results panel shows a skeleton while data is in flight
  • Mobile swipe gesture — drag up/down on the results panel to collapse or expand it
  • Content credentials — displays embedded image metadata (issuer, issued time, device, content summary) when present
  • i18n ready — all user-visible strings use $localize tagged templates; swap the translation file to localise the UI
  • Zoneless Angular — built with provideExperimentalZonelessChangeDetection for maximum performance

Installation

npm install @copyleaks/visual-report
# or
yarn add @copyleaks/visual-report
# or
pnpm add @copyleaks/visual-report

The package ships a single pre-built JS file. It registers the <copyleaks-visual-report> custom element as soon as the script loads — no build step required.


Framework Integration & Usage

Angular

1. Serve the JS file — add an asset entry to your app's angular.json build options:

"assets": [
  {
    "glob": "copyleaks-visual-report.js",
    "input": "node_modules/@copyleaks/visual-report",
    "output": "/"
  }
]

2. Load it dynamically — inject the script from main.ts instead of a static <script> tag in index.html. Angular 17+ uses Vite as its dev server, which tries to pre-transform every type="module" script listed in index.html; because this file is a pre-built bundle (not a source file), Vite fails to resolve it. Injecting the tag at runtime bypasses that step entirely:

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

const script = document.createElement('script');
script.type = 'module';
script.src = 'copyleaks-visual-report.js';
document.head.appendChild(script);

bootstrapApplication(AppComponent, appConfig).catch(console.error);

3. Allow unknown elements — add CUSTOM_ELEMENTS_SCHEMA to any component that uses it.

4. Set properties after the element is defined — the web component bootstraps an Angular application asynchronously (createApplication is async), so customElements.define runs after several microtasks. Angular's property bindings fire on the first render cycle, before the element is registered, and pre-upgrade property queuing does not work reliably with signal-based inputs. Wait for customElements.whenDefined and then set properties via a template ref.

Manual mode — you supply the data

import {
  CUSTOM_ELEMENTS_SCHEMA,
  AfterViewInit,
  Component,
  ElementRef,
  ViewChild,
} from '@angular/core';

@Component({
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <copyleaks-visual-report
      #reportEl
      style="display: flex; height: 100vh;">
    </copyleaks-visual-report>
  `,
})
export class MyComponent implements AfterViewInit {
  @ViewChild('reportEl') private reportElRef!: ElementRef<HTMLElement>;

  private imageUrl = 'https://example.com/image.jpg';
  private reportData: ImageDetectionResponseResult = { /* ... */ };

  ngAfterViewInit(): void {
    customElements.whenDefined('copyleaks-visual-report').then(() => {
      const el = this.reportElRef.nativeElement as any;
      el.isLoading = false;
      el.imageUrl = this.imageUrl;
      el.reportData = this.reportData;

      el.addEventListener('viewTypeChange', (e: CustomEvent) => console.log('View type:', e.detail));
      el.addEventListener('onReportRequestError', (e: CustomEvent) => console.error('Error:', e.detail));
      el.addEventListener('loadingChange', (e: CustomEvent) => console.log('Loading:', e.detail));
    });
  }
}

Endpoint mode — the component fetches the data

Pass a reportEndpointConfig object instead. The component handles all polling and fetching internally — do not set isLoading, imageUrl, or reportData alongside it.

import {
  CUSTOM_ELEMENTS_SCHEMA,
  AfterViewInit,
  Component,
  ElementRef,
  ViewChild,
} from '@angular/core';

@Component({
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <copyleaks-visual-report
      #reportEl
      style="display: flex; height: 100vh;">
    </copyleaks-visual-report>
  `,
})
export class MyComponent implements AfterViewInit {
  @ViewChild('reportEl') private reportElRef!: ElementRef<HTMLElement>;

  ngAfterViewInit(): void {
    customElements.whenDefined('copyleaks-visual-report').then(() => {
      const el = this.reportElRef.nativeElement as any;
      el.reportEndpointConfig = {
        resultsUrl: 'https://api.example.com/scan/123/results',
        progressUrl: 'https://api.example.com/scan/123/progress',
        imageUrlEndpoint: 'https://api.example.com/scan/123/image-url',
        headers: { Authorization: 'Bearer <token>' },
      };

      el.addEventListener('onReportRequestError', (e: CustomEvent) => console.error('Error:', e.detail));
      el.addEventListener('loadingChange', (e: CustomEvent) => console.log('Loading:', e.detail));
    });
  }
}

React

1. Copy the JS file to public/ — add a postinstall script to your package.json so it is copied automatically after every install:

"scripts": {
  "postinstall": "node -e \"const{cpSync}=require('fs');cpSync('node_modules/@copyleaks/visual-report/copyleaks-visual-report.js','public/copyleaks-visual-report.js');\""
}

2. Load it dynamically — inject the script from main.tsx instead of a static <script> tag in index.html. Vite pre-transforms every type="module" script listed in index.html; because this file is a pre-built bundle it fails to resolve. Injecting the tag at runtime bypasses that step entirely:

// main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'

const script = document.createElement('script')
script.type = 'module'
script.src = '/copyleaks-visual-report.js'
document.head.appendChild(script)

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

3. Declare the custom element for TypeScript — add a declaration file so JSX accepts the <copyleaks-visual-report> tag:

// src/custom-elements.d.ts
import 'react'

declare module 'react' {
  namespace JSX {
    interface IntrinsicElements {
      'copyleaks-visual-report': React.HTMLAttributes<HTMLElement> & {
        ref?: React.RefObject<HTMLElement | null>
      }
    }
  }
}

4. Create a typed React wrapper — React sets HTML attributes rather than DOM properties, so complex objects (like reportData) must be forwarded through a ref. The wrapper also waits for customElements.whenDefined before setting any properties, since the web component bootstraps asynchronously:

// src/CopyleaksVisualReport.tsx
import { useRef, useEffect, useState, CSSProperties } from 'react'

interface ImageDetectionResponseResult {
  /* see Data Models section below */
}

interface ReportEndpointConfig {
  imageUrl?: string
  imageUrlEndpoint?: string
  resultsUrl: string
  progressUrl?: string
  headers?: Record<string, string>
}

interface Props {
  imageUrl?: string
  isLoading?: boolean
  reportData?: ImageDetectionResponseResult | null
  reportEndpointConfig?: ReportEndpointConfig
  onViewTypeChange?: (type: string) => void
  onReportRequestError?: (err: { statusCode: number; error?: { code?: number; message?: string } }) => void
  onLoadingChange?: (loading: boolean) => void
  style?: CSSProperties
  className?: string
}

export function CopyleaksVisualReport({
  imageUrl,
  isLoading = true,
  reportData,
  reportEndpointConfig,
  onViewTypeChange,
  onReportRequestError,
  onLoadingChange,
  style,
  className,
}: Props) {
  const ref = useRef<HTMLElement>(null)
  const [ready, setReady] = useState(false)

  useEffect(() => {
    customElements.whenDefined('copyleaks-visual-report').then(() => setReady(true))
  }, [])

  useEffect(() => {
    if (!ready || !ref.current) return
    const el = ref.current as any
    el.isLoading = isLoading
    el.imageUrl = imageUrl ?? null
    el.reportData = reportData ?? null
    if (reportEndpointConfig !== undefined) el.reportEndpointConfig = reportEndpointConfig
  }, [ready, imageUrl, isLoading, reportData, reportEndpointConfig])

  useEffect(() => {
    const el = ref.current
    if (!el || !ready) return
    const handleViewType = (e: Event) => onViewTypeChange?.((e as CustomEvent).detail)
    const handleError = (e: Event) => onReportRequestError?.((e as CustomEvent).detail)
    const handleLoading = (e: Event) => onLoadingChange?.((e as CustomEvent).detail)
    el.addEventListener('viewTypeChange', handleViewType)
    el.addEventListener('onReportRequestError', handleError)
    el.addEventListener('loadingChange', handleLoading)
    return () => {
      el.removeEventListener('viewTypeChange', handleViewType)
      el.removeEventListener('onReportRequestError', handleError)
      el.removeEventListener('loadingChange', handleLoading)
    }
  }, [ready, onViewTypeChange, onReportRequestError, onLoadingChange])

  return <copyleaks-visual-report ref={ref} style={style} className={className} />
}

5. Use it:

Manual mode — you supply the data

<CopyleaksVisualReport
  isLoading={false}
  imageUrl="https://example.com/image.jpg"
  reportData={scanResult}
  onViewTypeChange={(type) => console.log(type)}
  style={{ display: 'flex', height: '100vh' }}
/>

Endpoint mode — the component fetches the data

<CopyleaksVisualReport
  reportEndpointConfig={{
    resultsUrl: 'https://api.example.com/scan/123/results',
    progressUrl: 'https://api.example.com/scan/123/progress',
    imageUrlEndpoint: 'https://api.example.com/scan/123/image-url',
    headers: { Authorization: 'Bearer <token>' },
  }}
  onReportRequestError={(err) => console.error(err)}
  style={{ display: 'flex', height: '100vh' }}
/>

Vue 3

1. Copy the JS file to public/:

cp node_modules/@copyleaks/visual-report/copyleaks-visual-report.js public/

Or add a postinstall script (same pattern as the React section above).

2. Load it dynamically — inject the script from main.ts instead of a static <script> tag in index.html. Vite pre-transforms every type="module" script listed in index.html; because this file is a pre-built bundle it fails to resolve. Injecting the tag at runtime bypasses that step entirely:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'

const script = document.createElement('script')
script.type = 'module'
script.src = '/copyleaks-visual-report.js'
document.head.appendChild(script)

createApp(App).mount('#app')

3. Tell Vue to treat the element as a custom element — in vite.config.ts:

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // Treat any tag starting with "copyleaks-" as a custom element
          isCustomElement: (tag) => tag.startsWith('copyleaks-'),
        },
      },
    }),
  ],
});

4. Use it in a component — Vue does not pass object props to custom element DOM properties automatically, so all data must be forwarded through a ref. Wait for customElements.whenDefined before setting properties, then keep them in sync with watch.

Manual mode — you supply the data

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'

const reportEl = ref<HTMLElement | null>(null)
const reportData = ref<ImageDetectionResponseResult>({ /* ... */ })
const imageUrl = ref('https://example.com/image.jpg')
const isLoading = ref(false)

function setProps() {
  const el = reportEl.value as any
  if (!el) return
  el.isLoading = isLoading.value
  el.imageUrl = imageUrl.value
  el.reportData = reportData.value
}

onMounted(() => {
  customElements.whenDefined('copyleaks-visual-report').then(() => setProps())
})

watch([isLoading, imageUrl, reportData], setProps)
</script>

<template>
  <copyleaks-visual-report
    ref="reportEl"
    @viewTypeChange="(e: CustomEvent) => console.log('View type:', e.detail)"
    @onReportRequestError="(e: CustomEvent) => console.error('Error:', e.detail)"
    @loadingChange="(e: CustomEvent) => console.log('Loading:', e.detail)"
    style="display: flex; height: 100vh;"
  />
</template>

Endpoint mode — the component fetches the data

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const reportEl = ref<HTMLElement | null>(null)

onMounted(() => {
  customElements.whenDefined('copyleaks-visual-report').then(() => {
    const el = reportEl.value as any
    if (!el) return
    el.reportEndpointConfig = {
      resultsUrl: 'https://api.example.com/scan/123/results',
      progressUrl: 'https://api.example.com/scan/123/progress',
      imageUrlEndpoint: 'https://api.example.com/scan/123/image-url',
      headers: { Authorization: 'Bearer <token>' },
    }
  })
})
</script>

<template>
  <copyleaks-visual-report
    ref="reportEl"
    @onReportRequestError="(e: CustomEvent) => console.error('Error:', e.detail)"
    @loadingChange="(e: CustomEvent) => console.log('Loading:', e.detail)"
    style="display: flex; height: 100vh;"
  />
</template>

Plain HTML / Vanilla JS

<!DOCTYPE html>
<html lang="en">
<head>
  <script src="copyleaks-visual-report.js" type="module"></script>
</head>
<body>
  <copyleaks-visual-report id="report" style="display:flex;height:100vh;"></copyleaks-visual-report>

  <script>
    const report = document.getElementById('report');

    // Properties must be set after the element is defined
    customElements.whenDefined('copyleaks-visual-report').then(() => {
      report.imageUrl = 'https://example.com/image.jpg';
      report.isLoading = false;
      report.reportData = { /* ImageDetectionResponseResult — see Data Models */ };

      // Listen for events
      report.addEventListener('viewTypeChange', (e) => console.log('View type:', e.detail));
      report.addEventListener('onReportRequestError', (e) => console.error('Error:', e.detail));
      report.addEventListener('loadingChange', (e) => console.log('Loading:', e.detail));
    });
  </script>
</body>
</html>

Server-Side Rendering (SSR)

<copyleaks-visual-report> is a client-only custom element and cannot be rendered on the server.

It depends on browser-only APIs that have no server equivalent: the customElements registry, ResizeObserver, <canvas>, mouse and touch gesture handlers, and an Angular Elements bootstrap that calls createApplication against a real DOM. Even if a framework's renderer accepted the tag, the SSR'd HTML would be a non-interactive shell that fully re-hydrates on the client — there is no SEO or first-paint benefit to gain.

You can still use it inside an SSR'd app — just gate the render so it only happens in the browser. Per-framework patterns:

Next.js

// app/report/page.tsx (App Router)
'use client'

import dynamic from 'next/dynamic'

const Report = dynamic(() => import('@/components/CopyleaksVisualReport'), {
  ssr: false,
})

export default function Page() {
  return <Report imageUrl="..." reportData={...} />
}

The wrapper component itself (the typed React wrapper from the React section above) lives in a separate file and imports @copyleaks/visual-report. dynamic({ ssr: false }) ensures Next never tries to render it during SSR or static export.

Nuxt 3

<template>
  <client-only>
    <copyleaks-visual-report ref="reportEl" />
  </client-only>
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue'

const reportEl = ref<HTMLElement | null>(null)

onMounted(async () => {
  await import('@copyleaks/visual-report')
  await customElements.whenDefined('copyleaks-visual-report')
  ;(reportEl.value as any).reportData = { /* ... */ }
})
</script>

<client-only> prevents server rendering; the dynamic import() inside onMounted ensures the bundle never loads in the Nitro server bundle. Don't forget the Vite isCustomElement config from the Vue section above.

SvelteKit

<script lang="ts">
  import { browser } from '$app/environment'
  import { onMount } from 'svelte'

  let el: HTMLElement

  onMount(async () => {
    await import('@copyleaks/visual-report')
    await customElements.whenDefined('copyleaks-visual-report')
    ;(el as any).reportData = { /* ... */ }
  })
</script>

{#if browser}
  <copyleaks-visual-report bind:this={el}></copyleaks-visual-report>
{/if}

Angular Universal

import { Component, PLATFORM_ID, inject } from '@angular/core'
import { isPlatformBrowser } from '@angular/common'

@Component({
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    @if (isBrowser) {
      <copyleaks-visual-report #reportEl></copyleaks-visual-report>
    }
  `,
})
export class ReportComponent {
  protected isBrowser = isPlatformBrowser(inject(PLATFORM_ID))
}

Pair this with the dynamic-script-injection pattern from the Angular section above so the bundle is only fetched in the browser.

Astro

Mark the island as client:only:

---
import Report from '../components/CopyleaksVisualReport.tsx'
---

<Report client:only="react" imageUrl="..." reportData={...} />

API Reference

Properties

All inputs work in both the web component (<copyleaks-visual-report>) and the Angular library (<cls-visual-report>).

Manual mode — you supply the data

Property Type Default Description
isLoading boolean true Show skeleton loaders while data is in flight
imageUrl string | null null URL of the scanned image
reportData ImageDetectionResponseResult | null null Scan result object returned by the Copyleaks API
displayName string | null null Optional display name shown in the report
<!-- Loading skeleton -->
<copyleaks-visual-report [isLoading]="true"></copyleaks-visual-report>

<!-- Data ready -->
<copyleaks-visual-report
  [isLoading]="false"
  [imageUrl]="imageUrl"
  [reportData]="reportData">
</copyleaks-visual-report>

Endpoint mode — the component fetches the data

When reportEndpointConfig is set, the component manages all HTTP requests internally. The manual isLoading, imageUrl, and reportData inputs are ignored.

Property Type Description
reportEndpointConfig IVisualReportEndpointConfig Endpoint URLs (and optional headers) the component uses to load the report

Common properties (any mode)

Property Type Default Description
injectFonts boolean true Auto-inject the Lato + Material Symbols Google Font stylesheets into <head>. Set to false to bring your own font setup

The component depends on the Lato and Material Symbols Rounded fonts. By default it injects the Google Fonts stylesheets the first time it renders (de-duplicated across instances and frameworks). If your host page already loads these fonts, blocks third-party requests via CSP, or wants to self-host them, disable auto-injection:

<!-- HTML / property-set -->
<copyleaks-visual-report inject-fonts="false"></copyleaks-visual-report>
// Angular / property-set via ref
el.injectFonts = false;

When auto-injection is off, you must provide both font families yourself. Otherwise icons render as text and headings fall back to a system font.

const config: IVisualReportEndpointConfig = {
  imageUrl: 'https://api.example.com/scan/123/image',    // optional: direct image URL
  imageUrlEndpoint: 'https://api.example.com/scan/123/image-url', // optional: endpoint returning { url: string }
  resultsUrl: 'https://api.example.com/scan/123/results', // required
  progressUrl: 'https://api.example.com/scan/123/progress', // optional: polls until percents === 100
  headers: { Authorization: 'Bearer <token>' },           // optional: applied to all requests
};

When progressUrl is provided, the component:

  1. Polls progressUrl every 2 seconds until percents reaches 100
  2. Fetches resultsUrl to get the detection result
  3. Sets imageUrl from imageUrlEndpoint response if provided, otherwise uses the static imageUrl

Events

Event name event.detail type Emitted when
viewTypeChange ViewType The user switches the overlay view type (standard / gradual / hidden)
onReportRequestError IVisualReportRequestError An HTTP request made by the component fails
loadingChange boolean The internal loading state changes (e.g. fetch starts or completes)

Listening in vanilla JS / React / Vue:

report.addEventListener('viewTypeChange', (e) => {
  // e.detail is 'standard' | 'gradual' | 'hidden'
  console.log(e.detail);
});

report.addEventListener('onReportRequestError', (e) => {
  // e.detail: { statusCode: number; error?: { code?: number; message?: string } }
  console.error(e.detail);
});

report.addEventListener('loadingChange', (e) => {
  // e.detail: boolean
  console.log('Loading:', e.detail);
});

CSS Custom Properties

The component renders with ViewEncapsulation.None at its root, so you can target it directly with standard CSS. There are no CSS custom properties (--var) exposed at this time — size the component via its host element:

copyleaks-visual-report {
  display: flex;
  width: 100%;
  height: 600px; /* or flex: 1; inside a flex parent */
}

Data Models

ImageDetectionResponseResult

The object you pass to reportData (or that the component receives from resultsUrl):

interface ImageDetectionResponseResult {
  model: string;               // Detection model name, e.g. "Ultra"
  result: {
    starts: number[];          // RLE span start positions
    lengths: number[];         // RLE span lengths
    labels?: number[];         // Per-span label (gradual mask mode)
  };
  summary: {
    human: number;             // 0.0–1.0  fraction of human pixels
    ai: number;                // 0.0–1.0  fraction of AI pixels
  };
  imageInfo: {
    shape: {
      height: number;
      width: number;
      size: string;            // e.g. "2.4 MB"
    };
    metadata?: {
      issuedTime?: string;     // ISO 8601 date string
      issuedBy?: string;
      appOrDeviceUsed?: string;
      contentSummary?: string;
    };
  };
  scannedDocument: {
    documentName: string;
    scannedTime: string;       // ISO 8601 date string
  };
  error?: {
    type: string;
    id: string;
    code: number;
    message: string;
  };
}

IVisualReportEndpointConfig

interface IVisualReportEndpointConfig {
  imageUrl?: string;              // Direct URL to the scanned image
  imageUrlEndpoint?: string;      // Endpoint returning { url: string } for the image
  resultsUrl: string;             // Endpoint returning ImageDetectionResponseResult JSON
  progressUrl?: string;           // Polling endpoint returning { percents: number; status: string }
  headers?: Record<string, string>; // Custom request headers (e.g. Authorization)
}

IVisualReportRequestError

Payload of the onReportRequestError event:

interface IVisualReportRequestError {
  statusCode: number;              // HTTP status code (0 = network error)
  error?: {
    code?: number;
    message?: string;
  };
}

ViewType

Payload of the viewTypeChange event:

type ViewType = 'standard' | 'gradual' | 'hidden';
Value Description
standard Binary mask — green for human pixels, red for AI
gradual Heatmap — orange gradient showing AI intensity
hidden Original image with no overlay

Building from Source

pnpm install

# Watch-build the library and serve the sandbox at localhost:4300
pnpm dev

# Build the Angular library only (dist/ngx-copyleaks-visual-report/)
pnpm build:lib

# Build the production web component bundle (single JS file)
pnpm build:wc

# Build the dev (unminified) web component bundle
pnpm build:wc:dev

# Watch-build the library only (no sandbox server)
pnpm build:watch

Output files:

Path Contents
dist/ngx-copyleaks-visual-report/ Angular library (ng-packagr output)
dist/copyleaks-visual-report-wc/browser/copyleaks-visual-report.js Web component bundle (publish this)

License

MIT © Copyleaks

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors