PDF editor example (#3159)

This diff adds a PDF editor example. It's pretty similar to the image
annotator, but is a better way to demo longer axis-locked scrolling.
There are some pretty big drawbacks to it at the moment (see the TODO
list on `PdfEditor.tsx`)

I'm going to land as-is for now, and we can iterate on it in the future.

### Change Type
- [x] `docs` — Changes to the documentation, examples, or templates.
- [x] `feature` — New feature
This commit is contained in:
alex 2024-03-19 11:55:21 +00:00 committed by GitHub
parent 3a736007e5
commit 4c5c3daa51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 688 additions and 1 deletions

View file

@ -11,6 +11,8 @@ const examplesWithoutCanvas = [
'yjs',
// starts by asking the user to select an image
'image-annotator',
// starts by asking the user to open a pdf
'pdf-editor',
]
const exampelsToTest = examplesFolderList.filter((route) => !examplesWithoutCanvas.includes(route))

View file

@ -40,6 +40,8 @@
"classnames": "^2.3.2",
"lazyrepo": "0.0.0-alpha.27",
"lodash": "^4.17.21",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.0.379",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.17.0",

View file

@ -22,6 +22,7 @@ import { AnnotatorImage } from './types'
// TODO:
// - prevent changing pages (create page, change page, move shapes to new page)
// - prevent locked shape context menu
// - inertial scrolling for constrained camera
export function ImageAnnotationEditor({
image,
onDone,

View file

@ -0,0 +1,89 @@
import { PDFDocument } from 'pdf-lib'
import { useState } from 'react'
import { Editor, assert, exportToBlob, useEditor } from 'tldraw'
import { Pdf } from './PdfPicker'
export function ExportPdfButton({ pdf }: { pdf: Pdf }) {
const [exportProgress, setExportProgress] = useState<number | null>(null)
const editor = useEditor()
return (
<button
className="ExportPdfButton"
onClick={async () => {
setExportProgress(0)
try {
await exportPdf(editor, pdf, setExportProgress)
} finally {
setExportProgress(null)
}
}}
>
{exportProgress ? `Exporting... ${Math.round(exportProgress * 100)}%` : 'Export PDF'}
</button>
)
}
async function exportPdf(
editor: Editor,
{ name, source, pages }: Pdf,
onProgress: (progress: number) => void
) {
const totalThings = pages.length * 2 + 2
let progressCount = 0
const tickProgress = () => {
progressCount++
onProgress(progressCount / totalThings)
}
const pdf = await PDFDocument.load(source)
tickProgress()
const pdfPages = pdf.getPages()
assert(pdfPages.length === pages.length, 'PDF page count mismatch')
const pageShapeIds = new Set(pages.map((page) => page.shapeId))
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter((id) => !pageShapeIds.has(id))
for (let i = 0; i < pages.length; i++) {
const page = pages[i]
const pdfPage = pdfPages[i]
const bounds = page.bounds
const shapesInBounds = allIds.filter((id) => {
const shapePageBounds = editor.getShapePageBounds(id)
if (!shapePageBounds) return false
return shapePageBounds.collides(bounds)
})
if (shapesInBounds.length === 0) {
tickProgress()
tickProgress()
continue
}
const exportedPng = await exportToBlob({
editor,
ids: allIds,
format: 'png',
opts: { background: false, bounds: page.bounds, padding: 0, scale: 2 },
})
tickProgress()
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight(),
})
tickProgress()
}
const url = URL.createObjectURL(new Blob([await pdf.save()], { type: 'application/pdf' }))
tickProgress()
const a = document.createElement('a')
a.href = url
a.download = name
a.click()
URL.revokeObjectURL(url)
}

View file

@ -0,0 +1,289 @@
import { useCallback, useEffect, useMemo } from 'react'
import {
Box,
SVGContainer,
TLImageShape,
TLShapeId,
TLShapePartial,
Tldraw,
clamp,
compact,
getIndicesBetween,
react,
sortByIndex,
track,
useBreakpoint,
useEditor,
} from 'tldraw'
import { PORTRAIT_BREAKPOINT } from 'tldraw/src/lib/ui/constants'
import { ExportPdfButton } from './ExportPdfButton'
import { Pdf } from './PdfPicker'
// TODO:
// - prevent changing pages (create page, change page, move shapes to new page)
// - prevent locked shape context menu
// - inertial scrolling for constrained camera
// - render pages on-demand instead of all at once.
export function PdfEditor({ pdf }: { pdf: Pdf }) {
const pdfShapeIds = useMemo(() => pdf.pages.map((page) => page.shapeId), [pdf.pages])
return (
<Tldraw
onMount={(editor) => {
editor.updateInstanceState({ isDebugMode: false })
editor.setCamera({ x: 1000, y: 1000, z: 1 })
editor.createAssets(
pdf.pages.map((page) => ({
id: page.assetId,
typeName: 'asset',
type: 'image',
meta: {},
props: {
w: page.bounds.w,
h: page.bounds.h,
mimeType: 'image/png',
src: page.src,
name: 'page',
isAnimated: false,
},
}))
)
editor.createShapes(
pdf.pages.map(
(page): TLShapePartial<TLImageShape> => ({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
assetId: page.assetId,
w: page.bounds.w,
h: page.bounds.h,
},
})
)
)
}}
components={{
PageMenu: null,
InFrontOfTheCanvas: useCallback(() => {
return <PageOverlayScreen pdf={pdf} />
}, [pdf]),
SharePanel: useCallback(() => {
return <ExportPdfButton pdf={pdf} />
}, [pdf]),
}}
>
<ConstrainCamera pdf={pdf} />
<KeepShapesLocked shapeIds={pdfShapeIds} />
<KeepShapesAtBottomOfCurrentPage shapeIds={pdfShapeIds} />
</Tldraw>
)
}
const PageOverlayScreen = track(function PageOverlayScreen({ pdf }: { pdf: Pdf }) {
const editor = useEditor()
const viewportPageBounds = editor.getViewportPageBounds()
const viewportScreenBounds = editor.getViewportScreenBounds()
const relevantPageBounds = compact(
pdf.pages.map((page) => {
if (!viewportPageBounds.collides(page.bounds)) return null
const topLeft = editor.pageToViewport(page.bounds)
const bottomRight = editor.pageToViewport({ x: page.bounds.maxX, y: page.bounds.maxY })
return new Box(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y)
})
)
function pathForPageBounds(bounds: Box) {
return `M ${bounds.x} ${bounds.y} L ${bounds.maxX} ${bounds.y} L ${bounds.maxX} ${bounds.maxY} L ${bounds.x} ${bounds.maxY} Z`
}
const viewportPath = `M 0 0 L ${viewportScreenBounds.w} 0 L ${viewportScreenBounds.w} ${viewportScreenBounds.h} L 0 ${viewportScreenBounds.h} Z`
return (
<>
<SVGContainer className="PageOverlayScreen-screen">
<path
d={`${viewportPath} ${relevantPageBounds.map(pathForPageBounds).join(' ')}`}
fillRule="evenodd"
/>
</SVGContainer>
{relevantPageBounds.map((bounds, i) => (
<div
key={i}
className="PageOverlayScreen-outline"
style={{
width: bounds.w,
height: bounds.h,
transform: `translate(${bounds.x}px, ${bounds.y}px)`,
}}
/>
))}
</>
)
})
function ConstrainCamera({ pdf }: { pdf: Pdf }) {
const editor = useEditor()
const breakpoint = useBreakpoint()
const isMobile = breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
useEffect(() => {
const marginTop = 64
const marginSide = isMobile ? 16 : 164
const marginBottom = 80
const targetBounds = pdf.pages.reduce(
(acc, page) => acc.union(page.bounds),
pdf.pages[0].bounds.clone()
)
function constrainCamera(camera: { x: number; y: number; z: number }): {
x: number
y: number
z: number
} {
const viewportBounds = editor.getViewportScreenBounds()
const usableViewport = new Box(
marginSide,
marginTop,
viewportBounds.w - marginSide * 2,
viewportBounds.h - marginTop - marginBottom
)
const minZoom = Math.min(
usableViewport.w / targetBounds.w,
usableViewport.h / targetBounds.h,
1
)
const zoom = Math.max(minZoom, camera.z)
const centerX = targetBounds.x - targetBounds.w / 2 + usableViewport.midX / zoom
const centerY = targetBounds.y - targetBounds.h / 2 + usableViewport.midY / zoom
const availableXMovement = Math.max(0, targetBounds.w - usableViewport.w / zoom)
const availableYMovement = Math.max(0, targetBounds.h - usableViewport.h / zoom)
return {
x: clamp(camera.x, centerX - availableXMovement / 2, centerX + availableXMovement / 2),
y: clamp(camera.y, centerY - availableYMovement / 2, centerY + availableYMovement / 2),
z: zoom,
}
}
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler(
'camera',
(_prev, next) => {
const constrained = constrainCamera(next)
if (constrained.x === next.x && constrained.y === next.y && constrained.z === next.z)
return next
return { ...next, ...constrained }
}
)
const removeReaction = react('update camera when viewport/shape changes', () => {
const original = editor.getCamera()
const constrained = constrainCamera(original)
if (
original.x === constrained.x &&
original.y === constrained.y &&
original.z === constrained.z
) {
return
}
// this needs to be in a microtask for some reason, but idk why
queueMicrotask(() => editor.setCamera(constrained))
})
return () => {
removeOnChange()
removeReaction()
}
}, [editor, isMobile, pdf.pages])
return null
}
function KeepShapesLocked({ shapeIds }: { shapeIds: TLShapeId[] }) {
const editor = useEditor()
useEffect(() => {
const shapeIdSet = new Set(shapeIds)
for (const shapeId of shapeIdSet) {
const shape = editor.getShape(shapeId)!
editor.updateShape({
id: shape.id,
type: shape.type,
isLocked: true,
})
}
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => {
if (!shapeIdSet.has(next.id)) return next
if (next.isLocked) return next
return { ...prev, isLocked: true }
})
return () => {
removeOnChange()
}
}, [editor, shapeIds])
return null
}
function KeepShapesAtBottomOfCurrentPage({ shapeIds }: { shapeIds: TLShapeId[] }) {
const editor = useEditor()
useEffect(() => {
const shapeIdSet = new Set(shapeIds)
function makeSureShapesAreAtBottom() {
const shapes = shapeIds.map((id) => editor.getShape(id)!).sort(sortByIndex)
const pageId = editor.getCurrentPageId()
const siblings = editor.getSortedChildIdsForParent(pageId)
const currentBottomShapes = siblings.slice(0, shapes.length).map((id) => editor.getShape(id)!)
if (currentBottomShapes.every((shape, i) => shape.id === shapes[i].id)) return
const otherSiblings = siblings.filter((id) => !shapeIdSet.has(id))
const bottomSibling = otherSiblings[0]
const lowestIndex = editor.getShape(bottomSibling)!.index
const indexes = getIndicesBetween(undefined, lowestIndex, shapes.length)
editor.updateShapes(
shapes.map((shape, i) => ({
id: shape.id,
type: shape.type,
isLocked: shape.isLocked,
index: indexes[i],
}))
)
}
makeSureShapesAreAtBottom()
const removeOnCreate = editor.sideEffects.registerAfterCreateHandler(
'shape',
makeSureShapesAreAtBottom
)
const removeOnChange = editor.sideEffects.registerAfterChangeHandler(
'shape',
makeSureShapesAreAtBottom
)
return () => {
removeOnCreate()
removeOnChange()
}
}, [editor, shapeIds])
return null
}

