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 lang="ts">
  import {
    Panel,
    PDFViewer,
    ToolbarItem,
    type Viewer,
    type ViewerConfig,
  } from '@cloudpdf/viewer-svelte';

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

  let status = $state<Status>('review');
  let title = $state('');
  let author = $state('');
  let done = $state<string[]>([]);

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

<PDFViewer
  src={proposal}
  {...config}
  appState={{ done, status, setStatus }}
  {onready}
  style="height: 100%"
>
  <ToolbarItem id="title">
    <div class="review-title">
      <strong>{title}</strong>
      {#if author}<span>From {author}</span>{/if}
    </div>
  </ToolbarItem>
  <ToolbarItem id="status">
    <span class="review-status" data-status={status}>{STATUS[status]}</span>
  </ToolbarItem>
  <Panel id="checklist">
    <div class="review-checklist">
      {#each CHECKS as check (check)}
        <label class="review-check">
          <input type="checkbox" value={check} bind:group={done} />
          {check}
        </label>
      {/each}
      <p class="review-count">{done.length} of {CHECKS.length} done</p>
    </div>
  </Panel>
</PDFViewer>

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#