Enhancers
Wrap createReader with official enhancers, or write your own to give the reader new options, an API namespace or new navigation targets.
Enhancers are the official and conventional way of adding functionalities to the reader. prose reader core tries to stay agnostic and focus on the minimum support of books. Things like bookmarks, highlighting, gesture navigation etc are not part of the core. That's when enhancers come into play.
Existing enhancers
Section titled “Existing enhancers”Official enhancers
Section titled “Official enhancers”There are already several official enhancers which cover common use cases (annotations and bookmarks, gestures, search, etc). Before trying to make your own take a look to see if it does not already exist. Each one has its page under Enhancers in the sidebar.
Example:
import { createReader } from "@prose-reader/core"import { annotationsEnhancer } from "@prose-reader/enhancer-annotations"import { gesturesEnhancer } from "@prose-reader/enhancer-gestures"import { searchEnhancer } from "@prose-reader/enhancer-search"
const createAppReader = annotationsEnhancer( gesturesEnhancer( searchEnhancer( createReader ) ))
const reader = createAppReader({ manifest })
// enhancers have usually their own namespace by conventionreader.search.search("whale").subscribe((results) => { console.log(results)})Paid enhancers
Section titled “Paid enhancers”As of right now there are no paid enhancers but we might consider looking into it to help cover the cost of development of prose. If we ever want to do so, we will try to not create essential paid enhancers. We want the community to be able to produce high quality products without necessarily having to pay.
Community enhancers
Section titled “Community enhancers”Writing your own enhancer
Section titled “Writing your own enhancer”An enhancer is simply a higher order function which takes a reader creator and returns its result.
The entire point of doing this is to be able to compose them, alter the reader creation and have dependency injection if needed. It is also a good way to keep consistency with the community.
Minimal implementation
Section titled “Minimal implementation”export const myEnhancer = <InheritOptions, InheritOutput extends Reader>(next: (options: InheritOptions) => InheritOutput) => (options: InheritOptions): InheritOutput => { const reader = next(options)
return reader }
// end user wrap its createReader with your enhancerconst createAppReader = myEnhancer(createReader)
const reader = createAppReader({ manifest })This enhancer follows the minimal configuration to be functional. It is however pretty useless in this state.
By the way here is how the user can use more than one enhancer:
const createAppReader = enhancerA( enhancerB( enhancerC( createReader ) ))Due to limitation in typescript heap size and complexity we cannot simplify the combination with something like:
const withEnhancer = compose(enhancerA, enhancerB, enhancerC)const createAppReader = withEnhancer(createReader)Writing a compose function is challenging for this type of enhancers.
Anyway, let's now add some features to our enhancer
Add functionalities to the reader
Section titled “Add functionalities to the reader”We want our enhancer to deal with the links being clicked in the book. prose follows a link to a place in the book on its own, but what to do with a link to a website is specific to how the end user wants to react. reader.links$ emits the click events of the book's links.
Let's say we want to show a confirm dialog to the user and if they confirm we open the link:
import { createReader, isHtmlTagElement, type Reader } from "@prose-reader/core"import { takeUntil } from "rxjs"
const withoutFragment = (url: URL) => { const copy = new URL(url)
copy.hash = ""
return copy.href}
/** * The url a click leads to when it is on a link to a website. A relative * href stays in the book, and so does an absolute one that names a spine * item: prose navigates to those itself. */const getExternalUrl = (reader: Reader, event: MouseEvent) => { // The link listened to, rather than what was clicked inside it. const link = event.currentTarget const href = isHtmlTagElement(link, "a") ? link.getAttribute("href") : null
if (!href || !URL.canParse(href)) return undefined
const url = new URL(href) const isInBook = reader.context.manifest?.spineItems.some( (item) => URL.canParse(item.href) && withoutFragment(new URL(item.href)) === withoutFragment(url), ) const isWebsite = url.protocol === "http:" || url.protocol === "https:"
return isWebsite && !isInBook ? url : undefined}
type CreateReader = typeof createReadertype ReaderOutput = ReturnType<CreateReader>
export const myEnhancer = <InheritOptions, InheritOutput extends ReaderOutput>(next: (options: InheritOptions) => InheritOutput) => (options: InheritOptions): InheritOutput => { const reader = next(options)
reader.links$ .pipe( // we don't forget to unsubscribe // reader.$.destroy$ is a good way stop. // It signifies the final destruction of the reader instance takeUntil(reader.$.destroy$), ) .subscribe((event) => { const url = getExternalUrl(reader, event)
if (url && confirm(`You are going to be redirected to external link`)) { window.open(url, "_blank") } })
return reader }For now it's pretty basic but we have an active subscription to the links being clicked and we unsubscribe at the right moment. The examples that follow reuse getExternalUrl and these imports.
Now let's add an option to our enhancer to let the user customize the confirm message:
type CreateReader = typeof createReadertype ReaderOutput = ReturnType<CreateReader>
export const myEnhancer = <InheritOptions, InheritOutput extends ReaderOutput>(next: (options: InheritOptions) => InheritOutput) => ( options: InheritOptions & { myEnhancer: { confirmMessage: string } }, ): InheritOutput => { const { myEnhancer: { confirmMessage }, } = options const reader = next(options)
reader.links$ .pipe( // we don't forget to unsubscribe // reader.$.destroy$ is a good way stop. // It signifies the final destruction of the reader instance takeUntil(reader.$.destroy$), ) .subscribe((event) => { const url = getExternalUrl(reader, event)
if (url && confirm(confirmMessage)) { window.open(url, "_blank") } })
return reader }
const createAppReader = myEnhancer(createReader)
const reader = createAppReader({ manifest, myEnhancer: { confirmMessage: `You are going to be redirected to external link`, },})We extended the type of options to allow some customization. We then simply have to get it from the options passed to our enhancer.
It's nice but what if the user wants to change the message later and after the reader is created ? Maybe because the language changed or else. Right now our options are static and can only be passed on creation.
Let's add a function to let user change the confirm message dynamically:
type CreateReader = typeof createReadertype ReaderOutput = ReturnType<CreateReader>
export const myEnhancer = < NextReader extends ReaderOutput, NextOptions, InheritOptions extends NextOptions & { myEnhancer: { confirmMessage: string } }, InheritOutput extends NextReader & { myEnhancer: { setConfirmMessage: (message: string) => void } }, >( next: (options: NextOptions) => NextReader, ) => (options: InheritOptions): InheritOutput => { let { myEnhancer: { confirmMessage }, } = options const reader = next(options)
reader.links$ .pipe( // we don't forget to unsubscribe // reader.$.destroy$ is a good way stop. // It signifies the final destruction of the reader instance takeUntil(reader.$.destroy$), ) .subscribe((event) => { const url = getExternalUrl(reader, event)
if (url && confirm(confirmMessage)) { window.open(url, "_blank") } })
return { ...reader, myEnhancer: { setConfirmMessage: (newMessage) => { confirmMessage = newMessage }, }, } as InheritOutput }
const createAppReader = myEnhancer(createReader)
const reader = createAppReader({ manifest, myEnhancer: { confirmMessage: `You are going to be redirected to external link`, },})
reader.myEnhancer.setConfirmMessage(`My new message`)Here we added new typing on the output of our enhancer and implemented a new function to update our message.
This may not seem like much but we already know how to:
- alter creation options
- add feature by reacting to reader events
- alter reader API
The final piece is how to make an enhancer that requires another enhancer. For the sake of simplicity let's stay with our current example of link interaction. Let's split our enhancer into two. The first enhancer will provide a confirm dialog, the second will redirect the link and use the first one to display the dialog. It does not make much sense but the exercise is about enhancer dependencies.
Due to the complexity we will need to decompose our types a bit more and use some unfortunate escape hatches. The escape hatches are within the enhancer itself, we provide a valid typescript enhancer definition for the end user.
type CreateReader = typeof createReadertype ReaderOutput = ReturnType<CreateReader>
export const dialogEnhancer = < InheritOptions, NextReader extends ReaderOutput, InheritOutput extends NextReader & { dialog: { confirm: (message: string) => boolean } }, >( next: (options: InheritOptions) => NextReader, ) => ( options: InheritOptions & { dialog: { confirmMessage: string } }, ): InheritOutput => { const reader = next(options)
const dialogConfirm = (message: string) => confirm(message)
return { ...reader, dialog: { confirm: dialogConfirm, }, // We unfortunately have a subset casting problem // NextReader (reader) cannot be assigned to InheritOutput // Even tho NextReader follow InheritOutput constraint // InheritOutput could have a different set of constraint on its own } as InheritOutput }
type DialogEnhancerOptions = Parameters<ReturnType<typeof dialogEnhancer>>[0]type DialogEnhancerOutput = ReturnType<ReturnType<typeof dialogEnhancer>>
export const linkEnhancer = < InheritOptions extends DialogEnhancerOptions, InheritOutput extends DialogEnhancerOutput, >( next: (options: InheritOptions) => DialogEnhancerOutput, ) => (options: InheritOptions): InheritOutput => { const reader = next(options)
reader.links$.pipe(takeUntil(reader.$.destroy$)).subscribe((event) => { const url = getExternalUrl(reader, event)
if (url && reader.dialog.confirm(options.dialog.confirmMessage)) { window.open(url, "_blank") } })
// Again, we have a problem of subset casting // It's not safe but it's the best way to deal with // it afaik return reader as InheritOutput }
// the order is important, you will get a typescript error if you// make a mistakeconst createAppReader = linkEnhancer(dialogEnhancer(createReader))
const reader = createAppReader({ manifest, dialog: { confirmMessage: `Do you want to continue with this link?`, },})Regarding this part, you are free to use a different strategy, especially if it feels cleaner to you. As long as your enhancer is correctly typed for end user, you are free to change the way you deal with dependencies.
Adding a navigation target
Section titled “Adding a navigation target”An enhancer can teach a reader a target of its own, both to navigate to and
to open at. It widens navigate and createReader's target option to take
it, translates it into a target core knows, and hands every other target on
unchanged. The type flows through the enhancers like any other output, so only
a reader built with the enhancer takes the new target.
A place that can only be found in a spine item's document, such as an element
by id or a text offset, translates into a selector target. Core calls
find once the document is loaded, and until then goes to the item's start.
Core's own url support is built this way: its navigation enhancer turns
{ type: "url", value: "…/ch02.xhtml#notes" } into a selector that finds
#notes in ch02.xhtml.
import type { NavigationTarget, Reader, UserNavigationEntry,} from "@prose-reader/core"
type NoteTarget = { type: "note"; value: string }
type NotesEnhancerOutput<InheritTarget> = { navigation: { navigate: (to: UserNavigationEntry<InheritTarget | NoteTarget>) => void }}
const isNoteTarget = (target: { type: string }): target is NoteTarget => target.type === "note"
/** The element a note's id names, in the `notes` spine item. */const noteToSelector = ({ value,}: NoteTarget): NavigationTarget<"selector"> => ({ type: "selector", value: { spineItem: "notes", find: (document) => { const node = document.getElementById(value)
return node ? { node } : undefined }, },})
export const notesEnhancer = < InheritOptions extends { target?: { type: string } }, InheritTarget extends { type: string }, InheritOutput extends Reader, >( next: (options: InheritOptions) => InheritOutput & { navigation: { navigate: (to: UserNavigationEntry<InheritTarget>) => void } }, ) => ( options: Omit<InheritOptions, "target"> & { target?: InheritOptions["target"] | NoteTarget }, ): InheritOutput & NotesEnhancerOutput<InheritTarget> => { const { target: openingTarget, ...rest } = options const reader = next({ ...rest, target: openingTarget && isNoteTarget(openingTarget) ? noteToSelector(openingTarget) : openingTarget, // Only `target` changed; TypeScript cannot rebuild generic options // from `Omit`. } as InheritOptions)
const navigate = (to: UserNavigationEntry<InheritTarget | NoteTarget>) => { const { target } = to
if (!isNoteTarget(target)) return reader.navigation.navigate({ ...to, target })
reader.navigation.navigate({ ...to, target: noteToSelector(target) }) }
return { ...reader, navigation: { ...reader.navigation, navigate } } }InheritTarget is read from the navigate of the reader next returns, so
enhancers that add targets stack in any order: each navigate takes its own
target and every target of the readers below it. The target is told apart with a
type guard, because a target.type !== "note" check cannot narrow a union that
contains InheritTarget. The explicit return type keeps TypeScript from
serializing the whole reader when the enhancer is exported.
The target option is translated before the options reach next, as the
navigation enhancer does for urls, so the reader opens at the selector the way
it opens at any target: once
the book is laid out and every enhancer has added its hooks, without its
reading position passing through the start of the book. Navigating to the note
once next has returned would run before the enhancers applied after yours
have added theirs. A translation that needs the book reads options.manifest:
the reader does not exist yet.
The option takes what the options of the reader below take,
InheritOptions["target"], and notes. Widened options no longer satisfy a
constraint on the narrower ones, so an enhancer applied after yours should
constrain its options only to what it reads, such as
{ target?: { type: string } } here, or Pick<CreateReaderOptions, "getRenderer">.
Adding reader settings
Section titled “Adding reader settings”An option the user changes while reading, like the confirm message, can be a reader setting. It then takes its value from createReader's options, changes with reader.settings.update and shows in reader.settings.values$, next to the reader's own settings. The fonts and layout enhancers createReader is built with add theirs this way.
reader.settings.extend takes the defaults of your settings and the options your enhancer was created with. It adds the keys of the defaults, each with its value from the options when that value is defined, and returns the reader's settings typed with them. Return them on the reader, and declare the reader's type with ExtendedSettings, so that it keeps the settings of the enhancers below yours:
import type { ExtendedSettings } from "@prose-reader/core"
type CreateReader = typeof createReadertype ReaderOutput = ReturnType<CreateReader>
type ConfirmLinksSettings = { confirmMessage: string}
const defaultConfirmLinksSettings: ConfirmLinksSettings = { confirmMessage: `You are going to be redirected to external link`,}
export const confirmLinksEnhancer = <InheritOptions, InheritOutput extends ReaderOutput>(next: (options: InheritOptions) => InheritOutput) => ( options: InheritOptions & Partial<ConfirmLinksSettings>, ): Omit<InheritOutput, "settings"> & { settings: ExtendedSettings<InheritOutput["settings"], ConfirmLinksSettings> } => { const reader = next(options) const inheritedSettings: InheritOutput["settings"] = reader.settings const settings = inheritedSettings.extend(defaultConfirmLinksSettings, options)
reader.links$.pipe(takeUntil(reader.$.destroy$)).subscribe((event) => { const url = getExternalUrl(reader, event)
if (url && confirm(settings.values.confirmMessage)) { window.open(url, "_blank") } })
return { ...reader, settings } }
const createAppReader = confirmLinksEnhancer(createReader)
const reader = createAppReader({ manifest, confirmMessage: `Do you want to leave the book?`,})
reader.settings.update({ confirmMessage: `Voulez-vous quitter le livre ?` })inheritedSettings is typed InheritOutput["settings"] on purpose. TypeScript types reader.settings itself from ReaderOutput, the constraint of InheritOutput, and extend would then leave out the settings that the enhancers below yours added.
The enhancer reads settings.values on each click, so an updated message applies from the next one. Unlike the namespaced options above, settings are flat: yours share reader.settings with the reader's and every other enhancer's. extend throws when a name is already taken, so give yours names no other enhancer uses. Give each one a default, undefined for an optional one: the settings drop a key their defaults lack.
The container
Section titled “The container”The element the reader is mounted into belongs to the application, and outlives the reader: an application can destroy a reader and mount a new one into the same element. Whatever your enhancer writes on it, an attribute, a style, an element or a listener, undo it when the reader is destroyed, as the reader does with what it writes. Static CSS files shows an attribute set and removed this way.
Custom CSS in enhancers
Section titled “Custom CSS in enhancers”To learn how to efficiently handle CSS with your enhancers, you can visit the Style & CSS section.
What if I don't want to follow enhancers best practice?
Section titled “What if I don't want to follow enhancers best practice?”You can very much do whatever you want and distribute a package that adds features to prose. You don't need to follow the enhancers convention. However it might make its use confusing for the end user and will create extra friction.
Here is an example of how to do things differently:
We are creating a "plugin" which handles click on links and redirect the user after confirmation, with the getExternalUrl helper from Add functionalities to the reader. This will work and is rather simple to use.
export const linkHandlerPlugin = (reader: Reader) => { const sub = reader.links$.subscribe((event) => { const url = getExternalUrl(reader, event)
if (url && confirm(`You are going to be redirected to external link`)) { window.open(url, "_blank") } })
return () => { sub.unsubscribe() }}Now its alternative with enhancer convention:
export const linkHandlerEnhancer = <InheritOptions, InheritOutput extends Reader>(next: (options: InheritOptions) => InheritOutput) => (options: InheritOptions): InheritOutput => { const reader = next(options)
reader.links$.pipe(takeUntil(reader.$.destroy$)).subscribe((event) => { const url = getExternalUrl(reader, event)
if (url && confirm(`You are going to be redirected to external link`)) { window.open(url, "_blank") } })
return reader }The enhancer version has more initial boilerplate, especially due to the generic types but this is essential for more complex enhancers which alter options, output, the reader itself or else.
For such simple examples the reason to use enhancer is not obvious and ultimately not needed at all because we don't change the input, output, reader in between and don't require other enhancers dependencies. That being said, keeping conventions is a good thing for community consistency.