View file

@ -0,0 +1,32 @@
import { useState } from 'react'
import { PdfEditor } from './PdfEditor'
import { Pdf, PdfPicker } from './PdfPicker'
import './pdf-editor.css'
type State =
| {
phase: 'pick'
}
| {
phase: 'edit'
pdf: Pdf
}
export default function PdfEditorWrapper() {
const [state, setState] = useState<State>({ phase: 'pick' })
switch (state.phase) {
case 'pick':
return (
<div className="PdfEditor">
<PdfPicker onOpenPdf={(pdf) => setState({ phase: 'edit', pdf })} />
</div>
)
case 'edit':
return (
<div className="PdfEditor">
<PdfEditor pdf={state.pdf} />
</div>
)
}
}

View file

@ -0,0 +1,118 @@
import { useState } from 'react'
import { AssetRecordType, Box, TLAssetId, TLShapeId, assertExists, createShapeId } from 'tldraw'
import tldrawPdf from './assets/tldraw.pdf'
export type PdfPage = {
src: string
bounds: Box
assetId: TLAssetId
shapeId: TLShapeId
}
export type Pdf = {
name: string
pages: PdfPage[]
source: string | ArrayBuffer
}
const pageSpacing = 32
export function PdfPicker({ onOpenPdf }: { onOpenPdf: (pdf: Pdf) => void }) {
const [isLoading, setIsLoading] = useState(false)
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
const PdfJS = await import('pdfjs-dist')
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString()
const pdf = await PdfJS.getDocument(source.slice(0)).promise
const pages: PdfPage[] = []
const canvas = window.document.createElement('canvas')
const context = assertExists(canvas.getContext('2d'))
const visualScale = 1.5
const scale = window.devicePixelRatio
let top = 0
let widest = 0
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const viewport = page.getViewport({ scale: scale * visualScale })
canvas.width = viewport.width
canvas.height = viewport.height
const renderContext = {
canvasContext: context,
viewport,
}
await page.render(renderContext).promise
const width = viewport.width / scale
const height = viewport.height / scale
pages.push({
src: canvas.toDataURL(),
bounds: new Box(0, top, width, height),
assetId: AssetRecordType.createId(),
shapeId: createShapeId(),
})
top += height + pageSpacing
widest = Math.max(widest, width)
}
canvas.width = 0
canvas.height = 0
for (const page of pages) {
page.bounds.x = (widest - page.bounds.width) / 2
}
return {
name,
pages,
source,
}
}
function onClickOpenPdf() {
const input = window.document.createElement('input')
input.type = 'file'
input.accept = 'application/pdf'
input.addEventListener('change', async (e) => {
const fileList = (e.target as HTMLInputElement).files
if (!fileList || fileList.length === 0) return
const file = fileList[0]
setIsLoading(true)
try {
const pdf = await loadPdf(file.name, await file.arrayBuffer())
onOpenPdf(pdf)
} finally {
setIsLoading(false)
}
})
input.click()
}
async function onClickUseExample() {
setIsLoading(true)
try {
const result = await fetch(tldrawPdf)
const pdf = await loadPdf('tldraw.pdf', await result.arrayBuffer())
onOpenPdf(pdf)
} finally {
setIsLoading(false)
}
}
if (isLoading) {
return <div className="PdfPicker">Loading...</div>
}
return (
<div className="PdfPicker">
<button onClick={onClickOpenPdf}>Open PDF</button>
<div>or</div>
<button onClick={onClickUseExample}>Use an example</button>
</div>
)
}

