EmbedPDF

Panels & dialogs

A viewer has sidebars (thumbnails, comments, search), dialogs and menus. The shell plugin keeps track of which are open, so a toolbar button, a keyboard shortcut and your own code can all open the same panel, and you can remember the layout for next time. You draw the panels; the plugin only holds their state. Register shellPlugin():

shell-plugin.tsx
import { shellPlugin } from '@embedpdf/react/shell';

export const plugins = [/* … */ shellPlugin()];

Then ask for a panel by name:

comments-panel.tsx
import { useSurface } from '@embedpdf/react/shell';
import { CommentList } from './comment-list';

export function CommentsPanel() {
  const panel = useSurface('comments');
  if (!panel.isOpen) return null;

  return (
    <aside>
      <button onClick={panel.close}>Close</button>
      <CommentList />
    </aside>
  );
}

export function CommentsButton() {
  const panel = useSurface('comments');
  return <button onClick={() => panel.toggle({ exclusive: 'right' })}>Comments</button>;
}

The names are yours: 'comments', 'thumbnails', 'settings'. Each document keeps its own, so every tab remembers which panels were open.

One panel per side#

Most layouts show one panel per side. Give panels a group with exclusive, and opening one closes the others in that group:

shell.open('thumbnails', { exclusive: 'left' });
shell.open('outline', { exclusive: 'left' }); // closes thumbnails
shell.open('comments', { exclusive: 'right' }); // the left panel stays

Loading live preview…

import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { SearchLayer, searchPlugin, useSearch, useSearchHits } from '@embedpdf/react/search';
import { shellPlugin, useShell, useSurface } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';

import './sidebars.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin(), shellPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// Both panels share the right side: opening one closes the other.
function PanelButton({ id, label }: { id: string; label: string }) {
  const panel = useSurface(id);
  return (
    <button
      type="button"
      className="button"
      aria-pressed={panel.isOpen}
      onClick={() => panel.toggle({ exclusive: 'right' })}
    >
      {label}
    </button>
  );
}

function SearchPanel() {
  const panel = useSurface('search');
  const search = useSearch();
  const hits = useSearchHits();
  const [text, setText] = useState('PDF');

  useEffect(() => {
    void search.search({ text });
  }, [search, text]);

  if (!panel.isOpen) return null;
  return (
    <aside className="panel" aria-label="Search">
      <header className="panel-header">
        <h3 className="panel-title">Search</h3>
        <button type="button" className="close" aria-label="Close" onClick={panel.close}>
          ×
        </button>
      </header>
      <input
        className="field"
        type="search"
        aria-label="Search"
        value={text}
        onChange={(event) => setText(event.target.value)}
      />
      <ol className="list">
        {hits.map((hit) => (
          <li key={`${hit.page.objectNumber}:${hit.start}`}>
            <button type="button" className="item" onClick={() => search.goToHit(hit)}>
              <span className="item-page">Page {hit.pageIndex + 1}</span>
              {hit.snippet && (
                <span className="item-text">
                  …{hit.snippet.before}
                  <mark>{hit.snippet.match}</mark>
                  {hit.snippet.after}…
                </span>
              )}
            </button>
          </li>
        ))}
      </ol>
    </aside>
  );
}

function NotesPanel({ notes, onChange }: { notes: string; onChange: (notes: string) => void }) {
  const panel = useSurface('notes');
  if (!panel.isOpen) return null;
  return (
    <aside className="panel" aria-label="Notes">
      <header className="panel-header">
        <h3 className="panel-title">Notes</h3>
        <button type="button" className="close" aria-label="Close" onClick={panel.close}>
          ×
        </button>
      </header>
      <textarea
        className="field notes"
        aria-label="Notes"
        placeholder="Your notes on this document…"
        value={notes}
        onChange={(event) => onChange(event.target.value)}
      />
    </aside>
  );
}

function Workspace() {
  const shell = useShell();
  // Your app's own data: it stays when the panel closes.
  const [notes, setNotes] = useState('');

  // The search panel is open when the document is.
  useEffect(() => {
    shell.open('search', { exclusive: 'right' });
  }, [shell]);

  return (
    <>
      <div className="toolbar">
        <PanelButton id="search" label="Search" />
        <PanelButton id="notes" label="Notes" />
      </div>
      <div className="workspace">
        <Stage className="stage">
          {() => (
            <>
              <RenderLayer />
              <SearchLayer />
            </>
          )}
        </Stage>
        <SearchPanel />
        <NotesPanel notes={notes} onChange={setNotes} />
      </div>
    </>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Workspace />
      </DocumentGate>
    </Viewer>
  );
}

