From b8232de551102d367a987cfc3811e36bd5be3e8b Mon Sep 17 00:00:00 2001 From: chenshenhai Date: Sun, 24 Dec 2023 16:43:16 +0800 Subject: [PATCH] feat: add export files features --- packages/board/src/index.ts | 7 +++ packages/core/src/index.ts | 6 ++- packages/idraw/src/file.ts | 58 +++++++++++++++++++++++ packages/idraw/src/idraw.ts | 22 ++++++++- packages/idraw/src/index.ts | 6 ++- packages/renderer/src/index.ts | 9 ++++ packages/renderer/src/loader.ts | 76 ++++++++++++++++-------------- packages/types/src/lib/loader.ts | 4 ++ packages/types/src/lib/renderer.ts | 4 +- packages/util/src/index.ts | 2 +- packages/util/src/lib/file.ts | 32 ++++++++++--- 11 files changed, 177 insertions(+), 49 deletions(-) create mode 100644 packages/idraw/src/file.ts diff --git a/packages/board/src/index.ts b/packages/board/src/index.ts index d0a444f..ceece4e 100644 --- a/packages/board/src/index.ts +++ b/packages/board/src/index.ts @@ -29,6 +29,7 @@ export class Board { #middlewares: BoardMiddleware[] = []; #activeMiddlewareObjs: BoardMiddlewareObject[] = []; #watcher: BoardWatcher; + #renderer: Renderer; #sharer: Sharer; #viewer: Viewer; #calculator: Calculator; @@ -51,6 +52,7 @@ export class Board { 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 { 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 { 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); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index be1f61e..a30129e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { setViewScale(opts: { scale: number; offsetX: number; offsetY: number }) { this.#board.updateViewScaleInfo(opts); } + + getLoadItemMap(): LoadItemMap { + return this.#board.getRenderer().getLoadItemMap(); + } } diff --git a/packages/idraw/src/file.ts b/packages/idraw/src/file.ts new file mode 100644 index 0000000..5d733ee --- /dev/null +++ b/packages/idraw/src/file.ts @@ -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 { + 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 + }; +} diff --git a/packages/idraw/src/idraw.ts b/packages/idraw/src/idraw.ts index 22ae92b..a2d7d14 100644 --- a/packages/idraw/src/idraw.ts +++ b/packages/idraw/src/idraw.ts @@ -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; @@ -232,4 +235,21 @@ export class iDraw { core.refresh(); core.trigger('change', { data, type: 'move-element' }); } + + async getImageBlobURL(opts: ExportImageFileBaseOptions): Promise { + 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() + }); + } } diff --git a/packages/idraw/src/index.ts b/packages/idraw/src/index.ts index 5cb259a..9bd64e4 100644 --- a/packages/idraw/src/index.ts +++ b/packages/idraw/src/index.ts @@ -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'; diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index 23fae28..2757500 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -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 implements BoardRen }); } } + + setLoadItemMap(itemMap: LoadItemMap) { + this.#loader.setLoadItemMap(itemMap); + } + + getLoadItemMap(): LoadItemMap { + return this.#loader.getLoadItemMap(); + } } diff --git a/packages/renderer/src/loader.ts b/packages/renderer/src/loader.ts index 17a8804..24424af 100644 --- a/packages/renderer/src/loader.ts +++ b/packages/renderer/src/loader.ts @@ -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 implements RendererLoader { - private _loadFuncMap: Record> = {}; - private _currentLoadItemMap: LoadItemMap = {}; - private _storageLoadItemMap: LoadItemMap = {}; + #loadFuncMap: Record> = {}; + #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 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 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 implements RendererLoad }; }); } - private _registerLoadFunc(type: T, func: LoadFunc) { + #registerLoadFunc(type: T, func: LoadFunc) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - this._loadFuncMap[type] = func; + this.#loadFuncMap[type] = func; } - private _getLoadElementSource(element: Element): null | string { + #getLoadElementSource(element: Element): 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 implements RendererLoad return source; } - private _createLoadItem(element: Element): LoadItem { + #createLoadItem(element: Element): LoadItem { return { element, status: 'null', @@ -89,44 +85,44 @@ export class Loader extends EventEmitter 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, assets: ElementAssets) { - const item = this._createLoadItem(element); + #loadResource(element: Element, 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 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) { + #isExistingErrorStorage(element: Element) { 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, 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): 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; } } diff --git a/packages/types/src/lib/loader.ts b/packages/types/src/lib/loader.ts index 81424d1..df24b9d 100644 --- a/packages/types/src/lib/loader.ts +++ b/packages/types/src/lib/loader.ts @@ -12,6 +12,10 @@ export interface LoadItem { source: string | null; } +export interface LoadItemMap { + [assetId: string]: LoadItem; +} + export interface LoaderEvent extends LoadItem { countTime: number; } diff --git a/packages/types/src/lib/renderer.ts b/packages/types/src/lib/renderer.ts index fd58505..eb9d56c 100644 --- a/packages/types/src/lib/renderer.ts +++ b/packages/types/src/lib/renderer.ts @@ -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 { // load(element: Element): void; load(element: Element, assets: ElementAssets): void; getContent(element: Element): LoadContent | null; + getLoadItemMap(): LoadItemMap; + setLoadItemMap(itemMap: LoadItemMap): void; } export interface RendererDrawOptions { diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 6992584..71f5896 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -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'; diff --git a/packages/util/src/lib/file.ts b/packages/util/src/lib/file.ts index 81ea447..22c7e21 100644 --- a/packages/util/src/lib/file.ts +++ b/packages/util/src/lib/file.ts @@ -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 { 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; +}