react-pdf icon indicating copy to clipboard operation
react-pdf copied to clipboard

Support Hyperlink Navigation to Bookmark

Open jcramercodes opened this issue 2 years ago • 3 comments

Would like to gain access to the function that runs when clicking a specific bookmark... In my case attaching it to a link component. So, clicking a <Link src="bookmark1">Go To Bookmark 1</Link> component would navigate to the bookmark's relevant pdf page position (aka: the pdf node the selected bookmark is associated with).

Not sure if this is technically a "new feature"... but I imagine this could be supported by supplying the correct syntax for the "src" string to trigger the navigation.

So in the Type declaration of LinkProps src becomes a type of:

src: string | Bookmark

(not sure what's possible here)

This would also be handy for generating a Table of Contents page where each text component links to a relevant bookmark.

-Thanks!

jcramercodes avatar Aug 11 '22 23:08 jcramercodes

PDF internal links are already supported, although not documented: https://github.com/diegomura/react-pdf/blob/master/packages/examples/src/goTo/index.js Something you won't be able to do this way is also auto-generate the correct target page numbers to render into your PDF.

carlobeltrame avatar Aug 16 '22 14:08 carlobeltrame

Thanks for the info! Is there another way you'd recommend gaining access to a react-pdf/renderer component's page number? (outside of bookmarks)

jcramercodes avatar Aug 16 '22 16:08 jcramercodes

I think there is no way to gain access to the page number and render it into the document, because theoretically, rendering the page number could necessitate a re-layouting of the document, and then the page number of the component might change.

carlobeltrame avatar Aug 16 '22 16:08 carlobeltrame

A hack I have used to get the page number is to create a top references map to hold ids to page numbers then abuse the the view render method to keep track

Here's an example Table of Contents component This assumes that references was passed in from the document level const references = new Map();

import { View, Text, Link } from '@react-pdf/renderer';

const styles = {
    column: {
        flexDirection: 'column',
    },
    row: {
        flexDirection: 'row',
        flexWrap: 'nowrap',
        alignItems: 'flex-start',
    },
    line: {
        flexGrow: 1,
        borderBottomStyle: 'dotted',
        borderBottomWidth: 1,
        borderBottomColor: '#888888',
        marginHorizontal: 2,
        height: 11,
    },
    page: {
        textAlign: 'right',
        textOverflow: 'hidden',
    },
};

function Toc({ items, references }) {
    return (
        <View style={styles.column}>
            {items.map((item, index) => {
                const { id, title } = item;
                return (
                    <View key={index} style={styles.row}>
                        <Link src={`#${id}`}>
                            <Text>{title}</Text>
                        </Link>
                        <View style={styles.line} />
                        <Text
                            style={styles.page}
                            render={() => {
                                const page = references.get(id);
                                return page == null ? '##' : String(page);
                            }}
                        />
                    </View>
                );
            })}
        </View>
    );
}

Then on each item page I update the references by using the render method of the view this (it should not take up any space but tracks the page number, it just needs access to the document level references map I made. (assuming the references map is in scope).

        <View
            wrap={false}
            render={({ pageNumber }) => {
                if (!references.has(id, pageNumber)) {
                    references.set(id, pageNumber);
                }
            }}
        />

To make it easier you can wrap use a React.Context that contains the references for the entire document and update the page numbers that way. Hope this helps

SpudNyk avatar Nov 15 '22 20:11 SpudNyk

Wow, does that really work? Why is the table of contents rendered after all of the other components, do you have your table of contents last in your pdf? If you are using context, doesn't that rerender the document for each new page number you add to the (context) map?

carlobeltrame avatar Nov 16 '22 06:11 carlobeltrame

It does really work. When the render method is used the doc is rendered twice (described here) - So the initial render method for my above Toc component gets nothing, but on the second render, all the ids will have been set by the View render methods in the children and a number is available. The Toc should not cause the page numbering to shift otherwise incorrect results will be present.

Regarding context as I am just using it to get the references object (defined at the document level) and mutate only so it won't cause a re-render

Simplified lib below - usePageReferences could be given to the Toc component in my previous comment to render the Toc

import { createContext, useContext, useRef } from 'react';

const Context = createContext();

export function ProvidePageReferences({ children }) {
    // use ref so we don't create a new map every render
    const ref = useRef();
    if (!ref.current) {
        ref.current = new Map();
    }
    return <Context.Provider value={ref.current}>{children}</Context.Provider>;
}

export function usePageReferences() {
    return useContext(Context);
}

// use this as a component in the react-pdf document tree
export function PageReference({ id }) {
    const references = usePageReferences();
    return references && id ? (
        <View
            wrap={false}
            render={({ pageNumber }) => {
                // this is so only the first page for an id is used if multiple are given
                if (!references.has(id, pageNumber)) {
                    references.set(id, pageNumber);
                }
            }}
        />
    ) : null;
}

SpudNyk avatar Nov 16 '22 15:11 SpudNyk

@SpudNyk Thanks for posting. I am having some trouble getting this to work. Could you share a repo by any chance where its working?

jopfre avatar Dec 07 '22 23:12 jopfre

I don't have a full repo that's publicly accessible.

There was an issue the PageReference component that was specific to working around a bug in an internal use case, I have updated it in my below example to fix it.

I have included an updated example below that should work correctly.

import { View, Text, Link, Page, Document } from '@react-pdf/renderer';
import { createContext, useContext, useRef } from 'react';

const Context = createContext();

export function ProvidePageReferences({ children }) {
    // use ref so we don't create a new map every render
    const ref = useRef();
    if (!ref.current) {
        ref.current = new Map();
    }
    return <Context.Provider value={ref.current}>{children}</Context.Provider>;
}

export function usePageReferences() {
    return useContext(Context);
}

// use this as a component in the react-pdf document tree
export function PageReference({ id }) {
    const references = usePageReferences();
    return references && id ? (
        <View
            wrap={false}
            render={({ pageNumber }) => {
                references.set(id, pageNumber);
            }}
        />
    ) : null;
}

const styles = {
    column: {
        flexDirection: 'column',
    },
    row: {
        flexDirection: 'row',
        flexWrap: 'nowrap',
        alignItems: 'flex-start',
    },
    line: {
        flexGrow: 1,
        borderBottomStyle: 'dotted',
        borderBottomWidth: 1,
        borderBottomColor: '#888888',
        marginHorizontal: 2,
        height: 11,
    },
    page: {
        textAlign: 'right',
        textOverflow: 'hidden',
    },
};

function Toc({ items }) {
    const references = usePageReferences();
    return (
        <View style={styles.column}>
            {items.map((item, index) => {
                const { id, title } = item;
                return (
                    <View key={index} style={styles.row}>
                        <Link src={`#${id}`}>
                            <Text>{title}</Text>
                        </Link>
                        <View style={styles.line} />
                        <Text
                            style={styles.page}
                            render={() => {
                                const page = references.get(id);
                                return page == null ? '##' : String(page);
                            }}
                        />
                    </View>
                );
            })}
        </View>
    );
}

function genItems(count) {
    const items = [];
    for (let i = 0; i < count; i++) {
        items.push({
            id: `item-${i}`,
            title: `Item ${i}`,
        });
    }
    return items;
}

function ItemContent({ item, break: pageBreak }) {
    const { id, title } = item;
    return (
        <View break={pageBreak}>
            <Text id={id}>{title}</Text>
            <PageReference id={id} />
            <Text
                render={({ pageNumber, totalPages }) =>
                    `${pageNumber} of ${totalPages}`
                }
            />
        </View>
    );
}

function newPage(chance) {
    return Math.random() < chance;
}

export function TocDoc() {
    const items = genItems(50);
    return (
        <Document>
            <ProvidePageReferences>
                <Page>
                    <Toc items={items} />
                </Page>
                <Page>
                    {items.map((item, index) => {
                        return (
                            <ItemContent
                                key={index}
                                item={item}
                                break={newPage(0.2)}
                            />
                        );
                    })}
                </Page>
            </ProvidePageReferences>
        </Document>
    );
}

SpudNyk avatar Dec 08 '22 15:12 SpudNyk

@SpudNyk thanks for this. It does work and I was able to get TOC working. However there seemed to be some issues with where page breaks would fall. I tried setting wrap={true} and playing with the positions of the page references and page breaks but couldn't figure out how to get consistent behavior.

The document I am trying to create is 30+ pages and I felt that I wanted something more stable so in the end I have switched to pdfmake https://github.com/bpampuch/pdfmake which natively supports TOCs and feels a little more predictable. I am sure your code will help some people and I would also recommend pdfmake to anyone struggling with react-pdf.

jopfre avatar Dec 10 '22 15:12 jopfre

Interesting definitely works for me on documents of 200+ pages. The dynamic render method is definitely very particular about actually including the content in the document.

react-pdf definitely has its quirks but being able to use the react model and components is very useful.

I do think that adding official support for page number references to ids would be great, and would ease creating Toc components.

SpudNyk avatar Dec 10 '22 18:12 SpudNyk

@SpudNyk Thanks for the inspiration. I wanted to register my chapters dynamically, instead of passing items directly to toc. It worked, but I had to move the toc contents into a render function:

export const TableOfContents = () => {
    const {chapters} = useDocumentContext()

    return (
        <View render={() => (
            <Text>
                {[...chapters.entries()].map(([id, chapter]) => (
                    <Link src={`#${id}`} key={id} debug>
                        <Text>{chapter.title}</Text>
                        <Text>{chapter.pageNumber}</Text>
                    </Link>
                ))}
            </Text>
        )}>
        </View>
    )
}

hypeJunction avatar Jan 30 '23 13:01 hypeJunction

@hypeJunction

That looks great!

As you didn't have the chapters yet for the initial page it would need the render function so the chapters would be available for the second render. The only thing I'd be worried about is your Toc potentially adding more pages and potentially changing the page numbers again, the 1.x versions didn't seem to call the re-render with the correct pages so that is why I went the route of rendering the list with a placeholder number on the first pass.

Great to see the idea being useful for others.

SpudNyk avatar Jan 30 '23 14:01 SpudNyk