Open a panel with something in it#

A panel can carry data, such as which comment to scroll to:

shell.open('comments', { exclusive: 'right', props: { focus: thread.root.ref } });

The panel reads them:

panel-props.tsx
import { useSurface } from '@embedpdf/react/shell';
import { CommentList } from './comment-list';

export function CommentsPanel() {
  const { props } = useSurface('comments'); // { focus: … }
  return <CommentList focus={props.focus} />;
}

shell.updateSurfaceProps('comments', { focus: nextThread.root.ref }) changes them without reopening the panel.

Loading live preview…

import { useEffect } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { SearchLayer, searchPlugin, useSearch, useSearchHits } from '@embedpdf/react/search';
import { shellPlugin, useShell, useSurface } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';

import './props.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin(), shellPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// The panel shows the match its props name, and steps to the next one without reopening.
function MatchPanel() {
  const panel = useSurface('match');
  const shell = useShell();
  const search = useSearch();
  const hits = useSearchHits();
  const index = typeof panel.props.index === 'number' ? panel.props.index : 0;
  const hit = hits[index];

  if (!panel.isOpen) return null;

  const show = (next: number) => {
    shell.updateSurfaceProps('match', { index: next });
    search.goToHit(hits[next]);
  };

  return (
    <aside className="panel" aria-label="Match">
      <header className="panel-header">
        <h3 className="panel-title">
          Match {index + 1} of {hits.length}
        </h3>
        <button type="button" className="close" aria-label="Close" onClick={panel.close}>
          ×
        </button>
      </header>
      {hit ? (
        <>
          <p className="page">Page {hit.pageIndex + 1}</p>
          {hit.snippet && (
            <p className="snippet">
              …{hit.snippet.before}
              <mark>{hit.snippet.match}</mark>
              {hit.snippet.after}…
            </p>
          )}
        </>
      ) : (
        <p className="page">Searching…</p>
      )}
      <div className="steps">
        <button
          type="button"
          className="button"
          disabled={index === 0}
          onClick={() => show(index - 1)}
        >
          ← Previous
        </button>
        <button
          type="button"
          className="button"
          disabled={index >= hits.length - 1}
          onClick={() => show(index + 1)}
        >
          Next →
        </button>
      </div>
    </aside>
  );
}

function Workspace() {
  const shell = useShell();
  const search = useSearch();
  const hits = useSearchHits();

  // Every "PDF" on the pages, and the first one open in the panel.
  useEffect(() => {
    void search.search({ text: 'PDF' });
    shell.open('match', { exclusive: 'right', props: { index: 0 } });
  }, [search, shell]);

  return (
    <div className="workspace">
      <Stage className="stage">
        {() => (
          <>
            <RenderLayer />
            {/* A click on a match opens it in the panel. */}
            <SearchLayer
              onHitClick={(hit) =>
                shell.open('match', { exclusive: 'right', props: { index: hits.indexOf(hit) } })
              }
            />
          </>
        )}
      </Stage>
      <MatchPanel />
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Workspace />
      </DocumentGate>
    </Viewer>
  );
}

Put the cursor in a panel#

A panel you type in, such as search, can take the cursor as it opens. Ask for it when you open the panel:

shell.open('search', { focus: true });

and say which element takes it, with useSurfaceFocus(id, ref):

panel-focus.tsx
import { useRef } from 'react';
import { useSurfaceFocus } from '@embedpdf/react/shell';

export function SearchPanel() {
  const field = useRef<HTMLInputElement>(null);
  // The cursor goes in the field when the panel asks for it, its text selected.
  useSurfaceFocus('search', field, { select: true });
  return <input ref={field} placeholder="Search" />;
}

The element takes focus on every open that asks, also when the panel is open already (pressing a Find key again), and when it appears while the request is up, as a panel whose code loads on its first open does. A plain open(id) leaves the cursor where it is. select: true selects the field’s text, so typing replaces it.

The request is the panel’s focusRequest in State: how many opens asked since the panel opened, back to 0 when it closes. Snapshots leave it out.

From commands#

A command can open a panel with no code: give it panel, and its button toggles the panel and shows as pressed while it’s open:

{ id: 'panel:comments', label: 'Comments', icon: 'comment', panel: { id: 'comments', exclusive: 'right' } }

With focus: true in panel, opening it asks for the cursor, as above. modal does the same for a dialog, and menu for a menu.

Menus are tracked separately, since several can be open at once, such as a menu and its submenu:

