Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19,678 changes: 19,678 additions & 0 deletions .storybook/public/pdf.mjs

Large diffs are not rendered by default.

56,289 changes: 56,289 additions & 0 deletions .storybook/public/pdf.worker.mjs

Large diffs are not rendered by default.

787 changes: 766 additions & 21 deletions package-lock.json

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions packages/InteractivePdf/DocumentController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Controller } from '@a11d/lit'
import { type PDFDocumentProxy } from 'pdfjs-dist'
import { type InteractivePdf } from './InteractivePdf.js'
import { PDFDocument } from 'pdf-lib'

declare var pdfjsLib: typeof import('pdfjs-dist')

export class DocumentController extends Controller {
override host!: InteractivePdf

static idleTimeout = 3_000

private static _workerUrl: string

static get workerUrl() {
if (!this._workerUrl) {
throw new Error('Worker url is not set!')
}
return this._workerUrl
}

static set workerUrl(value) {
this._workerUrl = value
if (!('pdfjsLib' in window)) {
throw new Error('Rendering library is missing!')
}
pdfjsLib.GlobalWorkerOptions.workerSrc = DocumentController.workerUrl
}

private document!: PDFDocumentProxy

private currentSource?: string

get numberOfPages() {
return this.document?.numPages ?? 0
}

override async hostUpdated() {
if (!('pdfjsLib' in window)) {
setTimeout(() => this.host.requestUpdate(), DocumentController.idleTimeout)
return
}
if (this.host.source === this.currentSource || !this.host.source) {
return
}
this.host.loading = true
this.currentSource = this.host.source
this.document = await pdfjsLib.getDocument(this.currentSource).promise
this.host.requestUpdate()
requestAnimationFrame(() => this.render())
}

private async render() {
for (let i = 0; i < this.numberOfPages; i++) {
const page = await this.document.getPage(i + 1);
const viewport = page.getViewport({ scale: 3 })
const documentNode = this.host.documentNodes[i]!
documentNode.width = viewport.width
documentNode.height = viewport.height
documentNode.style.width = `${viewport.width / viewport.scale}px`
documentNode.dataset.width = (viewport.width / (viewport.scale * 2)).toString()
documentNode.dataset.height = (viewport.height / (viewport.scale * 2)).toString()
page.render({ canvasContext: documentNode.getContext('2d')!, viewport })
}

this.host['fabricController'].render()

this.host.loading = false
}

async fetchNatively() {
const response = await fetch(this.host.source, {
// Get rid to test in the Storybook (leave only `fetch(this.host.source)`)
credentials: 'include',
})

const binary = await response.blob()
const reader = new FileReader()
const arrayBuffer = await new Promise<ArrayBuffer | null>(resolve => {
reader.addEventListener('loadend', () => resolve(reader.result as ArrayBuffer))
reader.addEventListener('error', () => resolve(null))
reader.readAsArrayBuffer(binary)
})

return arrayBuffer
}

async mergeWithFiber(arrayBuffer: ArrayBuffer) {
const file = await PDFDocument.load(arrayBuffer);
const pages = file.getPages()

for (let i = 0; i < pages.length; i++) {
const { width, height } = pages[i]!.getSize()
const image = this.host.fabricNodes[i]!.toDataURL('image/png')
pages[i]?.drawImage(await file.embedPng(image), { x: 0, y: 0, width, height })
}

return new Blob([await file.save()], { type: 'application/pdf' })
}
}
226 changes: 226 additions & 0 deletions packages/InteractivePdf/FabricController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { Controller } from '@a11d/lit'
import { type InteractivePdf } from './InteractivePdf.js'
import * as fabric from 'fabric'

type FabricEvent = { target: fabric.FabricObject }

export enum FabricMode { Brush, Text, Picture }

class PictureReader {
read(): Promise<fabric.FabricImage | null> {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'

return new Promise((resolve) => {
input.addEventListener('change', (e) => {
const file = (e.target as HTMLInputElement).files?.item(0)
const reader = new FileReader()
reader.addEventListener('load', (e) => {
const image = new Image()
image.addEventListener('load', () => {
resolve(
new fabric.FabricImage(image, { left: 100, top: 100, angle: 0, opacity: 1 })
)
})
image.addEventListener('error', () => resolve(null))
image.src = e.target!.result as string
})
reader.readAsDataURL(file!)
})

input.click()
})
}
}

