refactor(editor): move export manager to surface block extensions (#10231)

This commit is contained in:
Saul-Mirone
2025-02-18 04:39:05 +00:00
parent 31f8e92a4b
commit c3e924d4cb
20 changed files with 128 additions and 119 deletions

View File

@@ -24,9 +24,11 @@
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.7",
"fractional-indexing": "^3.2.0",
"html2canvas": "^1.4.1",
"lit": "^3.2.0",
"lodash.chunk": "^4.2.0",
"nanoid": "^5.0.7",
"pdf-lib": "^1.17.1",
"yjs": "^13.6.21",
"zod": "^3.23.8"
},

View File

@@ -0,0 +1,654 @@
import {
FrameBlockModel,
GroupElementModel,
ImageBlockModel,
type NoteBlockModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import { FetchUtils } from '@blocksuite/affine-shared/adapters';
import {
CANVAS_EXPORT_IGNORE_TAGS,
DEFAULT_IMAGE_PROXY_ENDPOINT,
} from '@blocksuite/affine-shared/consts';
import type { Viewport } from '@blocksuite/affine-shared/types';
import {
isInsidePageEditor,
matchModels,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
type BlockStdScope,
type EditorHost,
StdIdentifier,
} from '@blocksuite/block-std';
import {
GfxBlockElementModel,
type GfxController,
GfxControllerIdentifier,
type GfxPrimitiveElementModel,
} from '@blocksuite/block-std/gfx';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { IBound } from '@blocksuite/global/utils';
import { Bound, deserializeXYWH } from '@blocksuite/global/utils';
import type { ExtensionType, Store } from '@blocksuite/store';
import { SurfaceElementModel } from '../../element-model/base.js';
import type { CanvasRenderer } from '../../renderer/canvas-renderer.js';
import type { SurfaceBlockComponent } from '../../surface-block.js';
import { getBgGridGap } from '../../utils/get-bg-grip-gap.js';
import { FileExporter } from './file-exporter.js';
// oxlint-disable-next-line typescript/consistent-type-imports
type Html2CanvasFunction = typeof import('html2canvas').default;
export type ExportOptions = {
imageProxyEndpoint: string;
};
export class ExportManager {
private readonly _exportOptions: ExportOptions = {
imageProxyEndpoint: DEFAULT_IMAGE_PROXY_ENDPOINT,
};
private readonly _replaceRichTextWithSvgElement = (element: HTMLElement) => {
const richList = Array.from(element.querySelectorAll('.inline-editor'));
richList.forEach(rich => {
const svgEle = this._elementToSvgElement(
rich.cloneNode(true) as HTMLElement,
rich.clientWidth,
rich.clientHeight + 1
);
rich.parentElement?.append(svgEle);
rich.remove();
});
};
replaceImgSrcWithSvg = async (element: HTMLElement) => {
const imgList = Array.from(element.querySelectorAll('img'));
// Create an array of promises
const promises = imgList.map(img => {
return FetchUtils.fetchImage(
img.src,
undefined,
this._exportOptions.imageProxyEndpoint
)
.then(response => response && response.blob())
.then(async blob => {
if (!blob) return;
// If the file type is SVG, set svg width and height
if (blob.type === 'image/svg+xml') {
// Parse the SVG
const parser = new DOMParser();
const svgDoc = parser.parseFromString(
await blob.text(),
'image/svg+xml'
);
const svgElement =
svgDoc.documentElement as unknown as SVGSVGElement;
// Check if the SVG has width and height attributes
if (
!svgElement.hasAttribute('width') &&
!svgElement.hasAttribute('height')
) {
// Get the viewBox
const viewBox = svgElement.viewBox.baseVal;
// Set the SVG width and height
svgElement.setAttribute('width', `${viewBox.width}px`);
svgElement.setAttribute('height', `${viewBox.height}px`);
}
// Replace the img src with the modified SVG
const serializer = new XMLSerializer();
const newSvgStr = serializer.serializeToString(svgElement);
img.src =
'data:image/svg+xml;charset=utf-8,' +
encodeURIComponent(newSvgStr);
}
});
});
// Wait for all promises to resolve
await Promise.all(promises);
};
get doc(): Store {
return this.std.store;
}
get editorHost(): EditorHost {
return this.std.host;
}
constructor(readonly std: BlockStdScope) {}
private _checkCanContinueToCanvas(pathName: string, editorMode: boolean) {
if (
location.pathname !== pathName ||
isInsidePageEditor(this.editorHost) !== editorMode
) {
throw new BlockSuiteError(
ErrorCode.EdgelessExportError,
'Unable to export content to canvas'
);
}
}
private async _checkReady() {
const pathname = location.pathname;
const editorMode = isInsidePageEditor(this.editorHost);
const promise = new Promise((resolve, reject) => {
let count = 0;
const checkReactRender = setInterval(() => {
try {
this._checkCanContinueToCanvas(pathname, editorMode);
} catch (e) {
clearInterval(checkReactRender);
reject(e);
}
const rootModel = this.doc.root;
const rootComponent = this.doc.root
? rootModel
? this.editorHost.view.getBlock(rootModel.id)
: null
: null;
const imageCard = rootComponent?.querySelector(
'affine-image-fallback-card'
);
const isReady =
!imageCard || imageCard.getAttribute('imageState') === '0';
if (rootComponent && isReady) {
clearInterval(checkReactRender);
resolve(true);
}
count++;
if (count > 10 * 60) {
clearInterval(checkReactRender);
resolve(false);
}
}, 100);
});
return promise;
}
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 };
}
private _disableMediaPrint() {
document.querySelectorAll('.media-print').forEach(mediaPrint => {
mediaPrint.classList.add('hide');
});
}
private async _docToCanvas(): Promise<HTMLCanvasElement | void> {
const html2canvas = (await import('html2canvas')).default;
if (!(html2canvas instanceof Function)) return;
const pathname = location.pathname;
const editorMode = isInsidePageEditor(this.editorHost);
const rootComponent = getRootByEditorHost(this.editorHost);
if (!rootComponent) return;
const viewportElement = rootComponent.viewportElement;
if (!viewportElement) return;
const pageContainer = viewportElement.querySelector(
'.affine-page-root-block-container'
);
const rect = pageContainer?.getBoundingClientRect();
const { viewport } = rootComponent;
if (!viewport) return;
const pageWidth = rect?.width;
const pageLeft = rect?.left ?? 0;
const viewportHeight = viewportElement?.scrollHeight;
const html2canvasOption = {
ignoreElements: function (element: Element) {
if (
CANVAS_EXPORT_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-doc-meta-data is not needed to be showed
return true;
} else {
return false;
}
},
onclone: async (_documentClone: Document, element: HTMLElement) => {
element.style.height = `${viewportHeight}px`;
this._replaceRichTextWithSvgElement(element);
await this.replaceImgSrcWithSvg(element);
},
backgroundColor: window.getComputedStyle(viewportElement).backgroundColor,
x: pageLeft - viewport.left,
width: pageWidth,
height: viewportHeight,
useCORS: this._exportOptions.imageProxyEndpoint ? false : true,
proxy: this._exportOptions.imageProxyEndpoint,
};
let data: HTMLCanvasElement;
try {
this._enableMediaPrint();
data = await html2canvas(
viewportElement as HTMLElement,
html2canvasOption
);
} finally {
this._disableMediaPrint();
}
this._checkCanContinueToCanvas(pathname, editorMode);
return data;
}
private _drawEdgelessBackground(
ctx: CanvasRenderingContext2D,
{
size,
backgroundColor,
gridColor,
}: {
size: number;
backgroundColor: string;
gridColor: string;
}
) {
const svgImg = `<svg width='${ctx.canvas.width}px' height='${ctx.canvas.height}px' xmlns='http://www.w3.org/2000/svg' style='background-size:${size}px ${size}px;background-color:${backgroundColor}; background-image: radial-gradient(${gridColor} 1px, ${backgroundColor} 1px)'></svg>`;
const img = new Image();
const cleanup = () => {
img.onload = null;
img.onerror = null;
};
return new Promise<void>((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 _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.append(foreignObject);
foreignObject.append(node);
return svg;
}
private _enableMediaPrint() {
document.querySelectorAll('.media-print').forEach(mediaPrint => {
mediaPrint.classList.remove('hide');
});
}
private async _html2canvas(
htmlElement: HTMLElement,
options: Parameters<Html2CanvasFunction>[1] = {}
) {
const html2canvas = (await import('html2canvas'))
.default as unknown as Html2CanvasFunction;
const html2canvasOption = {
ignoreElements: function (element: Element) {
if (
CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) ||
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');
}
});
this._replaceRichTextWithSvgElement(element);
await this.replaceImgSrcWithSvg(element);
},
useCORS: this._exportOptions.imageProxyEndpoint ? false : true,
proxy: this._exportOptions.imageProxyEndpoint,
};
let data: HTMLCanvasElement;
try {
this._enableMediaPrint();
data = await html2canvas(
htmlElement,
Object.assign(html2canvasOption, options)
);
} finally {
this._disableMediaPrint();
}
return data;
}
private async _toCanvas(): Promise<HTMLCanvasElement | void> {
try {
await this._checkReady();
} catch (e: unknown) {
console.error('Failed to export to canvas');
console.error(e);
return;
}
if (isInsidePageEditor(this.editorHost)) {
return this._docToCanvas();
} else {
const rootModel = this.doc.root;
if (!rootModel) return;
const gfx = this.editorHost.std.get(GfxControllerIdentifier);
const surfaceBlock = gfx.surfaceComponent as SurfaceBlockComponent | null;
if (!surfaceBlock) return;
const bound = gfx.elementsBound;
return this.edgelessToCanvas(surfaceBlock.renderer, bound, gfx);
}
}
// TODO: refactor of this part
async edgelessToCanvas(
surfaceRenderer: CanvasRenderer,
bound: IBound,
gfx: GfxController,
nodes?: GfxBlockElementModel[],
surfaces?: GfxPrimitiveElementModel[],
edgelessBackground?: {
zoom: number;
}
): Promise<HTMLCanvasElement | undefined> {
const rootModel = this.doc.root;
if (!rootModel) return;
const pathname = location.pathname;
const editorMode = isInsidePageEditor(this.editorHost);
const rootComponent = getRootByEditorHost(this.editorHost);
if (!rootComponent) return;
const viewportElement = rootComponent.viewportElement;
if (!viewportElement) return;
const containerComputedStyle = window.getComputedStyle(viewportElement);
const html2canvas = (element: HTMLElement) =>
this._html2canvas(element, {
backgroundColor: containerComputedStyle.backgroundColor,
});
const container = rootComponent.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: getBgGridGap(edgelessBackground.zoom),
gridColor: containerComputedStyle.getPropertyValue(
'--affine-edgeless-grid-color'
),
});
}
const blocks =
nodes ?? gfx.getElementsByBound(bound, { type: 'block' }) ?? [];
for (const block of blocks) {
if (matchModels(block, [ImageBlockModel])) {
if (!block.sourceId) return;
const blob = await block.doc.blobSync.get(block.sourceId);
if (!blob) return;
const blobToImage = (blob: Blob) =>
new Promise<HTMLImageElement>((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
);
}
const blockComponent = this.editorHost.view.getBlock(block.id);
if (blockComponent) {
const blockBound = xywhArrayToObject(block);
const canvasData = await this._html2canvas(
blockComponent as HTMLElement
);
ctx.drawImage(
canvasData,
blockBound.x - bound.x + 50,
blockBound.y - bound.y + 50,
blockBound.w,
blockBound.h
);
}
if (matchModels(block, [FrameBlockModel])) {
// TODO(@L-Sun): use children of frame instead of bound
const blocksInsideFrame = getBlocksInFrameBound(this.doc, block, false);
const frameBound = Bound.deserialize(block.xywh);
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < blocksInsideFrame.length; i++) {
const element = blocksInsideFrame[i];
const htmlElement = this.editorHost.view.getBlock(block.id);
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, editorMode);
}
if (surfaces?.length) {
const surfaceElements = surfaces.flatMap(element =>
element instanceof GroupElementModel
? (element.childElements.filter(
el => el instanceof SurfaceElementModel
) as SurfaceElementModel[])
: element
);
const surfaceCanvas = surfaceRenderer.getCanvasByBound(
bound,
surfaceElements
);
ctx.drawImage(surfaceCanvas, 50, 50, bound.w, bound.h);
}
return canvas;
}
async exportPdf() {
const rootModel = this.doc.root;
if (!rootModel) 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(
(rootModel as RootBlockModel).title.toString() + '.pdf',
pdfBase64
);
}
async exportPng() {
const rootModel = this.doc.root;
if (!rootModel) return;
const canvasImage = await this._toCanvas();
if (!canvasImage) {
return;
}
FileExporter.exportPng(
(this.doc.root as RootBlockModel).title.toString(),
canvasImage.toDataURL('image/png')
);
}
}
export const ExportManagerExtension: ExtensionType = {
setup: di => {
di.add(ExportManager, [StdIdentifier]);
},
};
function xywhArrayToObject(element: GfxBlockElementModel) {
const [x, y, w, h] = deserializeXYWH(element.xywh);
return { x, y, w, h };
}
function getNotesInFrameBound(
doc: Store,
frame: FrameBlockModel,
fullyContained: boolean = true
): NoteBlockModel[] {
const bound = Bound.deserialize(frame.xywh);
return (doc.getBlockByFlavour('affine:note') as NoteBlockModel[]).filter(
ele => {
const xywh = Bound.deserialize(ele.xywh);
return fullyContained
? bound.contains(xywh)
: bound.isPointInBound([xywh.x, xywh.y]);
}
);
}
function getBlocksInFrameBound(
doc: Store,
model: FrameBlockModel,
fullyContained: boolean = true
) {
const bound = Bound.deserialize(model.xywh);
const surface = model.surface;
if (!surface) return [];
return (
getNotesInFrameBound(doc, model, fullyContained) as GfxBlockElementModel[]
).concat(
surface.children.filter((ele): ele is GfxBlockElementModel => {
if (ele.id === model.id) return false;
if (ele instanceof GfxBlockElementModel) {
const blockBound = Bound.deserialize(ele.xywh);
return fullyContained
? bound.contains(blockBound)
: bound.containsPoint([blockBound.x, blockBound.y]);
}
return false;
})
);
}
type RootBlockComponent = BlockComponent & {
viewportElement: HTMLElement;
viewport: Viewport;
};
function getRootByEditorHost(
editorHost: EditorHost
): RootBlockComponent | null {
const model = editorHost.doc.root;
if (!model) return null;
const root = editorHost.view.getBlock(model.id);
return root as RootBlockComponent | null;
}

