EmbedPDF

Contract review

For a legal or sales team reviewing a document someone sent them. The viewer gets an app bar of its own with the document’s name, the review status and the verdict; the review tools sit on a rail, the notes stay docked on the right, and a checklist of its own opens beside the rail. Approve waits for the checklist: it stays greyed out until every item is ticked. Its colors, font and words are the review’s.

import { Component, computed, signal, ViewEncapsulation } from '@angular/core';
import {
  EpdfPanel,
  EpdfPdfViewer,
  EpdfToolbarItem,
  type Viewer,
  type ViewerConfig,
} from '@embedpdf/viewer-angular';

const proposal = '/pdfs/proposal.pdf';

type Status = 'review' | 'approved' | 'changes';

// What the commands read from the app: the ticked checks, the status and how to set it.
interface Review {
  done: string[];
  status: Status;
  setStatus(status: Status): void;
}

const STATUS: Record<Status, string> = {
  review: 'In review',
  approved: 'Approved',
  changes: 'Changes requested',
};

// What the reviewer checks before a verdict, in the Checklist panel.
const CHECKS = [
  'The problem is stated clearly',
  'The phases and weeks add up',
  'Each open question has an owner',
  'Every review note has an answer',
];

// The review's look, words, tools, verdict and layout. A constant: the commands read the app
// from `appState`, so they never hold an old value.
const config: ViewerConfig<Review> = {
  theme: {
    preference: 'light',
    accent: '#4f46e5',
    tokens: {
      background: '#eef0f4',
      surface: '#ffffff',
      'surface-raised': '#ffffff',
      text: '#161a23',
      'text-muted': '#5d6474',
      border: '#dde1e8',
      hover: '#f0f2f6',
      canvas: '#363c4a',
      radius: '8px',
      font: '"Avenir Next", Avenir, "Segoe UI", system-ui, sans-serif',
    },
  },
  icons: {
    approve: [
      'M14 3v4a1 1 0 0 0 1 1h4',
      'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z',
      'M9 15l2 2l4 -4',
    ],
    requestChanges: [
      'M14 3v4a1 1 0 0 0 1 1h4',
      'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z',
      'M10 18l5 -5a1.414 1.414 0 0 0 -2 -2l-5 5v2h2z',
    ],
    checklist: [
      'M3.5 5.5l1.5 1.5l2.5 -2.5',
      'M3.5 11.5l1.5 1.5l2.5 -2.5',
      'M3.5 17.5l1.5 1.5l2.5 -2.5',
      'M11 6l9 0',
      'M11 12l9 0',
      'M11 18l9 0',
    ],
  },
  // The words of a review.
  strings: {
    en: {
      // The comments panel's button and its header.
      'panel:comments': 'Review notes',
      'tool:note': 'Review note',
      'annotation:comment': 'Review note',
      'tool:strikeout': 'Suggest a deletion',
      'tool:insert-text': 'Suggest an insertion',
      'tool:replace-text': 'Suggest a replacement',
    },
  },
  // What a review doesn't need.
  disable: [
    'shapes',
    'stamps',
    'signatures',
    'forms',
    'redaction',
    'measurement',
    'attachments',
    'pages',
  ],
  // Deletions in red, suggestions in the review's own color.
  plugins: {
    annotation: {
      tools: [
        { id: 'highlight', defaults: { color: '#ffcd45' } },
        { id: 'underline', defaults: { color: '#30a46c' } },
        { id: 'strikeout', defaults: { color: '#e5484d' } },
        { id: 'insert-text', defaults: { color: '#4f46e5' } },
        { id: 'replace-text', defaults: { color: '#4f46e5' } },
      ],
    },
  },
  commands: [
    {
      id: 'acme:approve',
      label: 'Approve',
      icon: 'approve',
      shortcut: 'Mod+Enter',
      // Greyed out until every check is ticked, and pressed once approved.
      enabled: ({ appState }) => appState.done.length === CHECKS.length,
      active: ({ appState }) => appState.status === 'approved',
      run: ({ appState }) => appState.setStatus('approved'),
    },
    {
      id: 'acme:request-changes',
      label: 'Request changes',
      icon: 'requestChanges',
      // The status, then the note tool, to say what should change.
      run: ({ appState, viewer }) => {
        appState.setStatus('changes');
        void viewer.commands.execute('tool:note');
      },
    },
  ],
  layout: (layout, env) =>
    layout
      // Your own bars take the place of the toolbar, and the mode bars along it go with it.
      .removeBar('main')
      // The review bar: the document and its status, then the verdict.
      .addBar('review', {
        place: 'top',
        start: [
          env.size === 'compact'
            ? [{ custom: 'status' }]
            : [{ custom: 'title' }, { custom: 'status' }],
        ],
        end: [
          { items: ['document:print', 'document:download'], importance: 2 },
          ['panel:comments'],
          {
            items: [
              // Short of room, Request changes gives up its label first: Approve keeps its own.
              {
                command: 'acme:request-changes',
                variants: ['icon+label', 'icon'],
                importance: 4,
              },
              { command: 'acme:approve', emphasis: 'primary', variants: ['icon+label', 'icon'] },
            ],
            importance: 5,
          },
        ],
      })
      // The review tools on a rail, and the panels on its side at its end.
      .addBar('tools', {
        place: env.size === 'compact' ? 'bottom' : 'start',
        start: [
          ['tool:pointer', 'tool:pan'],
          ['tool:note', 'tool:highlight', 'tool:underline', 'tool:strikeout'],
          ['tool:insert-text', 'tool:replace-text'],
          ['history:undo', 'history:redo'],
        ],
        end: [['panel:checklist', 'panel:thumbnails', 'panel:search']],
      })
      // Zoom joins the page number, floating over the pages.
      .add({ custom: 'zoom' }, { to: 'pager', section: 'center' })
      .addPanel('checklist', { side: 'start', title: 'Checklist', icon: 'checklist' })
      // The right side is the review notes': search and style open on the left.
      .panel('search', { side: 'start' })
      .panel('style', { side: 'start' })
      .panels({ present: { medium: 'dock' } })
      // The review notes are open from the start, and stay open where they dock.
      .panel('comments', { open: true, closable: false, width: 340 }),
};

