CloudPDF
DocsPricing
Start building

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.

<script setup lang="ts">
import { computed, ref } from 'vue';
import {
  Panel,
  PDFViewer,
  ToolbarItem,
  type Viewer,
  type ViewerConfig,
} from '@cloudpdf/viewer-vue';

import './review.css';

const proposal = { grant: 'shr_qnqHtCNRpzz-s7DPFBIbuOAF' };

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. 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 }),
};

const status = ref<Status>('review');
const title = ref('');
const author = ref('');
const done = ref<string[]>([]);
const appState = computed<Review>(() => ({
  done: done.value,
  status: status.value,
  setStatus: (value) => (status.value = value),
}));

function 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();
    title.value = fields?.title ?? 'Untitled';
    author.value = fields?.author ?? '';
  });
}
</script>

<template>
  <PDFViewer
    :src="proposal"
    v-bind="config"
    :app-state="appState"
    style="height: 100%"
    @ready="onReady"
  >
    <ToolbarItem id="title">
      <div class="review-title">
        <strong>{{ title }}</strong>
        <span v-if="author">From {{ author }}</span>
      </div>
    </ToolbarItem>
    <ToolbarItem id="status">
      <span class="review-status" :data-status="status">{{ STATUS[status] }}</span>
    </ToolbarItem>
    <Panel id="checklist">
      <div class="review-checklist">
        <label v-for="check in CHECKS" :key="check" class="review-check">
          <input v-model="done" type="checkbox" :value="check" />
          {{ check }}
        </label>
        <p class="review-count">{{ done.length }} of {{ CHECKS.length }} done</p>
      </div>
    </Panel>
  </PDFViewer>
</template>

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#