👀 Our most exciting product launch yet 🚀 Join us May 8th for Sanity Connect

How to Calculate the Reading Time of Body Content with React

17 replies
Last updated: Apr 5, 2022
Hi all! Trying to implement the "react-hook-reading-time" to calculate the reading time of the body content. But the problem is it's trying to calculate the body text. But the PortableText body is one array of multiple objects. Is it possible to add this? Can I like filter out the text only from the body array?
Apr 4, 2022, 2:17 PM
I have simply add a field with title " reading time " and put reading time myself 😄 if it's help 🙂You have also a reading time calculator here
https://niram.org/read/
Apr 4, 2022, 2:41 PM
    {
      name: "read",
      title: "read",
      type: "number",
      description: "put number in minutes for reading time👇",
      validation: (Rule) => Rule.required(),
    },
Apr 4, 2022, 2:42 PM
Hey
user Q
, I’ve made a little custom component that might help!
Schema:

import RenderReadingTime from "./RenderReadingTime";

export default {
    name: "page",
    title: "Pages",
    type: "document",
    fields: [
        {
            title: "Content",
            name: "content",
            type: "array",
            of: [{ type: "block" }],
        },
        {
            name: "reading",
            title: "Reading Time",
            type: "string",
            inputComponent: RenderReadingTime,
        },
    ],
};
Custom component:

import React from "react";
import { Card, Stack, Inline, Badge } from "@sanity/ui";
import { FormField } from "@sanity/base/components";
import { withDocument } from "part:@sanity/form-builder";
import { useReadingTime } from "react-hook-reading-time";

export const RenderReadingTime = React.forwardRef((props, ref) => {
    const { type, document } = props;

    const filteredBlock = document.content
        .filter((x) => x._type === "block")
        .map((children) => children.children.map((texts) => texts.text));

    const readingTime = useReadingTime(filteredBlock.join(" "));

    return (
        <FormField description={type.description} title={type.title}>
            <Card padding={[3, 3, 3, 3]} shadow={1} radius={2} ref={ref}>
                <Stack space={[3, 3, 3]}>
                    <Inline space={2}>
                        <Badge tone={"primary"}>{readingTime.text}</Badge>
                        <Badge>{`${readingTime.words} words`}</Badge>
                    </Inline>
                </Stack>
            </Card>
        </FormField>
    );
});

export default withDocument(RenderReadingTime);
The styling is quite basic, but hopefully this works for you.
Apr 4, 2022, 3:44 PM
Tried doing it dynamically why over complicate things. Thanks!
Apr 4, 2022, 3:45 PM
You’re welcome!
Apr 4, 2022, 4:02 PM
user P
Got it working. but inside GROQ I get null if "reading" is added to the query.
Apr 5, 2022, 8:28 AM
export const articleQuery = groq`*[_type == "article" && slug.current == $slug][0] {
    ...,
    reading,
    body[] {
    ...,
    _type == "imageWithAltText" => {
        asset -> {
          ...,
          url,
        },
    },

    _type == "video" => {
        asset -> {
          ...,
          videoPlatform,
          href,
        },
    },

    markDefs[] {
      ...,
      _type == "internalLink" => {
        "path": reference->slug.current
      }
    },
  },


  'comments': *[
    _type == "comment" &&
    article._ref == ^._id &&
    approved == true
  ],

  user -> {
    name,
    featuredImage,
  },


  "slug": slug.current,
}`;
Apr 5, 2022, 8:28 AM
is not
reading->,
?
export const articleQuery = groq`*[_type == "article" && slug.current == $slug][0] {
    ...,
    reading->,
    body[] {
    ...,
    _type == "imageWithAltText" => {
        asset -> {
          ...,
          url,
        },
    },

    _type == "video" => {
        asset -> {
          ...,
          videoPlatform,
          href,
        },
    },

    markDefs[] {
      ...,
      _type == "internalLink" => {
        "path": reference->slug.current
      }
    },
  },


  'comments': *[
    _type == "comment" &&
    article._ref == ^._id &&
    approved == true
  ],

  user -> {
    name,
    featuredImage,
  },


  "slug": slug.current,
}`;
Apr 5, 2022, 9:41 AM
Returns null
Apr 5, 2022, 9:44 AM
user Q
At the moment the ‘reading’ component is just a preview in the studio – it’s not patching the data into the schema. You’ll need to add the
PatchEvent
from
"@sanity/form-builder/PatchEvent"
into the RenderReadingTime component. I will try and update the component a bit later today!
Apr 5, 2022, 9:48 AM
user P
Thank you very much would be nice to have it inside the GROQ instead of having to write same code on the front-end side
Apr 5, 2022, 9:57 AM
user P