View file

@ -0,0 +1,8 @@
---
title: PDF editor
component: ./PdfEditorExample.tsx
category: use-cases
priority: 1
---
A very basic PDF editor built with tldraw

View file

@ -0,0 +1,66 @@
.PdfEditor {
position: absolute;
inset: 0;
}
.PdfEditor .PdfPicker {
position: absolute;
inset: 1rem;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
flex-direction: column;
gap: 1rem;
}
.PdfEditor .PdfPicker button {
padding: 0.5rem 1rem;
border: none;
background: #eee;
cursor: pointer;
font: inherit;
}
.PdfEditor .PdfPicker button:hover {
opacity: 0.9;
}
.PdfEditor .PdfBgRenderer {
position: absolute;
pointer-events: none;
}
.PdfEditor .PdfBgRenderer img {
position: absolute;
}
.PdfEditor .PageOverlayScreen-screen {
pointer-events: none;
z-index: -1;
fill: var(--color-background);
fill-opacity: 0.8;
stroke: none;
}
.PdfEditor .PageOverlayScreen-outline {
position: absolute;
pointer-events: none;
z-index: -1;
/* border: 1px solid var(--color-overlay); */
box-shadow: var(--shadow-2);
}
.PdfEditor .ExportPdfButton {
font: inherit;
background: var(--color-primary);
border: none;
color: var(--color-selected-contrast);
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 6px;
margin: 6px;
margin-bottom: 0;
pointer-events: all;
z-index: var(--layer-panels);
border: 2px solid var(--color-background);
cursor: pointer;
}
.PdfEditor .ExportPdfButton:hover {
filter: brightness(1.1);
}

