mirror of
https://github.com/idrawjs/idraw
synced 2026-05-23 09:38:22 +00:00
feat: add export files features
This commit is contained in:
parent
6aa8bffe42
commit
b8232de551
11 changed files with 177 additions and 49 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
packages/idraw/src/file.ts
Normal file
58
packages/idraw/src/file.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ export interface LoadItem {
|
|||
source: string | null;
|
||||
}
|
||||
|
||||
export interface LoadItemMap {
|
||||
[assetId: string]: LoadItem;
|
||||
}
|
||||
|
||||
export interface LoaderEvent extends LoadItem {
|
||||
countTime: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue