diff --git a/package-lock.json b/package-lock.json index 01aa2b2..b3b3d48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,14 @@ { "name": "webviewer", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "webviewer", - "version": "2.0.0", + "version": "2.1.0", "dependencies": { - "@pdftron/webviewer": "^10.0.0-20230412", + "@pdftron/webviewer": "^10.1.0-20230523", "classnames": "^2.2.6", "lodash": "^4.17.21" }, @@ -2958,9 +2958,9 @@ } }, "node_modules/@pdftron/webviewer": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@pdftron/webviewer/-/webviewer-10.0.0.tgz", - "integrity": "sha512-PD9xqNmdXt87H3wJwBtGOG3oy1ZfXElyt7cpe0pTMb+w94EG/F57sfAblHjBrdmCCld4hxYGkb9VOFbOaoC/Rg==" + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@pdftron/webviewer/-/webviewer-10.1.0.tgz", + "integrity": "sha512-ZGpVO02qfM9u/kAZpG5io6xHiwhz4IKWIsUYX+HgPbotaQpeLYJjZjUni/99UPQCMOVVkC2mNZcIlOlJWKK3UQ==" }, "node_modules/@prettier/plugin-xml": { "version": "1.2.0", @@ -20625,9 +20625,9 @@ } }, "@pdftron/webviewer": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@pdftron/webviewer/-/webviewer-10.0.0.tgz", - "integrity": "sha512-PD9xqNmdXt87H3wJwBtGOG3oy1ZfXElyt7cpe0pTMb+w94EG/F57sfAblHjBrdmCCld4hxYGkb9VOFbOaoC/Rg==" + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@pdftron/webviewer/-/webviewer-10.1.0.tgz", + "integrity": "sha512-ZGpVO02qfM9u/kAZpG5io6xHiwhz4IKWIsUYX+HgPbotaQpeLYJjZjUni/99UPQCMOVVkC2mNZcIlOlJWKK3UQ==" }, "@prettier/plugin-xml": { "version": "1.2.0", diff --git a/package.json b/package.json index e28156f..618c563 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webviewer", "widgetName": "WebViewer", - "version": "2.0.0", + "version": "2.1.0", "description": "My widget description", "copyright": "2023 Apryse", "author": "Andrey Safonov", @@ -30,7 +30,7 @@ "@types/react-dom": "~18.0.5" }, "dependencies": { - "@pdftron/webviewer": "^10.0.0-20230412", + "@pdftron/webviewer": "^10.1.0-20230523", "classnames": "^2.2.6", "lodash": "^4.17.21" } diff --git a/src/WebViewer.xml b/src/WebViewer.xml index d1c64cc..07ff996 100644 --- a/src/WebViewer.xml +++ b/src/WebViewer.xml @@ -36,6 +36,16 @@ Switches WebViewer to Office editing. + + + Enable page extraction + Enable page extraction so that users can select which pages they want to save to another file. + + + Allow extraction download + Allow the extracted pages to be downloaded. + + Enable full API @@ -191,6 +201,12 @@ The interval in milliseconds to get XFDF updates from the server for the current file. + + + Allow saving to Mendix + Allow the extracted pages to be saved back to Mendix as a separate file. + + diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index 4f0bd49..69a3fb7 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -1,7 +1,8 @@ import React, { createElement, useRef, useEffect, useState } from "react"; -import { debounce } from "lodash"; +import debounce from "lodash/debounce"; import viewer, { WebViewerInstance } from "@pdftron/webviewer"; import WebViewerModuleClient from "../clients/WebViewerModuleClient"; +import PageExtractionModal from "./PageExtractionModal"; export interface InputProps { containerHeight: string; @@ -30,6 +31,9 @@ export interface InputProps { defaultLanguage: string; enablePdfEditing?: boolean; enableOfficeEditing?: boolean; + enablePageExtraction?: boolean; + allowExtractionDownload?: boolean; + allowSavingToMendix?: boolean; l?: string; mx: any; enableDocumentUpdates?: boolean; @@ -308,12 +312,45 @@ const PDFViewer: React.FC = props => { } else { UI.disableFeatures([UI.Feature.ContentEdit]); } + // Check whether the backend module is available moduleClient.checkForModule().then(hasWebViewerModule => { if (!hasWebViewerModule) { return; } + if (props.enablePageExtraction) { + const dataElement = "pageExtractionElement"; + UI.addCustomModal({ + dataElement, + render: (): any => { + return ( + + ); + }, + header: undefined, + body: undefined, + footer: undefined + }); + + UI.setHeaderItems((header: any) => { + header.push({ + type: "actionButton", + title: "Page Extraction", + img: ``, + onClick: () => { + UI.openElements([dataElement]); + } + }); + }); + } + UI.setHeaderItems((header: any) => { if (props.enableDocumentUpdates) { header.push({ diff --git a/src/components/PageExtractionModal/ListItem.tsx b/src/components/PageExtractionModal/ListItem.tsx new file mode 100644 index 0000000..38919f7 --- /dev/null +++ b/src/components/PageExtractionModal/ListItem.tsx @@ -0,0 +1,113 @@ +import React, { createElement } from "react"; + +interface ListItemInputProps { + item: any; + render: any; + parentAddEventListener: any; + parentRemoveEventListener: any; +} + +interface ListItemState { + renderTarget: any; + shouldRenderItem: boolean; + isVisible: boolean; +} + +class ListItem extends React.Component { + private static MAX_SIZE = 0; + private _containerRef: React.RefObject; + private _measurementRef: React.RefObject; + private _resizeObserver: ResizeObserver; + private _height = 0; + private _scrollHandle: any; + constructor(props: ListItemInputProps) { + super(props); + this._containerRef = React.createRef(); + this._measurementRef = React.createRef(); + this._resizeObserver = new ResizeObserver(() => { + const rect = this._measurementRef.current?.getBoundingClientRect(); + if (!rect || rect.height === 0 || (this._height && rect.height < this._height)) { + return; + } + this._height = rect.height; + if (this._height > ListItem.MAX_SIZE) { + ListItem.MAX_SIZE = this._height; + } + }); + this.props.parentAddEventListener("scroll", this.onParentScroll); + const renderTarget = this.props.render(this.props.item); + const isPromise = renderTarget instanceof Promise; + if (isPromise) { + renderTarget.then((result: any) => this.setState({ renderTarget: result, shouldRenderItem: true })); + } + this.state = { + renderTarget: isPromise ? undefined : renderTarget, + shouldRenderItem: !isPromise, + isVisible: true + }; + } + componentDidMount(): void { + // @ts-ignore + this._resizeObserver.observe(this._measurementRef.current); + } + componentWillUnmount(): void { + if (this._measurementRef.current) { + this._resizeObserver.unobserve(this._measurementRef.current); + } + this.props.parentRemoveEventListener("scroll", this.onParentScroll); + } + onParentScroll = (parentRect: any, _scrollTop: number, padding: number) => { + clearTimeout(this._scrollHandle); + this._scrollHandle = setTimeout(() => { + const rect = this._containerRef.current?.getBoundingClientRect(); + if (this.doRectanglesIntersect(parentRect, rect, padding)) { + this.setState({ isVisible: true }); + } else { + this.setState({ isVisible: false }); + } + }, 100); + }; + doRectanglesIntersect = (rect1: any, rect2: any, padding = 13): boolean => { + const itemPadding = ListItem.MAX_SIZE * padding; + const rect1Top = rect1.y - itemPadding; + const rect1Bottom = rect1.y + rect1.height + itemPadding; + const rect2Top = rect2.y; + const rect2Bottom = rect2.y + rect2.height; + + const verticalIntersection = rect1Top < rect2Bottom && rect1Bottom > rect2Top; + + return verticalIntersection; + }; + render(): JSX.Element { + if (!this.state.shouldRenderItem) { + return <>; + } + return ( +
+
+ {this.state.isVisible ? ( + this.state.renderTarget + ) : ( +
+ )} +
+
+ ); + } +} + +export default ListItem; diff --git a/src/components/PageExtractionModal/PageExtractionThumbnail.tsx b/src/components/PageExtractionModal/PageExtractionThumbnail.tsx new file mode 100644 index 0000000..cd145a1 --- /dev/null +++ b/src/components/PageExtractionModal/PageExtractionThumbnail.tsx @@ -0,0 +1,117 @@ +import React, { createElement } from "react"; + +interface PageExtractionThumbnailInputProps { + wvInstance: any; + pageNumber: number; + addFileInputEventListener: any; + removeFileInputEventListener: any; + onClick: any; +} + +interface PageExtractionThumbnailState { + thumbnail?: string; + isHover: boolean; + isSelected: boolean; + isDisabled: boolean; +} + +const ListItemStyle = { display: "inline-block", boxShadow: "1px 1px 8px black", position: "relative" }; +const ListItemHoverStyle = { ...ListItemStyle, boxShadow: "1px 1px 5px #3183c8" }; + +class PageExtractionThumbnail extends React.Component { + constructor(props: PageExtractionThumbnailInputProps) { + super(props); + this.props.wvInstance.Core.documentViewer + .getDocument() + .getDocumentCompletePromise() + .then(() => { + this.props.wvInstance.Core.documentViewer + .getDocument() + .loadThumbnail(this.props.pageNumber, (thumbnailCanvas: HTMLCanvasElement) => { + this.setState({ + thumbnail: thumbnailCanvas.toDataURL() + }); + }); + }); + this.state = { + thumbnail: undefined, + isHover: false, + isSelected: this.props.pageNumber === 1, + isDisabled: false + }; + } + componentDidMount(): void { + this.props.addFileInputEventListener(this.props.pageNumber, this.onFileInputChanged); + } + componentWillUnmount(): void { + this.props.removeFileInputEventListener(this.props.pageNumber, this.onFileInputChanged); + } + onFileInputChanged = (input: string) => { + const parts = input.split(",").sort(); + let occurrances = 0; + let isSelected = false; + for (const part of parts) { + const rangeParts = part.split("-").sort(); + const isRange = rangeParts.length === 2; + + if (isRange) { + const lower = Number(rangeParts[0]); + const upper = Number(rangeParts[1]); + if (this.props.pageNumber >= lower && this.props.pageNumber <= upper) { + isSelected = true; + occurrances = occurrances ? occurrances++ : 2; + } + } else if (Number(part) === this.props.pageNumber) { + isSelected = Number(rangeParts[0]) === this.props.pageNumber; + occurrances++; + } + } + this.setState({ + isSelected, + isDisabled: occurrances > 1 + }); + }; + onHoverEnter = () => { + if (this.state.isDisabled) { + return; + } + this.setState({ isHover: true }); + }; + onHoverLeave = () => { + if (this.state.isDisabled) { + return; + } + this.setState({ isHover: false }); + }; + onClick = () => { + if (this.state.isDisabled) { + return; + } + this.props.onClick(this.props.pageNumber, !this.state.isSelected); + this.setState({ isSelected: !this.state.isSelected }); + }; + render(): JSX.Element { + const { thumbnail, isHover, isSelected } = this.state; + const listItemStyle = isHover ? ListItemHoverStyle : ListItemStyle; + return ( +
+ + +
+ ); + } +} + +export default PageExtractionThumbnail; diff --git a/src/components/PageExtractionModal/VirtualList.tsx b/src/components/PageExtractionModal/VirtualList.tsx new file mode 100644 index 0000000..b0ba9d2 --- /dev/null +++ b/src/components/PageExtractionModal/VirtualList.tsx @@ -0,0 +1,83 @@ +import React, { createElement } from "react"; +import ListItem from "./ListItem"; + +interface VirtualListInputProps { + items: any[]; + padding: number; + render: any; + height: string | number; +} + +class VirtualList extends React.Component { + private _scrollContainerRef: React.RefObject; + private _eventListeners: Record; + constructor(props: VirtualListInputProps) { + super(props); + this._scrollContainerRef = React.createRef(); + this._eventListeners = {}; + } + componentDidMount(): void { + this.trigger("mount", this._scrollContainerRef.current?.getBoundingClientRect()); + } + addEventListener = (event: string, handler: any): void => { + if (!this._eventListeners[event]) { + this._eventListeners[event] = []; + } + if (handler) { + this._eventListeners[event].push(handler); + } + }; + removeEventListener = (event: string, handler: any): void => { + if (!handler || !this._eventListeners[event]) { + return; + } + this._eventListeners[event] = this._eventListeners[event].reduce((acc, existingHandler) => { + if (existingHandler !== handler) { + acc.push(existingHandler); + } + return acc; + }, []); + }; + trigger = (event: string, ...parameters: any[]): void => { + if (!this._eventListeners[event]) { + return; + } + this._eventListeners[event].forEach(handler => handler(...parameters)); + }; + onScroll = (): void => { + this.trigger( + "scroll", + this._scrollContainerRef.current?.getBoundingClientRect(), + this._scrollContainerRef.current?.scrollTop, + this.props.padding + ); + }; + render(): JSX.Element { + return ( +
+ {this.props.items.map((item, i) => ( + + ))} +
+ ); + } +} + +export default VirtualList; diff --git a/src/components/PageExtractionModal/index.tsx b/src/components/PageExtractionModal/index.tsx new file mode 100644 index 0000000..dea96fa --- /dev/null +++ b/src/components/PageExtractionModal/index.tsx @@ -0,0 +1,293 @@ +import React, { createElement } from "react"; +import type { WebViewerInstance } from "@pdftron/webviewer"; +import type WebViewerModuleClient from "../../clients/WebViewerModuleClient"; +import VirtualList from "./VirtualList"; +import PageExtractionThumbnail from "./PageExtractionThumbnail"; + +interface PageExtractionModalInputProps { + wvInstance: WebViewerInstance; + moduleClient: WebViewerModuleClient; + dataElement: string; + allowDownload: boolean; + allowSaveAs: boolean; +} + +interface PageExtractionModalState { + pageInput: string; + pageCount: number[]; + includeAnnotations: boolean; +} + +class PageExtractionModal extends React.Component { + private _timeoutHandle: any; + private _thumbnailSubscriptions: any; + private _downloadRef: any; + constructor(props: PageExtractionModalInputProps) { + super(props); + this._thumbnailSubscriptions = {}; + this._downloadRef = React.createRef(); + const doc = this.props.wvInstance.Core.documentViewer.getDocument(); + this.state = { + pageInput: "1", + pageCount: doc ? Array.from({ length: doc.getPageCount() }, (_, index) => index + 1) : [], + includeAnnotations: true + }; + } + componentDidMount(): void { + this.props.wvInstance.Core.documentViewer.addEventListener("documentLoaded", this.onDocumentLoaded); + this.props.wvInstance.Core.documentViewer.addEventListener("documentUnloaded", this.onDocumentUnloaded); + } + componentWillUnmount(): void { + this.props.wvInstance.Core.documentViewer.removeEventListener("documentLoaded", this.onDocumentLoaded); + this.props.wvInstance.Core.documentViewer.removeEventListener("documentUnloaded", this.onDocumentUnloaded); + } + onDocumentLoaded = () => { + this.setState({ + pageInput: "1", + pageCount: Array.from( + { length: this.props.wvInstance.Core.documentViewer.getDocument().getPageCount() }, + (_, index) => index + 1 + ) + }); + }; + onDocumentUnloaded = () => { + this.setState({ pageInput: "1", pageCount: [] }); + }; + onPageInputChanged = (e: any) => { + this.setState({ + pageInput: e.target.value || "1" + }); + if (this._timeoutHandle) { + clearTimeout(this._timeoutHandle); + } + this._timeoutHandle = setTimeout(() => { + const parsedInput = this.parsePageInputString(this.state.pageInput); + this.setState({ pageInput: parsedInput }); + this.trigger(parsedInput); + }, 1000); + }; + onClickThumbnail = (pageNumber: number, isSelected: boolean) => { + if (isSelected) { + this.setState({ + pageInput: `${this.state.pageInput},${pageNumber}` + }); + } else { + this.setState({ + pageInput: this.parsePageInputString(this.state.pageInput.replace(`${pageNumber}`, "")) + }); + } + }; + parsePageInputString = (input: string) => { + if (!input) { + return "1"; + } + const numPages = this.props.wvInstance.Core.documentViewer.getDocument().getPageCount(); + const parts = input.split(",").map(input => input.replace(/[a-zA-Z]+/, "").trim()); + const sanitizedParts = parts.reduce((acc: string[], part: string | undefined | null): string[] => { + if (!part || part.startsWith("-")) { + return acc; + } + const rangeParts = part.split("-").sort(); + let finalPart = part; + const isRange = rangeParts.length === 2; + if (isRange) { + let lower = rangeParts[0]; + let upper = rangeParts[1]; + if (lower > upper) { + const temp = lower; + lower = upper; + upper = temp; + finalPart = `${lower}-${upper}`; + } else if (lower === upper) { + finalPart = lower; + } + if (Number(lower) > numPages || Number(lower) < 1 || Number(upper) > numPages || Number(upper) < 1) { + return acc; + } + } else { + if (Number(rangeParts[0]) > numPages || Number(rangeParts[0]) < 1) { + return acc; + } + finalPart = rangeParts[0]; + } + acc.push(finalPart); + return acc; + }, []); + + return sanitizedParts.join(","); + }; + stringToPageArray = (input: string): number[] => { + const parts = input.split(","); + const pages: number[] = []; + parts.forEach((part: string) => { + const rangeParts = part.split("-").sort(); + const isRange = rangeParts.length === 2; + if (isRange) { + const lower = Number(rangeParts[0]); + const upper = Number(rangeParts[1]); + Array.from({ length: upper - lower + 1 }, (_, index) => pages.push(index + lower)); + } else { + pages.push(Number(part)); + } + }); + return pages; + }; + subscribe = (pageNumber: number, handler: any) => { + const numPages = this.props.wvInstance.Core.documentViewer.getDocument().getPageCount(); + if (!pageNumber || pageNumber > numPages || !handler) { + return; + } + if (!this._thumbnailSubscriptions[pageNumber]) { + this._thumbnailSubscriptions[pageNumber] = []; + } + this._thumbnailSubscriptions[pageNumber].push(handler); + }; + unsubscribe = (pageNumber: number, handler: any) => { + const numPages = this.props.wvInstance.Core.documentViewer.getDocument().getPageCount(); + if (!pageNumber || pageNumber > numPages || !handler) { + return; + } + this._thumbnailSubscriptions[pageNumber] = this._thumbnailSubscriptions[pageNumber].reduce( + (acc: any[], registeredHandler: any) => { + if (registeredHandler !== handler) { + acc.push(registeredHandler); + } + return acc; + }, + [] + ); + }; + trigger = (input: string) => { + Object.values(this._thumbnailSubscriptions).forEach((handlers: any[]) => + handlers.forEach(handler => handler(input)) + ); + }; + loadThumbnail = (pageNumber: number) => { + return ( + + ); + }; + onToggleIncludeAnnotations = (e: any) => { + this.setState({ includeAnnotations: e.target.checked }); + }; + onCancel = () => { + this.props.wvInstance.UI.closeElements([this.props.dataElement]); + }; + onDownload = async () => { + const doc = this.props.wvInstance.Core.documentViewer.getDocument(); + if (!doc) { + console.warn("No document is loaded. Unable to extract pages"); + } + try { + this.props.wvInstance.UI.openElements(["loadingModal"]); + const pages = this.stringToPageArray(this.state.pageInput); + let xfdf; + if (this.state.includeAnnotations) { + xfdf = await this.props.wvInstance.Core.annotationManager.exportAnnotations(); + } + const extracted = await doc.extractPages(pages, xfdf); + const downloadBlob = new Blob([extracted], { type: "application/pdf" }); + const downloadUrl = URL.createObjectURL(downloadBlob); + this._downloadRef.current.href = downloadUrl; + this._downloadRef.current.download = doc + .getFilename() + .split(".") + .map((part, index, arr) => (index === arr.length - 1 ? "-extracted.pdf" : part)) + .join("."); + this._downloadRef.current.click(); + this.props.wvInstance.UI.closeElements([this.props.dataElement]); + } finally { + this.props.wvInstance.UI.closeElements(["loadingModal"]); + } + }; + onSaveToMendix = async () => { + const doc = this.props.wvInstance.Core.documentViewer.getDocument(); + if (!doc) { + console.warn("No document is loaded. Unable to extract pages"); + } + try { + this.props.wvInstance.UI.openElements(["loadingModal"]); + const pages = this.stringToPageArray(this.state.pageInput); + let xfdf; + if (this.state.includeAnnotations) { + xfdf = await this.props.wvInstance.Core.annotationManager.exportAnnotations(); + } + const extracted = await doc.extractPages(pages, xfdf); + await this.props.moduleClient.saveFile(extracted); + this.props.wvInstance.UI.closeElements([this.props.dataElement]); + } finally { + this.props.wvInstance.UI.closeElements(["loadingModal"]); + } + }; + render(): JSX.Element { + return ( + + ); + } +} + +export default PageExtractionModal; diff --git a/src/package.xml b/src/package.xml index 4729769..614bbc7 100644 --- a/src/package.xml +++ b/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/tsconfig.json b/tsconfig.json index 1fe53ee..7ae9d0e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "jsxFragmentFactory": "React.Fragment", "esModuleInterop": true, "jsx": "react" }, diff --git a/typings/WebViewerProps.d.ts b/typings/WebViewerProps.d.ts index 9afc516..0cd6bb1 100644 --- a/typings/WebViewerProps.d.ts +++ b/typings/WebViewerProps.d.ts @@ -39,6 +39,8 @@ export interface WebViewerContainerProps { loadAsPDF: boolean; enablePdfEditing: boolean; enableOfficeEditing: boolean; + enablePageExtraction: boolean; + allowExtractionDownload: boolean; enableFullAPI: boolean; annotationUser?: EditableValue; enableAnnotations: boolean; @@ -64,6 +66,7 @@ export interface WebViewerContainerProps { enableRealTimeAnnotating: boolean; onExportXfdfCommand?: ActionValue; autoXfdfCommandImportInterval: number; + allowSavingToMendix: boolean; l: string; } @@ -82,6 +85,8 @@ export interface WebViewerPreviewProps { loadAsPDF: boolean; enablePdfEditing: boolean; enableOfficeEditing: boolean; + enablePageExtraction: boolean; + allowExtractionDownload: boolean; enableFullAPI: boolean; annotationUser: string; enableAnnotations: boolean; @@ -108,5 +113,6 @@ export interface WebViewerPreviewProps { enableRealTimeAnnotating: boolean; onExportXfdfCommand: {} | null; autoXfdfCommandImportInterval: number | null; + allowSavingToMendix: boolean; l: string; } diff --git a/yarn.lock b/yarn.lock index beee853..9de6f7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1517,10 +1517,10 @@ "@nodelib/fs.scandir" "2.1.5" "fastq" "^1.6.0" -"@pdftron/webviewer@^10.0.0-20230412": - "integrity" "sha512-PD9xqNmdXt87H3wJwBtGOG3oy1ZfXElyt7cpe0pTMb+w94EG/F57sfAblHjBrdmCCld4hxYGkb9VOFbOaoC/Rg==" - "resolved" "https://registry.npmjs.org/@pdftron/webviewer/-/webviewer-10.0.0.tgz" - "version" "10.0.0" +"@pdftron/webviewer@^10.1.0-20230523": + "integrity" "sha512-ZGpVO02qfM9u/kAZpG5io6xHiwhz4IKWIsUYX+HgPbotaQpeLYJjZjUni/99UPQCMOVVkC2mNZcIlOlJWKK3UQ==" + "resolved" "https://registry.npmjs.org/@pdftron/webviewer/-/webviewer-10.1.0.tgz" + "version" "10.1.0" "@prettier/plugin-xml@^1.2.0": "integrity" "sha512-bFvVAZKs59XNmntYjyefn3K4TBykS6E+d6ZW8IcylAs88ZO+TzLhp0dPpi0VKfPzq1Nb+kpDnPRTiwb4zY6NgA=="