Rich text
A text box (a free text annotation) can mix styles: a bold word, a red sentence, a bigger heading.
Double-click the box below, select a word, and use the buttons, or press Ctrl/Cmd with B, I
or U while you type:
Loading live preview…
import { useEffect, useRef } from 'react';
import { Viewer, DocumentGate, usePageList } 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 } from '@embedpdf/react/interaction';
import {
AnnotationLayer,
annotationPlugin,
useAnnotation,
useAnnotationProperties,
useAnnotationState,
} from '@embedpdf/react/annotation';
import { cloudEngine } from '@cloudpdf/engine';
import './rich-text.css';
const engine = cloudEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];
const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };
// On load: a text box born with formatting, selected. Runs change the body only where they differ.
function AddTextBox() {
const annotation = useAnnotation();
const ready = useAnnotationState((state) => state.status === 'ready');
const cover = usePageList()[0]?.ref;
const added = useRef(false);
useEffect(() => {
if (!ready || !cover || added.current) return;
added.current = true;
void annotation.create(
cover,
{
subtype: 'free-text',
box: { x: 106, y: 570, width: 380, height: 60 },
interiorColor: '#fffbe6',
richText: {
body: { family: 'Helvetica', size: 16, color: '#1a2748' },
paragraphs: [
{
runs: [
{ text: 'Double-click me, select a word, then make it ' },
{ text: 'bold', style: { weight: 700 } },
{ text: ' or ' },
{ text: 'red', style: { color: '#c00000' } },
{ text: '.' },
],
},
],
},
},
undefined,
{ select: true },
);
}, [annotation, ready, cover]);
return null;
}
type Format = 'bold' | 'italic' | 'underline';
// The same calls style the selected words while typing, and the whole box otherwise.
function RichTextToolbar() {
const annotation = useAnnotation();
const { properties, values, mixed } = useAnnotationProperties();
const hasText = properties.some((property) => property.control === 'textFormat');
// On when every selected word has it; `mixed` lists what the words disagree on.
const isOn = (format: Format) => values[format] === true && !mixed.includes(format);
return (
<div className="toolbar">
{(['bold', 'italic', 'underline'] as const).map((format) => (
<button
key={format}
type="button"
className={`button ${format}`}
aria-pressed={isOn(format)}
disabled={!hasText}
onClick={() => annotation.text.toggleFormat(format)}
>
{format[0]!.toUpperCase()}
</button>
))}
<button
type="button"
className="button"
disabled={!hasText}
onClick={() => annotation.selection.update({ fontColor: '#c00000' })}
>
Red
</button>
<button
type="button"
className="button"
disabled={!hasText}
onClick={() => annotation.selection.update({ fontSize: values.fontSize === 24 ? 16 : 24 })}
>
{values.fontSize === 24 ? '16 pt' : '24 pt'}
</button>
<p className="hint">Ctrl or Cmd with B, I or U works while you type</p>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<AddTextBox />
<RichTextToolbar />
<Stage className="stage">
{() => (
<>
<RenderLayer />
<AnnotationLayer />
</>
)}
</Stage>
</DocumentGate>
</Viewer>
);
}How it’s stored#
A text box has a body style, which is how its text looks by default, and paragraphs of
runs. A run is a piece of text with the styles it changes, and nothing else: a bold word in a
regular box is one run with { weight: 700 }.
annotation.richText;
// {
// body: { family: 'Helvetica', size: 12, color: '#000000' },
// paragraphs: [{ runs: [{ text: 'Paid ' }, { text: 'in full', style: { weight: 700 } }] }],
// }The engine writes it the way Acrobat does, so the formatting is the same there. The engine docs have the full model: Free text.
Format from your toolbar#
The same calls work while someone types in a box and when boxes are only selected:
await annotation.selection.update({ bold: true });
await annotation.selection.update({ fontSize: 24, fontColor: '#c00000' });
await annotation.text.toggleFormat('italic'); // italic on, or off if it already isWhere a change lands depends on what’s selected:
- Words selected while typing: the change styles those words.
- Otherwise: it styles the whole text of every selected box.
A property that isn’t about text, such as opacity or the border, always applies to the whole box.
To show the state in your toolbar, useAnnotationProperties()
reports it the same way. With words selected, values.bold is true when every selected word is
bold, and mixed lists what the words disagree on. With a caret or a selected box, it reports the
box’s own style. Text boxes also give you textFormat properties, so a style panel shows the bold,
italic and underline buttons without extra code.
A word with its own style keeps it when the box changes: turn the whole box red, and the one blue word stays blue. To make a word follow the box again, write the paragraphs without that style.
When it’s saved#
Typing is written after a short pause, at every format change, and when editing ends, so the engine
never gets half a word. documents.download() waits for the last write, so the file you download has
everything the user typed.
Draw your own text box#
The annotation layer’s text box already edits rich text. If you draw text boxes
yourself, useRichTextEditor() makes your element the editor:
it draws the runs, turns typing back into runs, keeps the caret when the style changes, and handles
pasting, input methods and the format shortcuts:
import { useRichTextEditor, type AnnotationRendererProps } from '@embedpdf/react/annotation';
// The box fills its frame (`width` and `height` 100% in your CSS) and draws
// it; the editor inside fills the box.
export function BrandedTextBox({ annotation }: AnnotationRendererProps) {
const editor = useRichTextEditor(annotation);
return (
<div className={editor.editing ? 'text-box editing' : 'text-box'}>
<div ref={editor.ref} style={editor.style} />
</div>
);
}Loading live preview…
import { useEffect, useRef } from 'react';
import { Viewer, DocumentGate, usePageList } 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 } from '@embedpdf/react/interaction';
import {
AnnotationLayer,
annotationPlugin,
useAnnotation,
useAnnotationState,
useRichTextEditor,
type AnnotationRenderer,
type AnnotationRendererProps,
} from '@embedpdf/react/annotation';
import { cloudEngine } from '@cloudpdf/engine';
import './branded-text-box.css';
const engine = cloudEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];
const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };
// Your own text box, in your app's look: it fills its frame, which the layer
// places and turns like the text box. The element inside is the editor and
// fills the box, so the box draws the fill, border and padding.
function BrandedTextBox({ annotation }: AnnotationRendererProps) {
const editor = useRichTextEditor(annotation);
return (
<div className={editor.editing ? 'text-box editing' : 'text-box'}>
<div ref={editor.ref} style={editor.style} />
</div>
);
}
const RENDERERS: AnnotationRenderer[] = [
{ for: (annotation) => annotation.subtype === 'free-text', component: BrandedTextBox },
];
// On load: a text box on the cover, selected.
function AddTextBox() {
const annotation = useAnnotation();
const ready = useAnnotationState((state) => state.status === 'ready');
const cover = usePageList()[0]?.ref;
const added = useRef(false);
useEffect(() => {
if (!ready || !cover || added.current) return;
added.current = true;
void annotation.create(
cover,
{
subtype: 'free-text',
box: { x: 106, y: 570, width: 380, height: 60 },
contents: 'Double-click to type in your own text box',
fontSize: 16,
fontColor: '#1a2748',
},
undefined,
{ select: true },
);
}, [annotation, ready, cover]);
return null;
}
// The formatting calls work on your element as they do on the built-in one.
function FormatButtons() {
const annotation = useAnnotation();
const hasText = useAnnotationState((state) =>
state.selected.some((selected) => selected.subtype === 'free-text'),
);
return (
<div className="toolbar">
{(['bold', 'italic', 'underline'] as const).map((format) => (
<button
key={format}
type="button"
className={`button ${format}`}
disabled={!hasText}
onClick={() => annotation.text.toggleFormat(format)}
>
{format[0]!.toUpperCase()}
</button>
))}
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<AddTextBox />
<FormatButtons />
<Stage className="stage">
{() => (
<>
<RenderLayer />
<AnnotationLayer renderers={RENDERERS} />
</>
)}
</Stage>
</DocumentGate>
</Viewer>
);
}Try it: double-click the box and type. The buttons format the selected words, as on the built-in box.
editor.style is the box’s body style as CSS: font, size, color and alignment. It also makes the element fill its parent: the
text is laid out at 16 px or more and drawn back at the box’s size, so iOS doesn’t zoom the page in
when the box takes focus. Draw the box’s fill, border and padding on the parent, as above; anything
on the element itself would scale with the text. The formatting calls above work on your element as
they do on the built-in one.