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.

<link rel="stylesheet" href="./review.css" />

<embedpdf-viewer id="viewer" style="height: 100%">
  <div slot="item:title" class="review-title">
    <strong id="title"></strong>
    <span id="author"></span>
  </div>
  <span slot="item:status" id="status" class="review-status" data-status="review">In review</span>
  <div slot="panel:checklist" id="checklist" class="review-checklist">
    <p id="count" class="review-count">0 of 4 done</p>
  </div>
</embedpdf-viewer>

<script type="module">
  import 'https://cdn.jsdelivr.net/npm/@embedpdf/viewer@3/dist/embedpdf.js';

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

  const STATUS = {
    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',
  ];

  // What the commands read from the app: the ticked checks, the status and how to set it.
  let review = {
    done: [],
    status: 'review',
    setStatus: (status) => update({ status }),
  };

  // Each change is a new value: the viewer's buttons ask their commands again.
  function update(changes) {
    review = { ...review, ...changes };
    document.getElementById('viewer').appState = review;
    const status = document.getElementById('status');
    status.dataset.status = review.status;
    status.textContent = STATUS[review.status];
    document.getElementById('count').textContent = `${review.done.length} of ${CHECKS.length} done`;
  }

  // The checklist: ticking an item gives `done` a new value.
  const checklist = document.getElementById('checklist');
  for (const check of CHECKS) {
    const box = Object.assign(document.createElement('input'), { type: 'checkbox', value: check });
    const label = Object.assign(document.createElement('label'), { className: 'review-check' });
    label.append(box, check);
    document.getElementById('count').before(label);
  }
  checklist.addEventListener('change', () => {
    const ticked = checklist.querySelectorAll('input:checked');
    update({ done: Array.from(ticked, (box) => box.value) });
  });

  document.getElementById('viewer').config = {
    src: proposal,
    appState: review,
    // The review's look, words, tools, verdict and layout.
    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 }),
    onReady: (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();
        document.getElementById('title').textContent = fields?.title ?? 'Untitled';
        document.getElementById('author').textContent = fields?.author
          ? `From ${fields.author}`
          : '';
      }),
  };
</script>

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.