shell.openMenu('more');
shell.closeAllMenus(); // for a click outside, or Escape

openMenus in State lists the open menus, the last one opened on top.

Loading live preview…

import { useEffect, useRef } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  interactionPlugin,
  useInteraction,
  useInteractionState,
} from '@embedpdf/react/interaction';
import { SelectionLayer, selectionPlugin } from '@embedpdf/react/selection';
import { shellPlugin, useShell, useShellState } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';

import './menus.css';

const engine = localEngine();
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  selectionPlugin(),
  shellPlugin(),
];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

const TOOLS = [
  { id: 'pointer', label: 'Select text' },
  { id: 'pan', label: 'Scroll with the hand' },
];

// A menu with a submenu: opening the submenu leaves its menu open.
function ViewMenu() {
  const shell = useShell();
  const interaction = useInteraction();
  const { openMenus } = useShellState();
  const { activeToolId } = useInteractionState();
  const bar = useRef<HTMLDivElement>(null);
  const anyOpen = openMenus.length > 0;

  // A press outside the menus, or Escape, closes all of them.
  useEffect(() => {
    if (!anyOpen) return;
    const outside = (event: PointerEvent) => {
      if (!bar.current?.contains(event.target as Node)) shell.closeAllMenus();
    };
    const escape = (event: KeyboardEvent) => {
      if (event.key === 'Escape') shell.closeAllMenus();
    };
    document.addEventListener('pointerdown', outside, true);
    document.addEventListener('keydown', escape);
    return () => {
      document.removeEventListener('pointerdown', outside, true);
      document.removeEventListener('keydown', escape);
    };
  }, [shell, anyOpen]);

  return (
    <div className="toolbar">
      <div className="menubar" ref={bar}>
        <button
          type="button"
          className="button"
          aria-haspopup="menu"
          aria-expanded={openMenus.includes('view')}
          onClick={() => shell.toggleMenu('view')}
        >
          View ▾
        </button>
        {openMenus.includes('view') && (
          <div className="menu" role="menu">
            <button
              type="button"
              role="menuitem"
              className="menu-item"
              aria-haspopup="menu"
              aria-expanded={openMenus.includes('view-tool')}
              onClick={() => shell.toggleMenu('view-tool')}
            >
              Tool <span aria-hidden="true">▸</span>
            </button>
            <button
              type="button"
              role="menuitem"
              className="menu-item"
              onClick={() => shell.closeAllMenus()}
            >
              Close menus
            </button>
            {openMenus.includes('view-tool') && (
              <div className="menu submenu" role="menu">
                {TOOLS.map((tool) => (
                  <button
                    key={tool.id}
                    type="button"
                    role="menuitemradio"
                    aria-checked={tool.id === activeToolId}
                    className="menu-item"
                    onClick={() => {
                      interaction.activateTool(tool.id);
                      shell.closeAllMenus();
                    }}
                  >
                    {tool.label}
                  </button>
                ))}
              </div>
            )}
          </div>
        )}
      </div>
      <output className="readout">Open menus: {anyOpen ? openMenus.join(' › ') : 'none'}</output>
    </div>
  );
}

function Workspace() {
  const shell = useShell();

  // The menu and its submenu are open when the document is.
  useEffect(() => {
    shell.openMenu('view');
    shell.openMenu('view-tool');
  }, [shell]);

  return (
    <>
      <ViewMenu />
      <Stage className="stage">
        {() => (
          <>
            <RenderLayer />
            <SelectionLayer />
          </>
        )}
      </Stage>
    </>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Workspace />
      </DocumentGate>
    </Viewer>
  );
}

Remember the layout#

localStorage.setItem('panels', JSON.stringify(shell.getSnapshot()));
 
// next time
shell.applySnapshot(JSON.parse(localStorage.getItem('panels')!));

Loading live preview…

import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { shellPlugin, useShell, useShellState, useSurface } from '@embedpdf/react/shell';
import type { ShellSnapshot } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';

import './remember.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), shellPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// The browser may refuse storage, in a private window: then nothing is remembered.
const loadLayout = (): ShellSnapshot | null => {
  try {
    return JSON.parse(localStorage.getItem('panels') ?? 'null') as ShellSnapshot | null;
  } catch {
    return null;
  }
};
const saveLayout = (snapshot: ShellSnapshot) => {
  try {
    localStorage.setItem('panels', JSON.stringify(snapshot));
  } catch {
    // Not remembered this time.
  }
};

const TIPS = [
  'Ctrl or ⌘ and scroll to zoom',
  'Pinch to zoom on a touch screen',
  'Drag between pages to scroll',
];

