feat: add export files features

This commit is contained in:
chenshenhai 2023-12-24 16:43:16 +08:00
parent 6aa8bffe42
commit b8232de551
11 changed files with 177 additions and 49 deletions

View file

@ -29,6 +29,7 @@ export class Board<T extends BoardExtendEvent = BoardExtendEvent> {
#middlewares: BoardMiddleware[] = [];
#activeMiddlewareObjs: BoardMiddlewareObject[] = [];
#watcher: BoardWatcher;
#renderer: Renderer;
#sharer: Sharer;
#viewer: Viewer;
#calculator: Calculator;
@ -51,6 +52,7 @@ export class Board<T extends BoardExtendEvent = BoardExtendEvent> {
this.#opts = opts;
this.#sharer = sharer;
this.#watcher = watcher;
this.#renderer = renderer;
this.#calculator = calculator;
this.#viewer = new Viewer({
boardContent: opts.boardContent,
@ -253,6 +255,10 @@ export class Board<T extends BoardExtendEvent = BoardExtendEvent> {
return this.#viewer;
}
getRenderer() {
return this.#renderer;
}
setData(data: Data): { viewSizeInfo: ViewSizeInfo } {
const sharer = this.#sharer;
this.#sharer.setActiveStorage('data', data);
@ -338,6 +344,7 @@ export class Board<T extends BoardExtendEvent = BoardExtendEvent> {
boardContent.viewContext.$resize({ width, height, devicePixelRatio });
boardContent.helperContext.$resize({ width, height, devicePixelRatio });
boardContent.boardContext.$resize({ width, height, devicePixelRatio });
boardContent.underContext.$resize({ width, height, devicePixelRatio });
this.#viewer.drawFrame();
this.#watcher.trigger('resize', viewSize);
this.#sharer.setActiveViewSizeInfo(newViewSize);

View file

@ -1,4 +1,4 @@
import type { Data, PointSize, CoreOptions, BoardMiddleware, ViewSizeInfo, CoreEvent, ViewScaleInfo } from '@idraw/types';
import type { Data, PointSize, CoreOptions, BoardMiddleware, ViewSizeInfo, CoreEvent, ViewScaleInfo, LoadItemMap } from '@idraw/types';
import { Board } from '@idraw/board';
import { createBoardContent, validateElements } from '@idraw/util';
import { Cursor } from './lib/cursor';
@ -119,4 +119,8 @@ export class Core<E extends CoreEvent = CoreEvent> {
setViewScale(opts: { scale: number; offsetX: number; offsetY: number }) {
this.#board.updateViewScaleInfo(opts);
}
getLoadItemMap(): LoadItemMap {
return this.#board.getRenderer().getLoadItemMap();
}
}

View file

@ -0,0 +1,58 @@
import { Renderer } from '@idraw/renderer';
import { Calculator } from '@idraw/board';
import { createOffscreenContext2D } from '@idraw/util';
import type { Data, LoadItemMap, ViewContext2D, ViewScaleInfo, ViewSizeInfo } from '@idraw/types';
export interface ExportImageFileBaseOptions {
devicePixelRatio: number;
}
export type ExportImageFileOptions = ExportImageFileBaseOptions & {
data: Data;
width: number;
height: number;
loadItemMap: LoadItemMap;
viewScaleInfo: ViewScaleInfo;
viewSizeInfo: ViewSizeInfo;
};
export type ExportImageFileResult = {
blobURL: string | null;
width: number;
height: number;
devicePixelRatio: number;
};
export async function exportImageFileBlobURL(opts: ExportImageFileOptions): Promise<ExportImageFileResult> {
const { data, width, height, devicePixelRatio, viewScaleInfo, viewSizeInfo, loadItemMap } = opts;
let viewContext: ViewContext2D | null = createOffscreenContext2D({ width, height, devicePixelRatio });
let calculator: Calculator | null = new Calculator({ viewContext });
let renderer: Renderer | null = new Renderer({
viewContext,
calculator
});
renderer.setLoadItemMap(loadItemMap);
renderer.drawData(data, {
viewScaleInfo,
viewSizeInfo,
forceDrawAll: true
});
let blobURL: string | null = null;
let offScreenCanvas = viewContext.$getOffscreenCanvas();
if (offScreenCanvas) {
const blob = await offScreenCanvas.convertToBlob();
blobURL = window.URL.createObjectURL(blob);
}
offScreenCanvas = null;
viewContext = null;
calculator = null;
renderer = null;
return {
blobURL,
width,
height,
devicePixelRatio
};
}

View file

@ -27,9 +27,12 @@ import {
updateElementInList,
deleteElementInList,
moveElementPosition,
getElementPositionFromList
getElementPositionFromList,
calcElementListSize
} from '@idraw/util';
import { defaultSettings } from './config';
import { exportImageFileBlobURL } from './file';
import type { ExportImageFileBaseOptions, ExportImageFileResult } from './file';
export class iDraw {
#core: Core<IDrawEvent>;
@ -232,4 +235,21 @@ export class iDraw {
core.refresh();
core.trigger('change', { data, type: 'move-element' });
}
async getImageBlobURL(opts: ExportImageFileBaseOptions): Promise<ExportImageFileResult> {
const data = this.getData() || { elements: [] };
const { devicePixelRatio } = opts;
const outputSize = calcElementListSize(data.elements);
const { viewSizeInfo } = this.getViewInfo();
return await exportImageFileBlobURL({
width: outputSize.w,
height: outputSize.h,
devicePixelRatio,
data,
viewScaleInfo: { scale: 1, offsetLeft: -outputSize.x, offsetTop: -outputSize.y, offsetBottom: 0, offsetRight: 0 },
viewSizeInfo,
loadItemMap: this.#core.getLoadItemMap()
});
}
}

View file

@ -1,5 +1,3 @@
export { iDraw } from './idraw';
export type { IDrawEvent, IDrawEventKeys } from './event';
export type * from '@idraw/types';
export {
Core,
@ -22,6 +20,7 @@ export {
parseFileToBase64,
pickFile,
parseFileToText,
downloadFileFromText,
toColorHexStr,
toColorHexNum,
isColorStr,
@ -114,3 +113,6 @@ export {
deleteElementInListByPosition,
deleteElementInList
} from '@idraw/util';
export { iDraw } from './idraw';
export type { IDrawEvent, IDrawEventKeys } from './event';
export type { ExportImageFileResult, ExportImageFileBaseOptions } from './file';

View file

@ -1,4 +1,5 @@
import { EventEmitter } from '@idraw/util';
import type { LoadItemMap } from '@idraw/types';
import { drawElementList } from './draw/index';
import { Loader } from './loader';
import type { Data, BoardRenderer, RendererOptions, RendererEventMap, RendererDrawOptions } from '@idraw/types';
@ -82,4 +83,12 @@ export class Renderer extends EventEmitter<RendererEventMap> implements BoardRen
});
}
}
setLoadItemMap(itemMap: LoadItemMap) {
this.#loader.setLoadItemMap(itemMap);
}
getLoadItemMap(): LoadItemMap {
return this.#loader.getLoadItemMap();
}
}

View file

@ -1,10 +1,6 @@
import type { RendererLoader, LoaderEventMap, LoadFunc, LoadContent, LoadItem, LoadElementType, Element, ElementAssets } from '@idraw/types';
import type { RendererLoader, LoaderEventMap, LoadFunc, LoadContent, LoadItem, LoadItemMap, LoadElementType, Element, ElementAssets } from '@idraw/types';
import { loadImage, loadHTML, loadSVG, EventEmitter, createAssetId, isAssetId, createUUID } from '@idraw/util';
interface LoadItemMap {
[assetId: string]: LoadItem;
}
const supportElementTypes: LoadElementType[] = ['image', 'svg', 'html'];
const getAssetIdFromElement = (element: Element<'image' | 'svg' | 'html'>) => {
@ -26,13 +22,13 @@ const getAssetIdFromElement = (element: Element<'image' | 'svg' | 'html'>) => {
};
export class Loader extends EventEmitter<LoaderEventMap> implements RendererLoader {
private _loadFuncMap: Record<LoadElementType | string, LoadFunc<LoadElementType, LoadContent>> = {};
private _currentLoadItemMap: LoadItemMap = {};
private _storageLoadItemMap: LoadItemMap = {};
#loadFuncMap: Record<LoadElementType | string, LoadFunc<LoadElementType, LoadContent>> = {};
#currentLoadItemMap: LoadItemMap = {};
#storageLoadItemMap: LoadItemMap = {};
constructor() {
super();
this._registerLoadFunc<'image'>('image', async (elem: Element<'image'>, assets: ElementAssets) => {
this.#registerLoadFunc<'image'>('image', async (elem: Element<'image'>, assets: ElementAssets) => {
const src = assets[elem.detail.src]?.value || elem.detail.src;
const content = await loadImage(src);
return {
@ -41,7 +37,7 @@ export class Loader extends EventEmitter<LoaderEventMap> implements RendererLoad
content
};
});
this._registerLoadFunc<'html'>('html', async (elem: Element<'html'>, assets: ElementAssets) => {
this.#registerLoadFunc<'html'>('html', async (elem: Element<'html'>, assets: ElementAssets) => {
const html = assets[elem.detail.html]?.value || elem.detail.html;
const content = await loadHTML(html, {
width: elem.detail.width || elem.w,
@ -53,7 +49,7 @@ export class Loader extends EventEmitter<LoaderEventMap> implements RendererLoad
content
};
});
this._registerLoadFunc<'svg'>('svg', async (elem: Element<'svg'>, assets: ElementAssets) => {
this.#registerLoadFunc<'svg'>('svg', async (elem: Element<'svg'>, assets: ElementAssets) => {
const svg = assets[elem.detail.svg]?.value || elem.detail.svg;
const content = await loadSVG(svg);
return {
@ -63,13 +59,13 @@ export class Loader extends EventEmitter<LoaderEventMap> implements RendererLoad
};
});
}
private _registerLoadFunc<T extends LoadElementType>(type: T, func: LoadFunc<T, LoadContent>) {
#registerLoadFunc<T extends LoadElementType>(type: T, func: LoadFunc<T, LoadContent>) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this._loadFuncMap[type] = func;
this.#loadFuncMap[type] = func;
}
private _getLoadElementSource(element: Element<LoadElementType>): null | string {
#getLoadElementSource(element: Element<LoadElementType>): null | string {
let source: string | null = null;
if (element.type === 'image') {
source = (element as Element<'image'>)?.detail?.src || null;
@ -81,7 +77,7 @@ export class Loader extends EventEmitter<LoaderEventMap> implements RendererLoad
return source;
}
private _createLoadItem(element: Element<LoadElementType>): LoadItem {
#createLoadItem(element: Element<LoadElementType>): LoadItem {
return {
element,
status: 'null',
@ -89,44 +85,44 @@ export class Loader extends EventEmitter<LoaderEventMap> implements RendererLoad
error: null,
startTime: -1,
endTime: -1,
source: this._getLoadElementSource(element)
source: this.#getLoadElementSource(element)
};
}
private _emitLoad(item: LoadItem) {
#emitLoad(item: LoadItem) {
const assetId = getAssetIdFromElement(item.element);
const storageItem = this._storageLoadItemMap[assetId];
const storageItem = this.#storageLoadItemMap[assetId];
if (storageItem) {
if (storageItem.startTime < item.startTime) {
this._storageLoadItemMap[assetId] = item;
this.#storageLoadItemMap[assetId] = item;
this.trigger('load', { ...item, countTime: item.endTime - item.startTime });
}
} else {
this._storageLoadItemMap[assetId] = item;
this.#storageLoadItemMap[assetId] = item;
this.trigger('load', { ...item, countTime: item.endTime - item.startTime });
}
}
private _emitError(item: LoadItem) {
#emitError(item: LoadItem) {
const assetId = getAssetIdFromElement(item.element);
const storageItem = this._storageLoadItemMap[assetId];
const storageItem = this.#storageLoadItemMap[assetId];
if (storageItem) {
if (storageItem.startTime < item.startTime) {
this._storageLoadItemMap[assetId] = item;
this.#storageLoadItemMap[assetId] = item;
this.trigger('error', { ...item, countTime: item.endTime - item.startTime });
}
} else {
this._storageLoadItemMap[assetId] = item;
this.#storageLoadItemMap[assetId] = item;
this.trigger('error', { ...item, countTime: item.endTime - item.startTime });
}
}
private _loadResource(element: Element<LoadElementType>, assets: ElementAssets) {
const item = this._createLoadItem(element);
#loadResource(element: Element<LoadElementType>, assets: ElementAssets) {
const item = this.#createLoadItem(element);
const assetId = getAssetIdFromElement(element);
this._currentLoadItemMap[assetId] = item;
const loadFunc = this._loadFuncMap[element.type];
this.#currentLoadItemMap[assetId] = item;
const loadFunc = this.#loadFuncMap[element.type];
if (typeof loadFunc === 'function') {
item.startTime = Date.now();
loadFunc(element, assets)
@ -134,39 +130,47 @@ export class Loader extends EventEmitter<LoaderEventMap> implements RendererLoad
item.content = result.content;
item.endTime = Date.now();
item.status = 'load';
this._emitLoad(item);
this.#emitLoad(item);
})
.catch((err: Error) => {
console.warn(`Load element source "${item.source}" fail`, err, element);
item.endTime = Date.now();
item.status = 'error';
item.error = err;
this._emitError(item);
this.#emitError(item);
});
}
}
private _isExistingErrorStorage(element: Element<LoadElementType>) {
#isExistingErrorStorage(element: Element<LoadElementType>) {
const assetId = getAssetIdFromElement(element);
const existItem = this._currentLoadItemMap?.[assetId];
if (existItem && existItem.status === 'error' && existItem.source && existItem.source === this._getLoadElementSource(element)) {
const existItem = this.#currentLoadItemMap?.[assetId];
if (existItem && existItem.status === 'error' && existItem.source && existItem.source === this.#getLoadElementSource(element)) {
return true;
}
return false;
}
load(element: Element<LoadElementType>, assets: ElementAssets) {
if (this._isExistingErrorStorage(element)) {
if (this.#isExistingErrorStorage(element)) {
return;
}
if (supportElementTypes.includes(element.type)) {
// const elem = deepClone(element);
this._loadResource(element, assets);
this.#loadResource(element, assets);
}
}
getContent(element: Element<LoadElementType>): LoadContent | null {
const assetId = getAssetIdFromElement(element);
return this._storageLoadItemMap?.[assetId]?.content || null;
return this.#storageLoadItemMap?.[assetId]?.content || null;
}
getLoadItemMap(): LoadItemMap {
return this.#storageLoadItemMap;
}
setLoadItemMap(itemMap: LoadItemMap) {
this.#storageLoadItemMap = itemMap;
}
}

View file

@ -12,6 +12,10 @@ export interface LoadItem {
source: string | null;
}
export interface LoadItemMap {
[assetId: string]: LoadItem;
}
export interface LoaderEvent extends LoadItem {
countTime: number;
}

View file

@ -1,6 +1,6 @@
import type { ViewScaleInfo, ViewCalculator, ViewSizeInfo } from './view';
import type { Element, ElementSize, ElementAssets } from './element';
import type { LoaderEventMap, LoadElementType, LoadContent } from './loader';
import type { LoaderEventMap, LoadElementType, LoadContent, LoadItemMap } from './loader';
import type { UtilEventEmitter } from './util';
import type { StoreSharer } from './store';
import { ViewContext2D } from '@idraw/types';
@ -23,6 +23,8 @@ export interface RendererLoader extends UtilEventEmitter<LoaderEventMap> {
// load(element: Element<LoadElementType>): void;
load(element: Element<LoadElementType>, assets: ElementAssets): void;
getContent(element: Element<LoadElementType>): LoadContent | null;
getLoadItemMap(): LoadItemMap;
setLoadItemMap(itemMap: LoadItemMap): void;
}
export interface RendererDrawOptions {

View file

@ -1,5 +1,5 @@
export { delay, compose, throttle } from './lib/time';
export { downloadImageFromCanvas, parseFileToBase64, pickFile, parseFileToText } from './lib/file';
export { downloadImageFromCanvas, parseFileToBase64, pickFile, parseFileToText, downloadFileFromText } from './lib/file';
export { toColorHexStr, toColorHexNum, isColorStr, colorNameToHex, colorToCSS, colorToLinearGradientCSS, mergeHexColorAlpha } from './lib/color';
export { createUUID, isAssetId, createAssetId } from './lib/uuid';
export { deepClone, sortDataAsserts } from './lib/data';

View file

@ -1,14 +1,13 @@
type ImageType = 'image/jpeg' | 'image/png';
export function downloadImageFromCanvas(canvas: HTMLCanvasElement, opts: { filename: string; type: ImageType }): void {
const { filename, type = 'image/jpeg' } = opts;
export function downloadImageFromCanvas(canvas: HTMLCanvasElement, opts: { fileName: string; type: ImageType }): void {
const { fileName, type = 'image/jpeg' } = opts;
const stream = canvas.toDataURL(type);
const downloadLink = document.createElement('a');
let downloadLink: HTMLAnchorElement | null = document.createElement('a');
downloadLink.href = stream;
downloadLink.download = filename;
const downloadClickEvent = document.createEvent('MouseEvents');
downloadClickEvent.initEvent('click', true, false);
downloadLink.dispatchEvent(downloadClickEvent);
downloadLink.download = fileName;
downloadLink.click();
downloadLink = null;
}
export function pickFile(opts: { success: (data: { file: File }) => void; error?: (err: ErrorEvent) => void }) {
@ -70,3 +69,22 @@ export function parseFileToText(file: File): Promise<string | ArrayBuffer> {
reader.readAsText(file);
});
}
export function parseTextToBlobURL(text: string) {
const bytes = new TextEncoder().encode(text);
const blob = new Blob([bytes], {
type: 'text/plain;charset=utf-8'
});
const blobURL = window.URL.createObjectURL(blob);
return blobURL;
}
export function downloadFileFromText(text: string, opts: { fileName: string }): void {
const { fileName } = opts;
const blobURL = parseTextToBlobURL(text);
let downloadLink: HTMLAnchorElement | null = document.createElement('a');
downloadLink.href = blobURL;
downloadLink.download = fileName;
downloadLink.click();
downloadLink = null;
}