mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
refactor(editor): move export manager to surface block extensions (#10231)
This commit is contained in:
@@ -1,594 +0,0 @@
|
||||
import {
|
||||
type CanvasRenderer,
|
||||
SurfaceElementModel,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
FrameBlockModel,
|
||||
GroupElementModel,
|
||||
ImageBlockModel,
|
||||
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 {
|
||||
isInsidePageEditor,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type BlockStdScope,
|
||||
type EditorHost,
|
||||
StdIdentifier,
|
||||
} from '@blocksuite/block-std';
|
||||
import type {
|
||||
GfxBlockElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { IBound } from '@blocksuite/global/utils';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
import type { ExtensionType, Store } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
getBlockComponentByModel,
|
||||
getRootByEditorHost,
|
||||
} from '../../_common/utils/index.js';
|
||||
import type { EdgelessRootBlockComponent } from '../../root-block/edgeless/edgeless-root-block.js';
|
||||
import { getBlocksInFrameBound } from '../../root-block/edgeless/frame-manager.js';
|
||||
import { xywhArrayToObject } from '../../root-block/edgeless/utils/convert.js';
|
||||
import { getBackgroundGrid } from '../../root-block/edgeless/utils/query.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
|
||||
? getBlockComponentByModel(this.editorHost, rootModel)
|
||||
: 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 edgeless = getBlockComponentByModel(
|
||||
this.editorHost,
|
||||
rootModel
|
||||
) as EdgelessRootBlockComponent;
|
||||
const bound = edgeless.gfx.elementsBound;
|
||||
return this.edgelessToCanvas(edgeless.surface.renderer, bound, edgeless);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: refactor of this part
|
||||
async edgelessToCanvas(
|
||||
surfaceRenderer: CanvasRenderer,
|
||||
bound: IBound,
|
||||
edgeless?: EdgelessRootBlockComponent,
|
||||
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: getBackgroundGrid(edgelessBackground.zoom, true).gap,
|
||||
gridColor: containerComputedStyle.getPropertyValue(
|
||||
'--affine-edgeless-grid-color'
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const blocks =
|
||||
nodes ??
|
||||
edgeless?.service.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]);
|
||||
},
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
/* eslint-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;
|
||||
}
|
||||
@@ -8,10 +8,6 @@ import { isCanvasElement } from './root-block/edgeless/utils/query.js';
|
||||
|
||||
export * from './_common/adapters/index.js';
|
||||
export { type NavigatorMode } from './_common/edgeless/frame/consts.js';
|
||||
export {
|
||||
ExportManager,
|
||||
ExportManagerExtension,
|
||||
} from './_common/export-manager/export-manager.js';
|
||||
export * from './_common/test-utils/test-utils.js';
|
||||
export * from './_common/transformers/index.js';
|
||||
export { type AbstractEditor } from './_common/types.js';
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { FlavourExtension } from '@blocksuite/block-std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { ExportManagerExtension } from '../../_common/export-manager/export-manager';
|
||||
import { RootBlockAdapterExtensions } from '../adapters/extension';
|
||||
import {
|
||||
docRemoteSelectionWidget,
|
||||
@@ -29,7 +28,6 @@ export const CommonSpecs: ExtensionType[] = [
|
||||
DocModeService,
|
||||
ThemeService,
|
||||
EmbedOptionService,
|
||||
ExportManagerExtension,
|
||||
PageViewportServiceExtension,
|
||||
DNDAPIExtension,
|
||||
FileDropExtension,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { addImages } from '@blocksuite/affine-block-image';
|
||||
import {
|
||||
CanvasElementType,
|
||||
EdgelessCRUDIdentifier,
|
||||
ExportManager,
|
||||
SurfaceGroupLikeModel,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
@@ -68,7 +69,6 @@ import {
|
||||
import DOMPurify from 'dompurify';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { ExportManager } from '../../../_common/export-manager/export-manager.js';
|
||||
import { getRootByEditorHost } from '../../../_common/utils/query.js';
|
||||
import { ClipboardAdapter } from '../../clipboard/adapter.js';
|
||||
import { PageClipboard } from '../../clipboard/index.js';
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
EdgelessLegacySlotIdentifier,
|
||||
getBgGridGap,
|
||||
normalizeWheelDeltaY,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
@@ -51,7 +52,7 @@ import { EdgelessClipboardController } from './clipboard/clipboard.js';
|
||||
import type { EdgelessSelectedRectWidget } from './components/rects/edgeless-selected-rect.js';
|
||||
import { EdgelessPageKeyboardManager } from './edgeless-keyboard.js';
|
||||
import type { EdgelessRootService } from './edgeless-root-service.js';
|
||||
import { getBackgroundGrid, isCanvasElement } from './utils/query.js';
|
||||
import { isCanvasElement } from './utils/query.js';
|
||||
import { mountShapeTextEditor } from './utils/text.js';
|
||||
|
||||
export class EdgelessRootBlockComponent extends BlockComponent<
|
||||
@@ -106,7 +107,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
||||
private readonly _refreshLayerViewport = requestThrottledConnectedFrame(
|
||||
() => {
|
||||
const { zoom, translateX, translateY } = this.gfx.viewport;
|
||||
const { gap } = getBackgroundGrid(zoom, true);
|
||||
const gap = getBgGridGap(zoom);
|
||||
|
||||
if (this.backgroundElm) {
|
||||
this.backgroundElm.style.setProperty(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
SurfaceBlockComponent,
|
||||
SurfaceBlockModel,
|
||||
import {
|
||||
getBgGridGap,
|
||||
type SurfaceBlockComponent,
|
||||
type SurfaceBlockModel,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { EdgelessPreviewer } from '@blocksuite/affine-block-surface-ref';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
@@ -23,7 +24,7 @@ import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockWidgetName } from '../types.js';
|
||||
import type { EdgelessRootService } from './edgeless-root-service.js';
|
||||
import { getBackgroundGrid, isCanvasElement } from './utils/query.js';
|
||||
import { isCanvasElement } from './utils/query.js';
|
||||
|
||||
export class EdgelessRootPreviewBlockComponent
|
||||
extends BlockComponent<
|
||||
@@ -73,7 +74,7 @@ export class EdgelessRootPreviewBlockComponent
|
||||
private readonly _refreshLayerViewport = requestThrottledConnectedFrame(
|
||||
() => {
|
||||
const { zoom, translateX, translateY } = this.service.viewport;
|
||||
const { gap } = getBackgroundGrid(zoom, true);
|
||||
const gap = getBgGridGap(zoom);
|
||||
|
||||
this.background.style.setProperty(
|
||||
'background-position',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
||||
import { Overlay } from '@blocksuite/affine-block-surface';
|
||||
import type { FrameBlockModel, NoteBlockModel } from '@blocksuite/affine-model';
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
generateKeyBetweenV2,
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type IVec,
|
||||
type SerializedXYWH,
|
||||
} from '@blocksuite/global/utils';
|
||||
import type { Store } from '@blocksuite/store';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
@@ -462,47 +461,3 @@ export class EdgelessFrameManager extends GfxExtension {
|
||||
this._disposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotesInFrameBound(
|
||||
doc: Store,
|
||||
frame: FrameBlockModel,
|
||||
fullyContained: boolean = true
|
||||
) {
|
||||
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]);
|
||||
}
|
||||
) as NoteBlockModel[];
|
||||
}
|
||||
|
||||
export 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 => {
|
||||
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;
|
||||
}) as GfxBlockElementModel[]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { GfxBlockElementModel } from '@blocksuite/block-std/gfx';
|
||||
import { deserializeXYWH } from '@blocksuite/global/utils';
|
||||
|
||||
export function xywhArrayToObject(element: GfxBlockElementModel) {
|
||||
const [x, y, w, h] = deserializeXYWH(element.xywh);
|
||||
return { x, y, w, h };
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
type CanvasElementWithText,
|
||||
GRID_GAP_MAX,
|
||||
GRID_GAP_MIN,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { CanvasElementWithText } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type AttachmentBlockModel,
|
||||
type BookmarkBlockModel,
|
||||
@@ -34,7 +30,7 @@ import type {
|
||||
Viewport,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import type { PointLocation } from '@blocksuite/global/utils';
|
||||
import { Bound, clamp } from '@blocksuite/global/utils';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import type { Connectable } from '../../../_common/utils/index.js';
|
||||
@@ -228,18 +224,6 @@ export function getCursorMode(edgelessTool: GfxToolsFullOptionValue | null) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getBackgroundGrid(zoom: number, showGrid: boolean) {
|
||||
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,
|
||||
grid: showGrid
|
||||
? 'radial-gradient(var(--affine-edgeless-grid-color) 1px, var(--affine-background-primary-color) 1px)'
|
||||
: 'unset',
|
||||
};
|
||||
}
|
||||
|
||||
export type SelectableProps = {
|
||||
bound: Bound;
|
||||
rotate: number;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { CanvasRenderer } from '@blocksuite/affine-block-surface';
|
||||
import { ExportManager } from '@blocksuite/affine-block-surface';
|
||||
import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref';
|
||||
import { isTopLevelBlock } from '@blocksuite/affine-shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
GfxControllerIdentifier,
|
||||
type GfxModel,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import { assertExists, Bound } from '@blocksuite/global/utils';
|
||||
|
||||
import { ExportManager } from '../../../_common/export-manager/export-manager.js';
|
||||
|
||||
export const edgelessToBlob = async (
|
||||
host: EditorHost,
|
||||
options: {
|
||||
@@ -19,12 +21,13 @@ export const edgelessToBlob = async (
|
||||
const exportManager = host.std.get(ExportManager);
|
||||
const bound = Bound.deserialize(edgelessElement.xywh);
|
||||
const isBlock = isTopLevelBlock(edgelessElement);
|
||||
const gfx = host.std.get(GfxControllerIdentifier);
|
||||
|
||||
return exportManager
|
||||
.edgelessToCanvas(
|
||||
options.surfaceRenderer,
|
||||
bound,
|
||||
undefined,
|
||||
gfx,
|
||||
isBlock ? [edgelessElement] : undefined,
|
||||
isBlock ? undefined : [edgelessElement],
|
||||
{ zoom: options.surfaceRenderer.viewport.zoom }
|
||||
|
||||
Reference in New Issue
Block a user