Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add pdf annotation support #6289

Draft
wants to merge 8 commits into
base: feat/pdf-support
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/blocks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"sortablejs": "^1.15.2",
"unified": "^11.0.4",
"webfontloader": "^1.6.28",
"y-utility": "^0.1.3",
"zod": "^3.22.4"
},
"exports": {
Expand Down
6 changes: 6 additions & 0 deletions packages/blocks/src/_common/components/hover/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export class HoverController implements ReactiveController {
return this._portal;
}

/**
* HoverController is a controller that creates portal when the refrence element is hovered.
* @param host The element that host that portal, usually a LitElement.
* @param onHover A function that returns the portal position and portal content when the host is hovered. Called when the reference element is hovered.
* @param hoverOptions
*/
constructor(
host: ReactiveControllerHost,
onHover: (options: OptionsParams) => HoverPortalOptions | null,
Expand Down
8 changes: 8 additions & 0 deletions packages/blocks/src/_specs/_specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import { ParagraphBlockSchema } from '../paragraph-block/paragraph-model.js';
import { ParagraphService } from '../paragraph-block/paragraph-service.js';
import { PDFBlockSchema } from '../pdf-block/pdf-model.js';
import { PDFService } from '../pdf-block/pdf-service.js';
import { AFFINE_ANNOTATION_PREVIEW_WIDGET } from '../pdf-block/widget/annotation-preview.js';
import { AFFINE_PDF_ANNOTATION_TOOLBAR_WIDGET } from '../pdf-block/widget/annotation-toolbar.js';
import { AFFINE_PDF_TOOLBAR_WIDGET } from '../pdf-block/widget/pdf-toolbar.js';
import { EdgelessRootService } from '../root-block/edgeless/edgeless-root-service.js';
import {
type EdgelessRootBlockWidgetName,
Expand Down Expand Up @@ -195,6 +198,11 @@ const CommonFirstPartyBlockSpecs: BlockSpec[] = [
schema: PDFBlockSchema,
view: {
component: literal`affine-pdf`,
widgets: {
[AFFINE_PDF_ANNOTATION_TOOLBAR_WIDGET]: literal`affine-pdf-annotation-toolbar`,
[AFFINE_ANNOTATION_PREVIEW_WIDGET]: literal`affine-annotation-preview`,
[AFFINE_PDF_TOOLBAR_WIDGET]: literal`affine-pdf-toolbar-widget`,
},
},
service: PDFService,
},
Expand Down
194 changes: 168 additions & 26 deletions packages/blocks/src/pdf-block/pdf-block.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { BlockElement } from '@blocksuite/lit';
import { css, html, nothing } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { PDFDocumentProxy } from 'pdfjs-dist';

import {
FrameNavigatorNextIcon,
FrameNavigatorPrevIcon,
} from '../_common/icons/edgeless.js';
import type { PDFBlockModel } from './pdf-model.js';
import type { EdgelessPageService } from '../page-block/edgeless/edgeless-page-service.js';
import type { PageService } from '../page-block/page-service.js';
import { AnnotationType, type PDFBlockModel } from './pdf-model.js';
import { PDFService } from './pdf-service.js';
import { PDFException } from './pdf-service.js';

export type PageChangeEvent = CustomEvent<{ page: number }>;

type PDFStatus =
| 'loaded'
| 'module-failed'
Expand Down Expand Up @@ -41,13 +46,26 @@ export class PDFBlockComponent extends BlockElement<PDFBlockModel, PDFService> {

.pdf-renderer-container {
text-align: center;
position: relative;

isolation: isolate;
}

.pdf-canvas {
display: block;
font-size: 1px;
}

.pdf-annotation-canvas {
display: block;

position: absolute;
left: 0;
top: 0;

opacity: 0.5;
}

.pdf-pagination {
display: none;

Expand All @@ -61,6 +79,7 @@ export class PDFBlockComponent extends BlockElement<PDFBlockModel, PDFService> {
color: white;
font-size: 0.75rem;
user-select: none;
z-index: 3;
}

.affine-pdf-block:hover .pdf-pagination {
Expand Down Expand Up @@ -99,6 +118,56 @@ export class PDFBlockComponent extends BlockElement<PDFBlockModel, PDFService> {
@query('.pdf-textlayer')
pdfTextLayer!: HTMLDivElement;

@query('.pdf-annotation-canvas')
pdfAnnotationCanvas!: HTMLCanvasElement;

@query('.affine-pdf-block')
pdfContainer!: HTMLDivElement;

private _pdfLayerRect: DOMRect | null = null;

get currentPage() {
return this._pdfPageNum;
}

get pageService() {
return this.std.spec.getService('affine:page') as
| PageService
| EdgelessPageService;
}

private async _setupPDF() {
if (!this.service.moduleLoaded) return;

const blob = await this.page.blob.get(this.model.sourceId);

if (!blob) {
this._status = 'file-failed';
return;
}

try {
const pdfDoc = await this.service.parsePDF(URL.createObjectURL(blob));

if (!this.isConnected || !this.pdfCanvas) return;

this._pdfDoc = pdfDoc;

await this._renderPage(1);
} catch (err) {
this._status =
err instanceof PDFException
? err.type === 'module'
? 'module-failed'
: 'file-failed'
: 'render-failed';

console.error(err);
} finally {
this.isConnected && this.requestUpdate();
}
}

private async _renderPage(num: number) {
try {
const { RENDERING_SCALE } = PDFBlockComponent;
Expand Down Expand Up @@ -135,6 +204,7 @@ export class PDFBlockComponent extends BlockElement<PDFBlockModel, PDFService> {
this.rendererConatiner.append(div);
textLayer.setTextContentSource(textContent);
await textLayer.render(textLayerViewport);
this._renderAnnotation();
} catch (e) {
console.error(e);
this._status = 'render-failed';
Expand All @@ -143,48 +213,107 @@ export class PDFBlockComponent extends BlockElement<PDFBlockModel, PDFService> {
}
}

private async _setupPDF() {
if (!this.service.moduleLoaded) return;
private _renderAnnotation() {
const annotations = this.model.getAnnotationsByPage(this._pdfPageNum);
const { pdfAnnotationCanvas, pdfCanvas } = this;
const context = pdfAnnotationCanvas.getContext('2d')!;

const blob = await this.page.blob.get(this.model.sourceId);
context.clearRect(
0,
0,
pdfAnnotationCanvas.width,
pdfAnnotationCanvas.height
);

if (!blob) {
this._status = 'file-failed';
return;
}
if (!annotations.length) return;

try {
const pdfDoc = await this.service.parsePDF(URL.createObjectURL(blob));
if (pdfAnnotationCanvas.width !== pdfCanvas.width) {
pdfAnnotationCanvas.width = pdfCanvas.width;
pdfAnnotationCanvas.height = pdfCanvas.height;
}

if (!this.isConnected || !this.pdfCanvas) return;
pdfAnnotationCanvas.style.width = pdfCanvas.style.width;
pdfAnnotationCanvas.style.height = pdfCanvas.style.height;

const renderingScale =
pdfAnnotationCanvas.width /
parseInt(pdfAnnotationCanvas.style.width.replace('px', '')) ?? 1;
context.fillStyle = 'rgba(255, 255, 0, 1)';
context.strokeStyle = 'rgba(0, 0, 255, 1)';
context.lineWidth = 2;
context.setLineDash([16, 5]);

annotations.forEach(({ annotation }) => {
const rects = annotation.get('highlightRects')?.[this._pdfPageNum] ?? [];

rects.forEach(([x, y, w, h]) => {
if (annotation.get('type') === AnnotationType.Text) {
context.fillRect(
x * renderingScale,
y * renderingScale,
w * renderingScale,
h * renderingScale
);
}

this._pdfDoc = pdfDoc;
if (annotation.get('type') === AnnotationType.Clip) {
context.strokeRect(
x * renderingScale,
y * renderingScale,
w * renderingScale,
h * renderingScale
);
}
});
});
}

await this._renderPage(1);
} catch (err) {
this._status =
err instanceof PDFException
? err.type === 'module'
? 'module-failed'
: 'file-failed'
: 'render-failed';
private _setPage(num: number) {
if (num < 1 || num > this._pdfDoc.numPages) return;

console.error(err);
} finally {
this.isConnected && this.requestUpdate();
}
this._pdfPageNum = num;
this._renderPage(num).catch(() => {});
this.dispatchEvent(
new CustomEvent('pagechange', {
detail: {
page: num,
},
})
);
}

private _prev() {
if (this._pdfPageNum === 1) return;

this._renderPage(--this._pdfPageNum).catch(() => {});
this._setPage(this._pdfPageNum - 1);
}

private _next() {
if (this._pdfPageNum === this._pdfDoc.numPages) return;

this._renderPage(++this._pdfPageNum).catch(() => {});
this._setPage(this._pdfPageNum + 1);
}

toPDFCoords(x: number, y: number) {
this._pdfLayerRect = this.pdfCanvas.getBoundingClientRect();

if ('viewport' in this.pageService) {
const zoom = this.pageService.viewport.zoom;

return {
x: (x - this._pdfLayerRect.x) / zoom,
y: (y - this._pdfLayerRect.y) / zoom,
};
}

return {
x: x - this._pdfLayerRect.x,
y: y - this._pdfLayerRect.y,
};
}

enableTextSelection(enable: boolean) {
this.pdfTextLayer.style.pointerEvents = enable ? 'auto' : 'none';
}

override connectedCallback() {
Expand All @@ -199,6 +328,11 @@ export class PDFBlockComponent extends BlockElement<PDFBlockModel, PDFService> {
}
})
);
this._disposables.add(
this.model.annotationUpdated.on(() => {
this._renderAnnotation();
})
);
}

override firstUpdated() {
Expand All @@ -218,6 +352,12 @@ export class PDFBlockComponent extends BlockElement<PDFBlockModel, PDFService> {
return html`<div class="affine-pdf-block">PDF module is not loaded</div>`;
}

const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;

switch (this._status) {
case 'module-failed':
case 'file-failed':
Expand All @@ -230,6 +370,7 @@ export class PDFBlockComponent extends BlockElement<PDFBlockModel, PDFService> {
return html`<div class="affine-pdf-block">
<div class="pdf-renderer-container">
<canvas class="pdf-canvas"></canvas>
<canvas class="pdf-annotation-canvas"></canvas>
</div>
${this._pdfDoc
? html`<div class="pdf-pagination">
Expand All @@ -242,6 +383,7 @@ export class PDFBlockComponent extends BlockElement<PDFBlockModel, PDFService> {
</button>
</div>`
: nothing}
${widgets}
</div>`;
}
}
Expand Down