View file

@ -9,6 +9,10 @@ export default defineConfig({
build: {
outDir: path.join(__dirname, 'dist'),
assetsInlineLimit: 0,
target: 'es2022',
},
esbuild: {
target: 'es2022',
},
server: {
port: 5420,
@ -16,6 +20,9 @@ export default defineConfig({
clearScreen: false,
optimizeDeps: {
exclude: ['@tldraw/assets'],
esbuildOptions: {
target: 'es2022',
},
},
define: {
'process.env.TLDRAW_ENV': JSON.stringify(process.env.VERCEL_ENV ?? 'development'),

View file

@ -104,10 +104,15 @@
"typescript": "^5.3.3",
"vercel": "^28.16.15"
},
"// resolutions.canvas": [
"our examples app depenends on pdf.js which pulls in canvas as an optional dependency.",
"it slows down installs quite a bit though, so we replace it with an empty package."
],
"resolutions": {
"@microsoft/api-extractor@^7.35.4": "patch:@microsoft/api-extractor@npm%3A7.35.4#./.yarn/patches/@microsoft-api-extractor-npm-7.35.4-5f4f0357b4.patch",
"vectra@^0.4.4": "patch:vectra@npm%3A0.4.4#./.yarn/patches/vectra-npm-0.4.4-6aac3f6c29.patch",
"domino@^2.1.6": "patch:domino@npm%3A2.1.6#./.yarn/patches/domino-npm-2.1.6-b0dc3de857.patch"
"domino@^2.1.6": "patch:domino@npm%3A2.1.6#./.yarn/patches/domino-npm-2.1.6-b0dc3de857.patch",
"canvas": "npm:empty-npm-package@1.0.0"
},
"dependencies": {
"@sentry/cli": "^2.25.0",

View file

@ -4711,6 +4711,24 @@ __metadata:
languageName: node
linkType: hard
"@pdf-lib/standard-fonts@npm:^1.0.0":
version: 1.0.0
resolution: "@pdf-lib/standard-fonts@npm:1.0.0"
dependencies:
pako: "npm:^1.0.6"
checksum: 0dfcedbd16e3f48afcac321f153d81f49ea6030030fa1e05a07bde359629949e27b3d77b39a5c7b0b0ff8af09912765f62a911cd67d488d4dc0b3c1c5ca106f0
languageName: node
linkType: hard
"@pdf-lib/upng@npm:^1.0.1":
version: 1.0.1
resolution: "@pdf-lib/upng@npm:1.0.1"
dependencies:
pako: "npm:^1.0.10"
checksum: eecac72f36d51b67acb09f61a7bbb55577ceb6232aa0c1a827aea003126787cede07ad391c0c789ab36164a9a86d1634768c2cf941148743e155de7ed6c34fc3
languageName: node
linkType: hard
"@peculiar/asn1-schema@npm:^2.3.6":
version: 2.3.8
resolution: "@peculiar/asn1-schema@npm:2.3.8"
@ -10267,6 +10285,13 @@ __metadata:
languageName: node
linkType: hard
"canvas@npm:empty-npm-package@1.0.0":
version: 1.0.0
resolution: "empty-npm-package@npm:1.0.0"
checksum: 745b1e85c1c3f42d5960fc5729e6ad6114a41af9425a673b1f3493c0fb431273d48e30170bcfaf8141feca15f95ca141c8237aefee8ee84fd34586f06ee62368
languageName: node
linkType: hard
"capnp-ts@npm:^0.7.0":
version: 0.7.0
resolution: "capnp-ts@npm:0.7.0"
@ -13275,6 +13300,8 @@ __metadata:
dotenv: "npm:^16.3.1"
lazyrepo: "npm:0.0.0-alpha.27"
lodash: "npm:^4.17.21"
pdf-lib: "npm:^1.17.1"
pdfjs-dist: "npm:^4.0.379"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-router-dom: "npm:^6.17.0"
@ -19663,6 +19690,13 @@ __metadata:
languageName: node
linkType: hard
"pako@npm:^1.0.10, pako@npm:^1.0.11, pako@npm:^1.0.6":
version: 1.0.11
resolution: "pako@npm:1.0.11"
checksum: 1ad07210e894472685564c4d39a08717e84c2a68a70d3c1d9e657d32394ef1670e22972a433cbfe48976cb98b154ba06855dcd3fcfba77f60f1777634bec48c0
languageName: node
linkType: hard
"pako@npm:~0.2.0":
version: 0.2.9
resolution: "pako@npm:0.2.9"
@ -19907,6 +19941,13 @@ __metadata:
languageName: node
linkType: hard
"path2d-polyfill@npm:^2.0.1":
version: 2.0.1
resolution: "path2d-polyfill@npm:2.0.1"
checksum: 82b26ef1a737560bc7a974919aec5d1c316d923b39cdf3aae2f20cd77b08d6c5256a5605f178d7dc010de7c886eeac0c263a590653687f8aea60fabbc0582890
languageName: node
linkType: hard
"pathe@npm:^1.1.0, pathe@npm:^1.1.2":
version: 1.1.2
resolution: "pathe@npm:1.1.2"
@ -19914,6 +19955,33 @@ __metadata:
languageName: node
linkType: hard
"pdf-lib@npm:^1.17.1":
version: 1.17.1
resolution: "pdf-lib@npm:1.17.1"
dependencies:
"@pdf-lib/standard-fonts": "npm:^1.0.0"
"@pdf-lib/upng": "npm:^1.0.1"
pako: "npm:^1.0.11"
tslib: "npm:^1.11.1"
checksum: ea8c2c0c813d89ca359a0c831cb2e8a581c569ed44b074316f917942b5baa101f878ce922f1cb87e6f41a035008ba75b52267480aee5d65a5e68c445ee83bc37
languageName: node
linkType: hard
"pdfjs-dist@npm:^4.0.379":
version: 4.0.379
resolution: "pdfjs-dist@npm:4.0.379"
dependencies:
canvas: "npm:^2.11.2"
path2d-polyfill: "npm:^2.0.1"
dependenciesMeta:
canvas:
optional: true
path2d-polyfill:
optional: true
checksum: 9abea0cd20e3a209537be81092055e247dc10cadc08c840ad4b2495e1e6d512b3f3208dfe12f47389af6b9f078a6e00b7bb9d950fe4dbe9f214ef726f8c2815f
languageName: node
linkType: hard
"peek-stream@npm:^1.1.0":
version: 1.1.3
resolution: "peek-stream@npm:1.1.3"