Embedding and editing portable text blocks within Sanity content editor

4 replies
Last updated: Jun 27, 2024
Hey all, new to Sanity and I'm trying to extend the content editor so that blocks of portable text can be embedded and edited right within the portable text editor.
Sorta like this:

export default defineType({
  title: "Blog Body",
  name: "blogBody",
  type: "array",
  of: [
    {
      title: "Content",
      type: "block",
      styles: [
        { title: "Normal", value: "normal" },
        { title: "Quote", value: "blockquote" },
        { title: "Heading", value: "h2" },
        { title: "Sub Heading 1", value: "h3" },
        { title: "Sub Heading 2", value: "h4" },
        { title: "Sub Heading 3", value: "h5" },
      ],
      marks: {
        decorators: [
          { title: "Strong", value: "strong" },
          { title: "Emphasis", value: "em" },
          { title: "Inline Code", value: "code" },
          { title: "Underline", value: "underline" },
          { title: "Strike Through", value: "strike-through" },
          {
            title: "Highlight",
            value: "highlight",
            component: Highlight,
            icon: BulbOutlineIcon,
          },
        ],
      },
    },
    { title: "Image", type: "defaultImage" },
    { type: "code", options: { withFilename: true } },
    { title: "Youtube", type: "youtube" },
    {
      title: "Section",
      type: "object",
      name: "section",
      icon: BlockContentIcon,
      fields: [
        {
          title: "Content",
          type: "array",
          name: "content",
          of: [{ type: "block" }],
        },
      ],
    },
  ],
});
The objective here though is that I want
Section
objects to display the actual content inline within the parent editor.
Jun 25, 2024, 10:52 PM
Putting a portable text editor inside of a portable text editor is not recommended, as it drastically increases your attribute count. I’d suggest using marks or annotations to indicate sections instead.
Jun 25, 2024, 11:39 PM
Thanks. I don't think annotations can be applied across blocks though, and marks are a bit too generalized.
I decided to just use a hidden separator that i use to tag and slice block arrays on the frontend in a preprocessing step, which I wrap into sections. Easy peasy.
Jun 26, 2024, 7:09 PM
import SectionRule from "@/src/components/studio/block/SectionRule";
import { defineType } from "sanity";
import { BlockElementIcon } from "@sanity/icons";

export default defineType({
  title: "Section Rule",
  type: "object",
  name: "sectionRule",
  description:
    "A rule that wraps content into sections. Content must be nested between rules to be wrapped. Rules can optionally be set to render on the frontend.",
  icon: BlockElementIcon,
  fields: [
    {
      title: "Visible",
      type: "boolean",
      name: "visible",
      initialValue: false,
      description: "Sets whether this rule will be rendered on the frontend.",
    },
  ],
  preview: { select: { visible: "visible" } },
  components: { preview: SectionRule },
});

import { PreviewProps } from "sanity";

export default function SectionRule(props: PreviewProps) {
  const { visible } = props as PreviewProps & { visible: boolean };

  const opacity = visible ? "opacity-100" : "opacity-20";
  const width = visible ? "border-2" : "border";

  return <hr className={[opacity, width].join(" ")} />;
}
Jun 26, 2024, 7:59 PM
For anyone interested:
import { PortableTextBlock } from "next-sanity";

/**
 * Wraps multiple ranges of a block array with pseudo blocks.
 * @param blocks Input block array
 * @param ranges An array of inclusive index tuples
 * @param properties The pseudo block that wraps each range
 * @returns Processed block array
 */
function wrapBlocks(
  blocks: PortableTextBlock[],
  ranges: [number, number][],
  properties: { _type: string; _key?: string }
): PortableTextBlock[] {
  const result = [];
  let marker = 0;

  for (const [from, to] of ranges) {
    result.push(...blocks.slice(marker, from));
    result.push({ ...properties, children: blocks.slice(from, to + 1) });
    marker = to + 1;
  }

  result.push(...blocks.slice(marker));
  return result;
}

/**
 * Wraps groups of block array elements in a section block.
 * @param blocks Input block array
 * @param ranges An array of inclusive index tuples
 * @returns Processed block array
 */
export function wrapSections(
  blocks: PortableTextBlock[],
  ranges: [number, number][]
): PortableTextBlock[] {
  return wrapBlocks(blocks, ranges, { _type: "section" });
}

/**
 * Checks a block array for blocks that pass a predicate function
 * @param blocks Input block array
 * @param predicate A function that tests each block
 * @returns An array of indexes of passing blocks
 */
function getBlockIndexes(
  blocks: PortableTextBlock[],
  predicate: (block: PortableTextBlock) => boolean
): number[] {
  const indexes: number[] = [];
  blocks.forEach((block, index) => {
    if (predicate(block)) indexes.push(index);
  });
  return indexes;
}

/**
 * Extract ranges of blocks to wrap in section tags
 * @param blocks
 * @returns
 */
export function getSectionRanges(
  blocks: PortableTextBlock[]
): [number, number][] {
  const indexes = getBlockIndexes(
    blocks,
    (block) => block._type === "sectionRule"
  );
  const ranges: [number, number][] = [];

  if (indexes.length === 0) {
    return ranges;
  }

  let marker = indexes[0];

  for (let i = 1; i < indexes.length; i++) {
    ranges.push([marker + 1, indexes[i] - 1]);
    marker = indexes[i];
  }

  return ranges;
}
There's probably some library that does this already for portabletext. Haven't really bothered to search it out though.
Jun 27, 2024, 6:27 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?