export class FabricController extends Controller {
override host!: InteractivePdf

private fabricNodes = new Array<fabric.Canvas>()

brush = {
color: 'black',
}

fontStyle: Partial<fabric.ITextProps> = {
fontFamily: 'Arial',
fill: 'black',
fontSize: 20,
}

private _mode?: FabricMode

get mode() {
return this._mode
}

set mode(value) {
this._mode = value
this.host.requestUpdate()
}

render() {
this.fabricNodes = this.host.fabricNodes.map((fabricNode, i) => {
const { clientWidth, clientHeight } = this.host.documentNodes[i]!
const ctx = new fabric.Canvas(fabricNode, { width: clientWidth, height: clientHeight })
ctx.on('object:added', (e) => this.objectAdded(ctx, e))
return ctx
})

this.host.addEventListener('keydown', (e) => {
if (e.key === 'Backspace') {
const activeStage = this.fabricNodes.find(ctx => ctx.getActiveObject())!
const activeObjects = activeStage?.getActiveObjects();

if (!activeObjects.length) {
return
}

activeObjects.forEach(() => {
activeStage.remove(...activeObjects);
activeStage.renderAll();
})
}
});
}

private objectAdded(ctx: fabric.Canvas, e: FabricEvent) {
e.target.set({
editable: true,
selectable: true,
hasControls: true,
hasBorders: true,
lockMovementX: false,
lockMovementY: false,
})

ctx.setActiveObject(e.target)
}

setMode(mode: FabricMode) {
if (this.mode === mode) {
return this.exitMode()
}
this.mode = mode
switch (mode) {
case FabricMode.Brush:
return this.useBrush()
case FabricMode.Text:
return this.useText()
case FabricMode.Picture:
return this.usePicture()
default:
break
}
}

exitMode() {
this.mode = undefined
this.fabricNodes.forEach(ctx => {
ctx.freeDrawingBrush = undefined
ctx.isDrawingMode = false
})
}

private useBrush() {
this.fabricNodes.forEach(ctx => {
const brush = new fabric.PatternBrush(ctx)
brush.getPatternSrc = () => this.getBrushPattern()
ctx.freeDrawingBrush = brush
ctx.isDrawingMode = true
ctx.once('mouse:up', () => this.exitMode())
})
}

setCurrentColor(color: string) {
this.brush.color = color
this.fontStyle.fill = color

const activeStage = this.fabricNodes.find(ctx => ctx.getActiveObject())

if (!activeStage) {
return this.host.requestUpdate()
}

activeStage.getActiveObjects()
.forEach(activeObject => this.setObjectColor(activeObject, color))

this.host.requestUpdate()
}

private setObjectColor = (activeObject: fabric.FabricObject, color: string) => {
if (activeObject.type.includes('text')) {
activeObject.set('fill', color)
}

if (activeObject.type === 'line' || activeObject.type === 'path') {
activeObject.set('stroke', color)
}

activeObject.canvas?.renderAll()
}

private usePicture = async () => {
const picture = await new PictureReader().read()

if (!picture) {
return this.exitMode()
}

let isPlacingPicture = true

this.fabricNodes.forEach(ctx => {
ctx.once('mouse:down', (e) => {
if (!isPlacingPicture) {
return
}

isPlacingPicture = false
this.exitMode()

const pointer = ctx.getViewportPoint(e.e)
picture.setX(pointer.x - picture.width / 2)
picture.setY(pointer.y - picture.height / 2)

ctx.add(picture)
ctx.setActiveObject(picture)
})
})
}

private useText = () => {
let isPlacingText = true

this.fabricNodes.forEach((ctx) => {
ctx.once('mouse:down', (e) => {
if (!isPlacingText) {
return
}

isPlacingText = false

this.exitMode()

const pointer = ctx.getViewportPoint(e.e)

const text = new fabric.IText('', { left: pointer.x, top: pointer.y, ...this.fontStyle })

ctx.add(text)
ctx.setActiveObject(text)
text.enterEditing()
})
})
}

private getBrushPattern() {
const pattern = document.createElement('canvas')
pattern.width = pattern.height = 10

const ctx = pattern.getContext('2d')!
ctx.fillStyle = this.brush.color
ctx.fillRect(0, 0, pattern.width, pattern.height)

return pattern
}
}
30 changes: 30 additions & 0 deletions packages/InteractivePdf/InteractivePdf.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/web-components'
import { html } from '@a11d/lit'
import p from './package.json'
import { DocumentController } from './DocumentController.js'
import './index.js'

export default {
title: 'Interactive Pdf',
component: 'mo-interactive-pdf',
package: p,
} as Meta

export const InteractivePdf: StoryObj = {
render: () => {
requestIdleCallback(() => {
DocumentController.workerUrl = './pdf.worker.mjs'
})
return html`
<script type="module" src="./pdf.mjs"></script>

<style>
mo-interactive-pdf::part(viewer) {
min-height: 600px;
}
</style>

<mo-interactive-pdf name='Fiber' source='https://pdfobject.com/pdf/sample.pdf'></mo-interactive-pdf>
`
}
}
Loading