View File

@@ -0,0 +1,81 @@
/* oxlint-disable no-control-regex */
// Context: Lean towards breaking out any localizable content into constants so it's
// easier to track content we may need to localize in the future. (i18n)
const UNTITLED_PAGE_NAME = 'Untitled';
/** Tools for exporting files to device. For example, via browser download. */
export const FileExporter = {
/**
* Create a download for the user's browser.
*
* @param filename
* @param text
* @param mimeType like `"text/plain"`, `"text/html"`, `"application/javascript"`, etc. See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types mdn docs List of MIME types}.
*
* @remarks
* Only accepts data in utf-8 encoding (html files, javascript source, text files, etc).
*
* @example
* const todoMDText = `# Todo items
* [ ] Item 1
* [ ] Item 2
* `
* FileExporter.exportFile("Todo list.md", todoMDText, "text/plain")
*
* @example
* const stateJsonContent = JSON.stringify({ a: 1, b: 2, c: 3 })
* FileExporter.exportFile("state.json", jsonContent, "application/json")
*/
exportFile(filename: string, dataURL: string) {
const element = document.createElement('a');
element.setAttribute('href', dataURL);
const safeFilename = getSafeFileName(filename);
element.setAttribute('download', safeFilename);
element.style.display = 'none';
document.body.append(element);
element.click();
element.remove();
},
exportPng(docTitle: string | undefined, dataURL: string) {
const title = docTitle?.trim() || UNTITLED_PAGE_NAME;
FileExporter.exportFile(title + '.png', dataURL);
},
};
function getSafeFileName(string: string) {
const replacement = ' ';
const filenameReservedRegex = /[<>:"/\\|?*\u0000-\u001F]/g;
const windowsReservedNameRegex = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
const reControlChars = /[\u0000-\u001F\u0080-\u009F]/g;
const reTrailingPeriods = /\.+$/;
const allowedLength = 50;
function trimRepeated(string: string, target: string) {
const escapeStringRegexp = target
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
.replace(/-/g, '\\x2d');
const regex = new RegExp(`(?:${escapeStringRegexp}){2,}`, 'g');
return string.replace(regex, target);
}
string = string
.normalize('NFD')
.replace(filenameReservedRegex, replacement)
.replace(reControlChars, replacement)
.replace(reTrailingPeriods, '');
string = trimRepeated(string, replacement);
string = windowsReservedNameRegex.test(string)
? string + replacement
: string;
const extIndex = string.lastIndexOf('.');
const filename = string.slice(0, extIndex).trim();
const extension = string.slice(extIndex);
string =
filename.slice(0, Math.max(1, allowedLength - extension.length)) +
extension;
return string;
}

View File

@@ -0,0 +1 @@
export { ExportManager, ExportManagerExtension } from './export-manager.js';

View File

@@ -1,3 +1,4 @@
export * from './crud-extension';
export * from './export-manager';
export * from './legacy-slot-extension';
export * from './query';

View File

@@ -80,6 +80,7 @@ export {
addNote,
addNoteAtPoint,
generateElementId,
getBgGridGap,
getLastPropsKey,
getSurfaceBlock,
normalizeWheelDeltaY,

View File

@@ -10,6 +10,7 @@ import {
EdgelessCRUDExtension,
EdgelessLegacySlotExtension,
} from './extensions';
import { ExportManagerExtension } from './extensions/export-manager/export-manager';
import { SurfaceBlockService } from './surface-service';
import { MindMapView } from './view/mindmap';
@@ -19,6 +20,7 @@ const CommonSurfaceBlockSpec: ExtensionType[] = [
MindMapView,
EdgelessCRUDExtension,
EdgelessLegacySlotExtension,
ExportManagerExtension,
];
export const PageSurfaceBlockSpec: ExtensionType[] = [

View File

@@ -0,0 +1,10 @@
import { clamp } from '@blocksuite/global/utils';
import { GRID_GAP_MAX, GRID_GAP_MIN } from '../consts';
export function getBgGridGap(zoom: number) {
const step = zoom < 0.5 ? 2 : 1 / (Math.floor(zoom) || 1);
const gap = clamp(20 * step * zoom, GRID_GAP_MIN, GRID_GAP_MAX);
return gap;
}

View File

@@ -34,6 +34,7 @@ export function normalizeWheelDeltaY(delta: number, zoom = 1) {
}
export { addNote, addNoteAtPoint } from './add-note';
export { getBgGridGap } from './get-bg-grip-gap';
export { getLastPropsKey } from './get-last-props-key';
export { getSurfaceBlock } from './get-surface-block';
export * from './mindmap/style-svg.js';