import type { BlockService } from '@blockeditor/block-std'; import { assertExists } from '@blockeditor/global/utils'; import type { EditorHost } from '@blockeditor/lit'; import type { BlockModel, Page } from '@blockeditor/store'; import { blockElementGetter, getBlockComponentByModel, getPageByEditorHost, isInsideDocEditor, matchFlavours, } from '../../_common/utils/index.js'; import type { PageBlockModel } from '../../models.js'; import type { EdgelessPageBlockComponent } from '../../page-block/edgeless/edgeless-page-block.js'; import { getBlocksInFrame } from '../../page-block/edgeless/frame-manager.js'; import type { EdgelessBlockModel } from '../../page-block/edgeless/type.js'; import { xywhArrayToObject } from '../../page-block/edgeless/utils/convert.js'; import { getBackgroundGrid } from '../../page-block/edgeless/utils/query.js'; import type { IBound } from '../../surface-block/consts.js'; import type { ElementModel } from '../../surface-block/element-model/index.js'; import type { Renderer } from '../../surface-block/index.js'; import { Bound } from '../../surface-block/utils/bound.js'; import { FileExporter } from './file-exporter.js'; type Html2CanvasFunction = typeof import('html2canvas').default; export type ExportOptions = { imageProxyEndpoint: string; }; const IGNORE_TAGS = [ 'AFFINE-BLOCK-HUB', 'EDGELESS-TOOLBAR', 'AFFINE-DRAG-HANDLE-WIDGET', ]; export class ExportManager { private _exportOptions: ExportOptions; private _blockService: BlockService; constructor(blockService: BlockService, options: ExportOptions) { this._exportOptions = options; this._blockService = blockService; } get page(): Page { return this._blockService.std.page; } get editorHost(): EditorHost { return this._blockService.std.host as EditorHost; } private async _checkReady() { const pathname = location.pathname; const pageMode = isInsideDocEditor(this.editorHost); const promise = new Promise((resolve, reject) => { let count = 0; const checkReactRender = setInterval(() => { try { this._checkCanContinueToCanvas(pathname, pageMode); } catch (e) { clearInterval(checkReactRender); reject(e); } const root = this.page.root; const pageBlock = root ? getBlockComponentByModel(this.editorHost, root) : null; const imageCard = pageBlock?.querySelector('affine-image-block-card'); const isReady = !imageCard || imageCard.getAttribute('imageState') === '0'; if (pageBlock && isReady) { clearInterval(checkReactRender); resolve(true); } count++; if (count > 10 * 60) { clearInterval(checkReactRender); resolve(false); } }, 100); }); return await promise; } private _drawEdgelessBackground( ctx: CanvasRenderingContext2D, { size, backgroundColor, gridColor, }: { size: number; backgroundColor: string; gridColor: string; } ) { const svgImg = ``; const img = new Image(); const cleanup = () => { img.onload = null; img.onerror = null; }; return new Promise((resolve, reject) => { img.onload = () => { cleanup(); ctx.drawImage(img, 0, 0); resolve(); }; img.onerror = e => { cleanup(); reject(e); }; img.src = `data:image/svg+xml,${encodeURIComponent(svgImg)}`; }); } private async _html2canvas( htmlElement: HTMLElement, options: Parameters[1] = {} ) { const html2canvas = (await import('html2canvas')) .default as unknown as Html2CanvasFunction; const html2canvasOption = { ignoreElements: function (element: Element) { if ( element.tagName === 'AFFINE-BLOCK-HUB' || element.tagName === 'EDGELESS-TOOLBAR' || element.classList.contains('dg') ) { return true; } else { return false; } }, onclone: async (documentClone: Document, element: HTMLElement) => { // html2canvas can't support transform feature element.style.setProperty('transform', 'none'); const layer = element.classList.contains('.affine-edgeless-layer') ? element : null; if (layer instanceof HTMLElement) { layer.style.setProperty('transform', 'none'); } const boxShadowEles = documentClone.querySelectorAll( "[style*='box-shadow']" ); boxShadowEles.forEach(function (element) { if (element instanceof HTMLElement) { element.style.setProperty('box-shadow', 'none'); } }); await this._replaceRichTextWithSvgElement(element); }, useCORS: this._exportOptions.imageProxyEndpoint ? false : true, proxy: this._exportOptions.imageProxyEndpoint, }; return html2canvas(htmlElement, Object.assign(html2canvasOption, options)); } private _createCanvas(bound: IBound, fillStyle: string) { const canvas = document.createElement('canvas'); const dpr = window.devicePixelRatio || 1; const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; canvas.width = (bound.w + 100) * dpr; canvas.height = (bound.h + 100) * dpr; ctx.scale(dpr, dpr); ctx.fillStyle = fillStyle; ctx.fillRect(0, 0, canvas.width, canvas.height); return { canvas, ctx }; } public async edgelessToCanvas( surfaceRenderer: Renderer, bound: IBound, blockElementGetter: (model: BlockModel) => Element | null = () => null, edgeless?: EdgelessPageBlockComponent, nodes?: EdgelessBlockModel[], surfaces?: ElementModel[], edgelessBackground?: { zoom: number; } ): Promise { const root = this.page.root; if (!root) return; const pathname = location.pathname; const pageMode = isInsideDocEditor(this.editorHost); const pageElement = getPageByEditorHost(this.editorHost); assertExists(pageElement); const viewportElement = pageElement.viewportElement; const containerComputedStyle = window.getComputedStyle(viewportElement); const html2canvas = (element: HTMLElement) => this._html2canvas(element, { backgroundColor: containerComputedStyle.backgroundColor, }); const container = pageElement.querySelector( '.affine-block-children-container' ); if (!container) return; const { ctx, canvas } = this._createCanvas( bound, window.getComputedStyle(container).backgroundColor ); if (edgelessBackground) { await this._drawEdgelessBackground(ctx, { backgroundColor: containerComputedStyle.getPropertyValue( '--affine-background-primary-color' ), size: getBackgroundGrid(edgelessBackground.zoom, true).gap, gridColor: containerComputedStyle.getPropertyValue( '--affine-edgeless-grid-color' ), }); } // TODO: refactor of this part const blocks = nodes ?? edgeless?.service.pickElementsByBound(bound, 'blocks') ?? []; for (const block of blocks) { if (matchFlavours(block, ['affine:image'])) { if (!block.sourceId) return; const blob = await block.page.blob.get(block.sourceId); if (!blob) return; const blobToImage = (blob: Blob) => new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = URL.createObjectURL(blob); }); const blockBound = xywhArrayToObject(block); ctx.drawImage( await blobToImage(blob), blockBound.x - bound.x, blockBound.y - bound.y, blockBound.w, blockBound.h ); } let blockElement = blockElementGetter(block)?.parentElement; if (matchFlavours(block, ['affine:note'])) { blockElement = blockElement?.closest('.edgeless-block-portal-note'); } if (blockElement) { const blockBound = xywhArrayToObject(block); const canvasData = await this._html2canvas(blockElement as HTMLElement); ctx.drawImage( canvasData, blockBound.x - bound.x + 50, blockBound.y - bound.y + 50, blockBound.w, blockBound.h ); } if (matchFlavours(block, ['affine:frame'])) { const blocksInsideFrame = getBlocksInFrame(this.page, block, false); const frameBound = Bound.deserialize(block.xywh); for (let i = 0; i < blocksInsideFrame.length; i++) { const element = blocksInsideFrame[i]; const htmlElement = blockElementGetter(element); const blockBound = xywhArrayToObject(element); const canvasData = await html2canvas(htmlElement as HTMLElement); ctx.drawImage( canvasData, blockBound.x - bound.x + 50, blockBound.y - bound.y + 50, blockBound.w, (blockBound.w / canvasData.width) * canvasData.height ); } const surfaceCanvas = surfaceRenderer.getCanvasByBound(frameBound); ctx.drawImage(surfaceCanvas, 50, 50, frameBound.w, frameBound.h); } this._checkCanContinueToCanvas(pathname, pageMode); } const surfaceCanvas = surfaceRenderer.getCanvasByBound(bound, surfaces); ctx.drawImage(surfaceCanvas, 50, 50, bound.w, bound.h); return canvas; } private async _docToCanvas(): Promise { const html2canvas = (await import('html2canvas')).default; if (!(html2canvas instanceof Function)) return; const pathname = location.pathname; const pageMode = isInsideDocEditor(this.editorHost); const pageElement = getPageByEditorHost(this.editorHost); assertExists(pageElement); const viewportElement = pageElement.viewportElement; const pageContainer = viewportElement.querySelector( '.affine-doc-page-block-container' ); const rect = pageContainer?.getBoundingClientRect(); const { viewport } = pageElement; const pageWidth = rect?.width; const pageLeft = rect?.left ?? 0; const viewportHeight = viewportElement?.scrollHeight; const replaceRichTextWithSvgElementFunc = this._replaceRichTextWithSvgElement.bind(this); const html2canvasOption = { ignoreElements: function (element: Element) { if ( IGNORE_TAGS.includes(element.tagName) || element.classList.contains('dg') ) { return true; } else if ( (element.classList.contains('close') && element.parentElement?.classList.contains( 'meta-data-expanded-title' )) || (element.classList.contains('expand') && element.parentElement?.classList.contains('meta-data')) ) { // the close and expand buttons in affine-page-meta-data is not needed to be showed return true; } else { return false; } }, onclone: async function (_documentClone: Document, element: HTMLElement) { element.style.height = `${viewportHeight}px`; await replaceRichTextWithSvgElementFunc(element); }, backgroundColor: window.getComputedStyle(viewportElement).backgroundColor, x: pageLeft - viewport.left, width: pageWidth, height: viewportHeight, useCORS: this._exportOptions.imageProxyEndpoint ? false : true, proxy: this._exportOptions.imageProxyEndpoint, }; const data = await html2canvas( viewportElement as HTMLElement, html2canvasOption ); this._checkCanContinueToCanvas(pathname, pageMode); return data; } private _replaceRichTextWithSvgElement = async (element: HTMLElement) => { const richList = Array.from(element.querySelectorAll('.inline-editor')); await Promise.all( richList.map(async rich => { const svgEle = await this._elementToSvgElement( rich.cloneNode(true) as HTMLElement, rich.clientWidth, rich.clientHeight + 1 ); rich.parentElement?.appendChild(svgEle); rich.parentElement?.removeChild(rich); }) ); }; private async _elementToSvgElement( node: HTMLElement, width: number, height: number ) { const xmlns = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(xmlns, 'svg'); const foreignObject = document.createElementNS(xmlns, 'foreignObject'); svg.setAttribute('width', `${width}`); svg.setAttribute('height', `${height}`); svg.setAttribute('viewBox', `0 0 ${width} ${height}`); foreignObject.setAttribute('width', '100%'); foreignObject.setAttribute('height', '100%'); foreignObject.setAttribute('x', '0'); foreignObject.setAttribute('y', '0'); foreignObject.setAttribute('externalResourcesRequired', 'true'); svg.appendChild(foreignObject); foreignObject.appendChild(node); return svg; } private _checkCanContinueToCanvas(pathName: string, pageMode: boolean) { if ( location.pathname !== pathName || isInsideDocEditor(this.editorHost) !== pageMode ) { throw new Error('Unable to export content to canvas'); } } private async _toCanvas(): Promise { await this._checkReady(); if (isInsideDocEditor(this.editorHost)) { return await this._docToCanvas(); } else { const root = this.page.root; if (!root) return; const edgeless = getBlockComponentByModel( this.editorHost, root ) as EdgelessPageBlockComponent; const bound = edgeless.getElementsBound(); assertExists(bound); return await this.edgelessToCanvas( edgeless.surface.renderer, bound, (model: BlockModel) => blockElementGetter(model, this.editorHost.view), edgeless ); } } public async exportPng() { const root = this.page.root; if (!root) return; const canvasImage = await this._toCanvas(); if (!canvasImage) { return; } FileExporter.exportPng( (this.page.root as PageBlockModel).title.toString(), canvasImage.toDataURL('image/png') ); } public async exportPdf() { const root = this.page.root; if (!root) return; const canvasImage = await this._toCanvas(); if (!canvasImage) { return; } const PDFLib = await import('pdf-lib'); const pdfDoc = await PDFLib.PDFDocument.create(); const page = pdfDoc.addPage([canvasImage.width, canvasImage.height]); const imageEmbed = await pdfDoc.embedPng(canvasImage.toDataURL('PNG')); const { width, height } = imageEmbed.scale(1); page.drawImage(imageEmbed, { x: 0, y: 0, width, height, }); const pdfBase64 = await pdfDoc.saveAsBase64({ dataUri: true }); FileExporter.exportFile( (root as PageBlockModel).title.toString() + '.pdf', pdfBase64 ); } }