function PanelButton({ id, label, side }: { id: string; label: string; side: 'left' | 'right' }) {
  const panel = useSurface(id);
  return (
    <button
      type="button"
      className="button"
      aria-pressed={panel.isOpen}
      onClick={() => panel.toggle({ exclusive: side })}
    >
      {label}
    </button>
  );
}

function TipsPanel() {
  const panel = useSurface('tips');
  if (!panel.isOpen) return null;
  return (
    <aside className="panel" aria-label="Tips">
      <h3 className="panel-title">Tips</h3>
      <ul className="tips">
        {TIPS.map((tip) => (
          <li key={tip}>{tip}</li>
        ))}
      </ul>
    </aside>
  );
}

function NotesPanel() {
  const panel = useSurface('notes');
  if (!panel.isOpen) return null;
  return (
    <aside className="panel" aria-label="Notes">
      <h3 className="panel-title">Notes</h3>
      <textarea className="notes" aria-label="Notes" placeholder="Your notes on this document…" />
    </aside>
  );
}

function Workspace() {
  const shell = useShell();
  const { openSurfaces } = useShellState();
  const [saved, setSaved] = useState(loadLayout);

  // The layout saved last time, or the tips the first time.
  useEffect(() => {
    const layout = loadLayout();
    if (layout) shell.applySnapshot(layout);
    else shell.open('tips', { exclusive: 'left' });
  }, [shell]);

  const save = () => {
    const layout = shell.getSnapshot();
    saveLayout(layout);
    setSaved(layout);
  };

  const open = openSurfaces.map((surface) => surface.id).join(', ');

  return (
    <>
      <div className="toolbar">
        <PanelButton id="tips" label="Tips" side="left" />
        <PanelButton id="notes" label="Notes" side="right" />
        <span className="spacer" />
        <button type="button" className="button" onClick={save}>
          Save layout
        </button>
        <button
          type="button"
          className="button"
          disabled={!saved}
          onClick={() => saved && shell.applySnapshot(saved)}
        >
          Restore
        </button>
      </div>
      <p className="readout">Open: {open || 'no panels'}</p>
      <div className="workspace">
        <TipsPanel />
        <Stage className="stage">{() => <RenderLayer />}</Stage>
        <NotesPanel />
      </div>
    </>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Workspace />
      </DocumentGate>
    </Viewer>
  );
}

Methods#

Everything useShell() gives you.

MethodWhat it does
open(id, { exclusive?, props?, focus? })Opens a panel or dialog. exclusive closes the others in that group, props travels with it, and focus asks for the cursor, also when it’s open. One panel per side, Put the cursor in a panel
toggle(id, { exclusive?, props?, focus? })Opens a closed panel, with the options open takes, or closes an open one
close(id), closeAll()Closes one panel, or every panel
updateSurfaceProps(id, props)Changes a panel’s props without reopening it. Open a panel with something in it
openMenu(id), closeMenu(id), toggleMenu(id)Opens, closes or toggles one menu. Opening a menu leaves the others open, so a submenu stays above its menu. Menus
closeAllMenus()Closes every menu, for a click outside or Escape
getSnapshot(), applySnapshot(snapshot)Everything the plugin holds, as plain data you can store; and back. Remember the layout
In a component, useSurface(id) gives you one panel: { isOpen, props, open, close, toggle }.

State#

Everything useShellState() gives you. Your component re-renders when a value changes. The method in the last column reads the same value once.

FieldWhat it isRead once
openSurfacesThe open panels and dialogs, each with its id, exclusive group, props and focusRequestlistOpenSurfaces(), or isOpen(id) and getSurface(id) for one
openMenusThe open menus’ ids, the last one opened on toplistOpenMenus(), or isMenuOpen(id) for one

Events#

panel-events.tsx
import { useShellEvent } from '@embedpdf/react/shell';
import { analytics } from './analytics';

export function PanelAnalytics() {
  useShellEvent(
    (shell) => shell.onSurfaceOpened,
    ({ id }) => analytics.track('panel', { id }),
  );
  return null;
}
EventFires whenCarries
onSurfaceOpenedA panel or dialog openedid, props
onSurfaceClosedA panel or dialog closedid
onMenuOpenedA menu openedid
onMenuClosedA menu closedid

Good to know#

No document yet#

The panel hooks work before a document is open: every panel reads as closed, and opening one does nothing until there is a document. So a toolbar outside <DocumentGate> can use them.

Was this page helpful?

Your feedback goes directly to the documentation team.