Mischief

31 / Documents

Document Splits

Mark where one scanned batch becomes several documents. Splits are toggled between pages and the segments update as you go.

2 documents

Document 1 · 2 pages

12

Document 2 · 1 page

3

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/document-splits
import { DocumentSplits } from "mischief-ui/document-splits"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/document-splits/document-splits.tsx
"use client" import * as React from "react"import { FileText, Scissors, Undo2 } from "lucide-react"import { cn } from "@/lib/utils" export type SplitPage = {  number: number  src?: string  label?: string} export type DocumentSegment = {  index: number

Usage

export function Batch({ pages }: { pages: SplitPage[] }) {
  return <DocumentSplits pages={pages} onSplitChange={saveSplits} />
}

How a split is stored

The state is not a list of documents. It is splitAfter: the page numbers that a break falls after. Everything else -- the segments, their order, how many there are -- is derived from that, which is why dragging a break never has to renumber anything.

// Twelve pages, broken into 1-3, 4-9, and 10-12.
<DocumentSplits pages={pages} defaultSplitAfter={[3, 9]} />

A break after the final page is ignored, since it would produce an empty segment. Duplicates and unsorted values are fine; the list is sorted before use.

Turning it into files

What a server needs is ranges, and they fall straight out of the same array. Do the conversion where the split is submitted rather than storing both, so there is only ever one description of where the breaks are.

function toRanges(pages, splitAfter) {
  const bounds = [...new Set(splitAfter)].sort((a, b) => a - b)
  const last = pages.at(-1).number
  const starts = [pages[0].number, ...bounds.map((page) => page + 1)]

  return starts
    .filter((start) => start <= last)
    .map((start, index) => ({ start, end: bounds[index] ?? last }))
}
Gives [{ start: 1, end: 3 }, { start: 4, end: 9 }, { start: 10, end: 12 }].

API

pagesSplitPage[]Page number, optional thumbnail src, and optional label.
splitAfter, defaultSplitAfternumber[]Page numbers a split follows, controlled or uncontrolled.
onSplitChange(splitAfter: number[]) => voidRuns with the sorted boundaries whenever they change.
segmentLabel(segment: DocumentSegment) => ReactNodeReplaces the default heading above each document.
renderImage(props) => ReactNodeUses a framework image component for thumbnails.

SplitPage

numbernumberThe page number. What splitAfter refers to.
srcstringThumbnail. Omit for a numbered placeholder.
labelstringExtra description for the page.

DocumentSegment

indexnumberPosition of the segment, from zero.
pagesSplitPage[]The pages it contains, in order.

Accessibility

Each split control is a toggle button naming the page it follows, so the action is clear without seeing the layout. No control is offered after the final page, since a split there would mean nothing. Segment headings state the document number and page count as text.