@Component({
  selector: 'demo-root',
  imports: [EpdfPdfViewer, EpdfToolbarItem, EpdfPanel],
  styleUrl: './review.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <epdf-pdf-viewer
      [src]="proposal"
      [config]="config"
      [appState]="appState()"
      style="height: 100%"
      (ready)="onReady($event)"
    >
      <ng-template epdfToolbarItem="title">
        <div class="review-title">
          <strong>{{ title() }}</strong>
          @if (author()) {
            <span>From {{ author() }}</span>
          }
        </div>
      </ng-template>
      <ng-template epdfToolbarItem="status">
        <span class="review-status" [attr.data-status]="status()">{{ labels[status()] }}</span>
      </ng-template>
      <ng-template epdfPanel="checklist">
        <div class="review-checklist">
          @for (check of checks; track check) {
            <label class="review-check">
              <input type="checkbox" [checked]="done().includes(check)" (change)="toggle(check)" />
              {{ check }}
            </label>
          }
          <p class="review-count">{{ done().length }} of {{ checks.length }} done</p>
        </div>
      </ng-template>
    </epdf-pdf-viewer>
  `,
})
export class App {
  readonly proposal = proposal;
  readonly config = config;
  readonly labels = STATUS;
  readonly checks = CHECKS;
  readonly status = signal<Status>('review');
  readonly title = signal('');
  readonly author = signal('');
  readonly done = signal<string[]>([]);
  readonly appState = computed<Review>(() => ({
    done: this.done(),
    status: this.status(),
    setStatus: (status) => this.status.set(status),
  }));

  onReady(viewer: Viewer) {
    viewer.documents.onOpened(async ({ documentId }) => {
      // The title and the author come from the PDF itself.
      const metadata = viewer.forDocument(documentId).get('metadata');
      await metadata.ensureLoaded();
      const fields = metadata.getSnapshot();
      this.title.set(fields?.title ?? 'Untitled');
      this.author.set(fields?.author ?? '');
    });
  }

  toggle(check: string) {
    this.done.update((done) =>
      done.includes(check) ? done.filter((item) => item !== check) : [...done, check],
    );
  }
}

Try it: read the thread on Dana Smith’s note next to the title in Review notes. Approve is greyed out until the review is done: open the Checklist at the bottom of the rail and tick all four items. Then Approve turns the status next to the document’s name green and stays pressed; Request changes turns it red and picks the note tool, so your next click on the page leaves a note.

What it uses#

Was this page helpful?

Your feedback goes directly to the documentation team.