This is what I have now but GROQ returns 1d.


import React from "react";
import { Card, Stack, Inline, Badge, TextInput } from "@sanity/ui";
import { FormField } from "@sanity/base/components";
import { withDocument } from "part:@sanity/form-builder";
import { useReadingTime } from "react-hook-reading-time";
import { useId } from "@reach/auto-id"; // hook to generate unique IDs

import PatchEvent, { set, unset } from "@sanity/form-builder/PatchEvent";

export const RenderReadingTime = React.forwardRef((props, ref) => {
  const {
    type,
    document,
    value,
    readOnly,
    onChange,
    compareValue,
    markers,
    presence,
  } = props;

  const filteredBlock = document.body
    .filter((x) => x._type === "block")
    .map((children) => children.children.map((texts) => texts.text));

  const readingTime = useReadingTime(filteredBlock.join(" "));

  // Creates a unique ID for our input
  const inputId = useId();

  // Creates a change handler for patching data
  const handleChange = React.useCallback(
    // useCallback will help with performance
    (event) => {
      const inputValue = event.currentTarget.value; // get current value
      // if the value exists, set the data, if not, unset the data
      onChange(PatchEvent.from(inputValue ? set(inputValue) : unset()));
    },
    []
  );

  return (
    <FormField
      description={type.description}
      title={type.title}
      compareValue={compareValue}
      inputId={inputId}
      __unstable_markers={markers}
      __unstable_presence={presence}
    >
      <Card padding={[3, 3, 3, 3]} shadow={1} radius={2} ref={ref}>
        <Stack space={[3, 3, 3]}>
          <Inline space={2}>
            <Badge tone="primary">{readingTime.text}</Badge>
            <Badge>{`${readingTime.words} words`}</Badge>
            <TextInput
              id={inputId}
              onChange={handleChange}
              value={readingTime.minutes || ""}
              readOnly={readOnly}
              placeholder={readingTime.minutes}
              ref={ref}
            />
          </Inline>
        </Stack>
      </Card>
    </FormField>
  );
});

export default withDocument(RenderReadingTime);
Apr 5, 2022, 10:13 AM
I’ve updated the component to patch the resulting reading time into the schema (as well as showing a preview in the studio).
Although this works, I’m unsure if I’m following the best practice in terms of patching data! I ended up using the
sanityClient
rather than
PatchEvent
as I believe PatchEvent requires an onChange event to trigger, and as the component doesn’t have a direct user input, this isn’t possible (correct me if I’m wrong).
The reading time component supports both
string
and
block
types, and requires the options of
fieldName
(which field that will be patched, which is probably is the same field) &amp; `target`:

{
    title: "Content",
    name: "content",
    type: "array",
    of: [{ type: "block" }],
},
{
    name: "reading",
    title: "Reading Time",
    type: "string",
    inputComponent: RenderReadingTime,
    options: { fieldName: "reading", target: "content" }
},
Here is the component:

import React, { useState, useMemo, useEffect } from "react";
import { Card, Stack, Inline, Badge } from "@sanity/ui";
import { FormField } from "@sanity/base/components";
import { withDocument } from "part:@sanity/form-builder";
import { useReadingTime } from "react-hook-reading-time";
import sanityClient from "part:@sanity/base/client";
const client = sanityClient.withConfig({ apiVersion: "2022-02-15" });

export const RenderReadingTime = React.forwardRef((props, ref) => {
    const { type, document } = props;
    const [time, setTime] = useState(null);

    const valid = useMemo(() => {
        const validate =
            (type.options?.target !== undefined &&
                document[type.options?.target] !== undefined &&
                document[type.options?.fieldName] !== undefined &&
                Array.isArray(document[type.options?.target])) ||
            Object.prototype.toString.call(document[type.options?.target]) === "[object String]";
        
        const targetType = validate
            ? Array.isArray(document[type.options?.target])
                ? "block"
                : "string"
            : false;
        
        return targetType;
    }, []);

    useEffect(() => {
        client
            .patch(document._id)
            .set({ reading: time?.words && valid !== false ? time.text : "" })
            .commit()
            .catch((err) => console.error("The update to 'reading' failed: ", err.message));
    }, [time]);

    return (
        <FormField description={type.description} title={type.title}>
            <Card padding={[3, 3, 3, 3]} shadow={1} radius={2} ref={ref}>
                <Stack space={[3, 3, 3]}>
                    {valid !== false ? (
                        <RenderReadingTimePatch
                            document={document}
                            type={type}
                            valid={valid}
                            time={time}
                            setTime={setTime}
                        />
                    ) : (
                        <Inline space={2}>
                            <Badge tone={"critical"}>Invalid or missing target/fieldName</Badge>
                        </Inline>
                    )}
                </Stack>
            </Card>
        </FormField>
    );
});

const RenderReadingTimePatch = ({ document, type, valid, time, setTime }) => {
    const debouncedInput = useDebounce(document[type.options.target], 1000);

    useEffect(() => {
        if (valid === "block") {
            const filteredBlock = document[type.options.target]
                .filter((x) => x._type === "block")
                .map((children) => children.children.map((texts) => texts.text));
            setTime(useReadingTime(filteredBlock.join(" ")));
        } else {
            setTime(useReadingTime(useReadingTime(document[type.options.target])));
        }
    }, [debouncedInput]);

    return (
        <div>
            {time && (
                <Inline space={2}>
                    <Badge tone={"primary"}>{time.text}</Badge>
                    <Badge>{`${time.words} words`}</Badge>
                </Inline>
            )}
        </div>
    );
};

const useDebounce = (value, delay) => {
    const [debouncedValue, setDebouncedValue] = useState(value);
    useEffect(() => {
        const handler = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        return () => {
            clearTimeout(handler);
        };
    }, [value, delay]);
    return debouncedValue;
};

export default withDocument(RenderReadingTime);
Apr 5, 2022, 1:21 PM
user P
Great! Which setState value has to be changed to only return "minutes" as a number instead of complete string? Because I am need the minutes only this way I can apply my own JSX.
Apr 5, 2022, 1:52 PM
You can change the section below from
time.text
to
time.minutes.toString()


    useEffect(() => {
        client
            .patch(document._id)
            .set({ reading: time?.words && valid !== false ? time.text : "" }) //CHANGE HERE
            .commit()
            .catch((err) => console.error("The update to 'reading' failed: ", err.message));
    }, [time]);
Apr 5, 2022, 1:57 PM
So
.set({ reading: time?.words && valid !== false ? time.text : "" })
becomes
.set({ reading: time?.words && valid !== false ? time.minutes.toString() : "" })
Apr 5, 2022, 1:58 PM
Applied time.minutes but missed the toString() function. Thank you Simon!
Apr 5, 2022, 1:59 PM

Sanity– build remarkable experiences at scale

Sanity is a modern headless CMS that treats content as data to power your digital business. Free to get started, and pay-as-you-go on all plans.

Was this answer helpful?