diff --git a/jest.config.js b/jest.config.js index 4f69cbb..ba142b7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,15 +20,19 @@ module.exports = { modulePaths: [''], testRegex: '(/packages/([^/]{1,})/__tests__/.*)\\.test.ts$', testPathIgnorePatterns: [ - '(/packages/board/__tests__/.*)\\.test.ts$', + // TODO '(/packages/core/__tests__/.*)\\.test.ts$', - '(/packages/idraw/__tests__/.*)\\.test.ts$', + // '(/packages/idraw/__tests__/.*)\\.test.ts$', '(/packages/renderer/__tests__/.*)\\.test.ts$', '(/packages/types/__tests__/.*)\\.test.ts$' ], moduleNameMapper: { - '@idraw/util': '/packages/util/src/index.ts' + '@idraw/types': '/packages/types/src/index.ts', + '@idraw/util': '/packages/util/src/index.ts', + '@idraw/renderer': '/packages/renderer/src/index.ts', + '@idraw/core': '/packages/core/src/index.ts', + '^idraw$': '/packages/idraw/src/index.ts' }, // "testRegex": "(/packages/idraw/__tests__/.*)\\.test.ts$", - setupFiles: ['jest-canvas-mock'] + setupFiles: ['jest-canvas-mock', './jest.setup.js'] }; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..f882fab --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,29 @@ +/* eslint-disable no-undef */ +// mock OffscreenCanvas +global.OffscreenCanvas = class OffscreenCanvas { + constructor(width, height) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + this.__canvas = canvas; + } + getContext(type) { + return this.__canvas.getContext(type); + } + get width() { + return this.__canvas.width; + } + set width(value) { + this.__canvas.width = value; + } + get height() { + return this.__canvas.height; + } + set height(value) { + this.__canvas.height = value; + } + + get canvas() { + return this.__canvas; + } +}; diff --git a/package.json b/package.json index 9fcd592..d95775b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": false, - "version": "0.4.0-beta.40", + "version": "0.4.0-beta.41", "workspaces": [ "packages/*" ], diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index b4628b1..ed8fec5 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -13,8 +13,7 @@ import type { ViewSizeInfo, PointSize, BoardExtendEventMap, - UtilEventEmitter, - ModifyOptions + UtilEventEmitter } from '@idraw/types'; import { BoardWatcher } from './watcher'; import { Sharer } from './sharer'; @@ -30,8 +29,8 @@ interface BoardMiddlewareMapItem { export class Board { #opts: BoardOptions; - #middlewareMap: WeakMap = new WeakMap(); - #middlewares: BoardMiddleware[] = []; + #middlewareMap: Map = new Map(); + // #middlewares: BoardMiddleware[] = []; #activeMiddlewareObjs: BoardMiddlewareObject[] = []; #watcher: BoardWatcher; #renderer: Renderer; @@ -291,13 +290,12 @@ export class Board { #resetActiveMiddlewareObjs() { const activeMiddlewareObjs: BoardMiddlewareObject[] = []; - const middlewareMap = this.#middlewareMap; - this.#middlewares.forEach((middleware: BoardMiddleware) => { - const item = middlewareMap.get(middleware); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const [_, item] of this.#middlewareMap) { if (item?.status === 'enable' && item?.middlewareObject) { activeMiddlewareObjs.push(item.middlewareObject); } - }); + } this.#activeMiddlewareObjs = activeMiddlewareObjs; } @@ -313,13 +311,7 @@ export class Board { return this.#renderer; } - setData( - data: Data, - opts?: { - modifiedOptions?: ModifyOptions; // TODO - } - ): { viewSizeInfo: ViewSizeInfo } { - const { modifiedOptions } = opts || {}; + setData(data: Data): { viewSizeInfo: ViewSizeInfo } { const sharer = this.#sharer; this.#sharer.setActiveStorage('data', data); const viewSizeInfo = sharer.getActiveViewSizeInfo(); @@ -330,17 +322,11 @@ export class Board { viewHeight: viewSizeInfo.height, extend: true }); - if (modifiedOptions) { - this.#viewer.resetVirtualFlatItemMap(data, { - viewSizeInfo, - viewScaleInfo - }); - } else { - this.#viewer.resetVirtualFlatItemMap(data, { - viewSizeInfo, - viewScaleInfo - }); - } + + this.#viewer.resetVirtualFlatItemMap(data, { + viewSizeInfo, + viewScaleInfo + }); this.#viewer.drawFrame(); const newViewSizeInfo = { @@ -360,14 +346,14 @@ export class Board { use(middleware: BoardMiddleware, config?: Partial) { if (this.#middlewareMap.has(middleware)) { const item = this.#middlewareMap.get(middleware); - if (item) { - item.middlewareObject.use?.(); + if (item && item.status !== 'enable') { item.status = 'enable'; - this.#middlewareMap.set(middleware, item); + item.middlewareObject.use?.(); this.#resetActiveMiddlewareObjs(); - return; } + return; } + const { boardContent, container } = this.#opts; const sharer = this.#sharer; const viewer = this.#viewer; @@ -379,8 +365,6 @@ export class Board { config ); obj.use?.(); - this.#middlewares.push(middleware); - this.#activeMiddlewareObjs.push(obj); this.#middlewareMap.set(middleware, { status: 'enable', @@ -391,11 +375,13 @@ export class Board { } disuse(middleware: BoardMiddleware) { - const item = this.#middlewareMap.get(middleware); - if (item) { - item.middlewareObject.disuse?.(); - item.status = 'disable'; - this.#middlewareMap.set(middleware, item); + if (this.#middlewareMap.has(middleware)) { + const item = this.#middlewareMap.get(middleware); + if (item) { + item.middlewareObject.disuse?.(); + item.status = 'disable'; + } + this.#middlewareMap.delete(middleware); this.#resetActiveMiddlewareObjs(); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a6a82c2..dcafe41 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,16 +2,41 @@ import type { Data, PointSize, CoreOptions, - BoardMiddleware, + Middleware, ViewSizeInfo, CoreEventMap, ViewScaleInfo, LoadItemMap, - ModifyOptions + ElementType, + RecursivePartial, + Element, + ModifyRecord, + ElementPosition, + DataLayout, + FlattenLayout, + DataGlobal } from '@idraw/types'; +import { + deepClone, + createElement, + getElementPositionFromList, + toFlattenElement, + deleteElementInList, + findElementFromListByPosition, + updateElementInListByPosition, + insertElementToListByPosition, + moveElementPosition, + toFlattenLayout, + toFlattenGlobal, + get, + mergeLayout, + mergeGlobal +} from '@idraw/util'; import { Board, Sharer, Calculator } from './board'; import { createBoardContent, validateElements } from '@idraw/util'; import { Cursor } from './lib/cursor'; +import { getModifyElementRecord } from './record'; + export { coreEventKeys } from './config'; export type { CoreEventKeys } from './config'; @@ -77,26 +102,21 @@ export class Core { container.style.position = 'relative'; } - use(middleware: BoardMiddleware, config?: C) { + use(middleware: Middleware, config?: C) { this.#board.use(middleware, config); } - disuse(middleware: BoardMiddleware) { + disuse(middleware: Middleware) { this.#board.disuse(middleware); } - resetMiddlewareConfig(middleware: BoardMiddleware, config?: Partial) { + resetMiddlewareConfig(middleware: Middleware, config?: Partial) { this.#board.resetMiddlewareConfig(middleware, config); } - setData( - data: Data, - opts?: { - modifiedOptions?: ModifyOptions; - } - ) { + setData(data: Data) { validateElements(data?.elements || []); - this.#board.setData(data, opts); + this.#board.setData(data); } getData(): Data | null { @@ -168,4 +188,265 @@ export class Core { offBoardWatcherEvents() { this.#board.offWatcherEvents(); } + + createElement( + type: T, + element: RecursivePartial>, + opts?: { + viewCenter?: boolean; + } + ): Element { + const { viewScaleInfo, viewSizeInfo } = this.getViewInfo(); + return createElement( + type, + element || {}, + opts?.viewCenter === true + ? { + viewScaleInfo, + viewSizeInfo + } + : undefined + ); + } + + updateElement(element: Element): ModifyRecord<'updateElement'> | null { + const data: Data = this.getData() || { elements: [] }; + const uuid = element.uuid; + const position = getElementPositionFromList(uuid, data.elements); + const beforeElem = findElementFromListByPosition(position, data.elements); + if (!beforeElem) { + return null; + } + const before = toFlattenElement(beforeElem); + const updatedElement = updateElementInListByPosition(position, element, data.elements, { strict: true }) as Element; + const after = toFlattenElement(updatedElement); + this.setData(data); + this.refresh(); + + const modifyRecord: ModifyRecord<'updateElement'> = { + type: 'updateElement', + time: Date.now(), + content: { method: 'updateElement', uuid, before, after } + }; + return modifyRecord; + } + + modifyElement( + element: RecursivePartial> & Pick + ): ModifyRecord<'modifyElement'> | null { + const { uuid, ...restElement } = element; + const data: Data = this.getData() || { elements: [] }; + const position = getElementPositionFromList(uuid, data.elements); + const beforeElem = findElementFromListByPosition(position, data.elements); + if (!beforeElem) { + return null; + } + const modifyRecord: ModifyRecord<'modifyElement'> = getModifyElementRecord({ + modifiedElement: element, + beforeElement: beforeElem + }); + updateElementInListByPosition(position, restElement, data.elements) as Element; + this.setData(data); + this.refresh(); + return modifyRecord; + } + + modifyElements( + elements: Array> & Pick> + ): ModifyRecord<'modifyElements'> | null { + const data: Data = this.getData() || { elements: [] }; + let modifyRecord: ModifyRecord<'modifyElements'> | null = null; + const before: (FlattenLayout & { uuid: string })[] = []; + const after: (FlattenLayout & { uuid: string })[] = []; + elements.forEach((element) => { + const { uuid, ...restElement } = element; + const position = getElementPositionFromList(uuid, data.elements); + const beforeElem = findElementFromListByPosition(position, data.elements); + if (!beforeElem) { + return null; + } + const tempRecord = getModifyElementRecord({ + modifiedElement: element, + beforeElement: beforeElem + }); + if (tempRecord.content) { + before.push({ + ...tempRecord.content.before, + uuid + }); + after.push({ + ...tempRecord.content.after, + uuid + }); + } + updateElementInListByPosition(position, restElement, data.elements) as Element; + }); + + modifyRecord = { + type: 'modifyElements', + time: Date.now(), + content: { + method: 'modifyElements', + before, + after + } + }; + + this.setData(data); + this.refresh(); + return modifyRecord; + } + + addElement( + element: Element, + opts?: { + position: ElementPosition; + } + ): ModifyRecord<'addElement'> { + const data: Data = this.getData() || { elements: [] }; + + if (!opts || !opts?.position?.length) { + data.elements.push(element); + } else if (opts?.position) { + const position = [...(opts?.position || [])]; + insertElementToListByPosition(element, position, data.elements); + } + const position: ElementPosition = getElementPositionFromList(element.uuid, data.elements); + const modifyRecord: ModifyRecord<'addElement'> = { + type: 'addElement', + time: Date.now(), + content: { method: 'addElement', uuid: element.uuid, position, element: deepClone(element) } + }; + this.setData(data); + this.refresh(); + return modifyRecord; + } + + deleteElement(uuid: string): ModifyRecord<'deleteElement'> { + const data: Data = this.getData() || { elements: [] }; + const position = getElementPositionFromList(uuid, data.elements); + const element = findElementFromListByPosition(position, data.elements); + const modifyRecord: ModifyRecord<'deleteElement'> = { + type: 'deleteElement', + time: Date.now(), + content: { method: 'deleteElement', uuid, position, element: element ? deepClone(element) : null } + }; + deleteElementInList(uuid, data.elements); + this.setData(data); + this.refresh(); + return modifyRecord; + } + + moveElement(uuid: string, to: ElementPosition): ModifyRecord<'moveElement'> { + const data: Data = this.getData() || { elements: [] }; + const from = getElementPositionFromList(uuid, data.elements); + + const modifyRecord: ModifyRecord<'moveElement'> = { + type: 'moveElement', + time: Date.now(), + content: { method: 'moveElement', uuid, from: [...from], to: [...to] } + }; + const { elements: list } = moveElementPosition(data.elements, { from, to }); + data.elements = list; + this.setData(data); + this.refresh(); + return modifyRecord; + } + + modifyLayout(layout: RecursivePartial | null): ModifyRecord<'modifyLayout'> { + const data: Data = this.getData() || { elements: [] }; + const modifyRecord: ModifyRecord<'modifyLayout'> = { + type: 'modifyLayout', + time: Date.now(), + content: { + method: 'modifyLayout', + before: null, + after: null + } + }; + + if (layout === null) { + if (data.layout) { + modifyRecord.content.before = toFlattenLayout(data.layout); + delete data['layout']; + this.setData(data); + this.refresh(); + return modifyRecord; + } else { + return modifyRecord; + } + } + + const beforeLayout = data.layout; + let before: FlattenLayout = {}; + const after: FlattenLayout = toFlattenLayout(layout); + + if (data.layout) { + Object.keys(after).forEach((key: string) => { + let val = get(beforeLayout, key); + if (val === undefined && /(borderRadius|borderWidth)\[[0-9]{1,}\]$/.test(key)) { + key = key.replace(/\[[0-9]{1,}\]$/, ''); + val = get(beforeLayout, key); + } + before[key] = val; + }); + before = toFlattenLayout(before); + modifyRecord.content.before = before; + } else { + data.layout = {} as any; + } + + modifyRecord.content.after = after; + mergeLayout(data.layout as DataLayout, layout) as DataLayout; + + this.setData(data); + this.refresh(); + return modifyRecord; + } + + modifyGlobal(global: RecursivePartial | null) { + const data: Data = this.getData() || { elements: [] }; + const modifyRecord: ModifyRecord<'modifyGlobal'> = { + type: 'modifyGlobal', + time: Date.now(), + content: { + method: 'modifyGlobal', + before: null, + after: null + } + }; + + if (global === null) { + if (data.global) { + modifyRecord.content.before = toFlattenGlobal(data.global); + delete data['global']; + this.setData(data); + this.refresh(); + return modifyRecord; + } else { + return modifyRecord; + } + } + + const beforeGlobal = data.global; + let before: FlattenLayout = {}; + const after: FlattenLayout = toFlattenGlobal(global); + + if (data.global) { + Object.keys(after).forEach((key: string) => { + before[key] = get(beforeGlobal, key); + }); + before = toFlattenGlobal(before); + modifyRecord.content.before = before; + } else { + data.global = {} as any; + } + + modifyRecord.content.after = after; + mergeGlobal(data.global as DataGlobal, global) as DataGlobal; + + this.setData(data); + this.refresh(); + return modifyRecord; + } } diff --git a/packages/core/src/middleware/dragger/index.ts b/packages/core/src/middleware/dragger/index.ts index 104c233..ca83b7c 100644 --- a/packages/core/src/middleware/dragger/index.ts +++ b/packages/core/src/middleware/dragger/index.ts @@ -1,4 +1,4 @@ -import type { BoardMiddleware, CoreEventMap, Point } from '@idraw/types'; +import type { Middleware, CoreEventMap, Point } from '@idraw/types'; import { coreEventKeys } from '../../config'; const key = 'DRAG'; @@ -8,7 +8,7 @@ type DraggerSharedStorage = { [keyPrevPoint]: Point | null; }; -export const MiddlewareDragger: BoardMiddleware = (opts) => { +export const MiddlewareDragger: Middleware = (opts) => { const { eventHub, sharer, viewer } = opts; let isDragging = false; diff --git a/packages/core/src/middleware/info/draw-info.ts b/packages/core/src/middleware/info/draw-info.ts index 9fced8b..5542b73 100644 --- a/packages/core/src/middleware/info/draw-info.ts +++ b/packages/core/src/middleware/info/draw-info.ts @@ -6,7 +6,15 @@ const fontFamily = 'monospace'; export function drawSizeInfoText( ctx: ViewContext2D, - opts: { point: PointSize; rotateCenter: PointSize; angle: number; text: string; fontSize: number; lineHeight: number; style: MiddlewareInfoStyle } + opts: { + point: PointSize; + rotateCenter: PointSize; + angle: number; + text: string; + fontSize: number; + lineHeight: number; + style: MiddlewareInfoStyle; + } ) { const { point, rotateCenter, angle, text, style, fontSize, lineHeight } = opts; const { textColor, textBackground } = style; @@ -39,7 +47,7 @@ export function drawSizeInfoText( ctx.lineTo(bgEnd.x, bgEnd.y); ctx.lineTo(bgStart.x, bgEnd.y); ctx.closePath(); - ctx.fill(); + ctx.fill('nonzero'); ctx.fillStyle = textColor; ctx.textBaseline = 'top'; @@ -49,7 +57,15 @@ export function drawSizeInfoText( export function drawPositionInfoText( ctx: ViewContext2D, - opts: { point: PointSize; rotateCenter: PointSize; angle: number; text: string; fontSize: number; lineHeight: number; style: MiddlewareInfoStyle } + opts: { + point: PointSize; + rotateCenter: PointSize; + angle: number; + text: string; + fontSize: number; + lineHeight: number; + style: MiddlewareInfoStyle; + } ) { const { point, rotateCenter, angle, text, style, fontSize, lineHeight } = opts; const { textBackground, textColor } = style; @@ -82,7 +98,7 @@ export function drawPositionInfoText( ctx.lineTo(bgEnd.x, bgEnd.y); ctx.lineTo(bgStart.x, bgEnd.y); ctx.closePath(); - ctx.fill(); + ctx.fill('nonzero'); ctx.fillStyle = textColor; ctx.textBaseline = 'top'; @@ -92,7 +108,15 @@ export function drawPositionInfoText( export function drawAngleInfoText( ctx: ViewContext2D, - opts: { point: PointSize; rotateCenter: PointSize; angle: number; text: string; fontSize: number; lineHeight: number; style: MiddlewareInfoStyle } + opts: { + point: PointSize; + rotateCenter: PointSize; + angle: number; + text: string; + fontSize: number; + lineHeight: number; + style: MiddlewareInfoStyle; + } ) { const { point, rotateCenter, angle, text, style, fontSize, lineHeight } = opts; const { textBackground, textColor } = style; @@ -125,7 +149,7 @@ export function drawAngleInfoText( ctx.lineTo(bgEnd.x, bgEnd.y); ctx.lineTo(bgStart.x, bgEnd.y); ctx.closePath(); - ctx.fill(); + ctx.fill('nonzero'); ctx.fillStyle = textColor; ctx.textBaseline = 'top'; diff --git a/packages/core/src/middleware/info/index.ts b/packages/core/src/middleware/info/index.ts index 14ccfc8..34cf9b4 100644 --- a/packages/core/src/middleware/info/index.ts +++ b/packages/core/src/middleware/info/index.ts @@ -1,5 +1,13 @@ -import type { BoardMiddleware, ViewRectInfo, Element, MiddlewareInfoConfig, CoreEventMap } from '@idraw/types'; -import { formatNumber, getViewScaleInfoFromSnapshot, getViewSizeInfoFromSnapshot, createUUID, limitAngle, rotatePoint, parseAngleToRadian } from '@idraw/util'; +import type { Middleware, ViewRectInfo, Element, MiddlewareInfoConfig, CoreEventMap } from '@idraw/types'; +import { + formatNumber, + getViewScaleInfoFromSnapshot, + getViewSizeInfoFromSnapshot, + createUUID, + limitAngle, + rotatePoint, + parseAngleToRadian +} from '@idraw/util'; import { keySelectedElementList, keyActionType, keyGroupQueue } from '../selector'; import { drawSizeInfoText, drawPositionInfoText, drawAngleInfoText } from './draw-info'; import type { DeepInfoSharedStorage } from './types'; @@ -10,7 +18,7 @@ export { MIDDLEWARE_INTERNAL_EVENT_SHOW_INFO_ANGLE }; const infoFontSize = 10; const infoLineHeight = 16; -export const MiddlewareInfo: BoardMiddleware< +export const MiddlewareInfo: Middleware< DeepInfoSharedStorage, CoreEventMap & { [MIDDLEWARE_INTERNAL_EVENT_SHOW_INFO_ANGLE]: { show: boolean }; diff --git a/packages/core/src/middleware/layout-selector/index.ts b/packages/core/src/middleware/layout-selector/index.ts index 89ee133..2c20639 100644 --- a/packages/core/src/middleware/layout-selector/index.ts +++ b/packages/core/src/middleware/layout-selector/index.ts @@ -1,10 +1,21 @@ -import type { BoardMiddleware, ElementSize, Point, MiddlewareLayoutSelectorConfig, CoreEventMap } from '@idraw/types'; +import type { + Middleware, + ElementSize, + Point, + MiddlewareLayoutSelectorConfig, + CoreEventMap, + RecursivePartial, + ModifyRecord, + DataLayout +} from '@idraw/types'; import { calcLayoutSizeController, isViewPointInVertexes, getViewScaleInfoFromSnapshot, isViewPointInElementSize, - calcViewElementSize + calcViewElementSize, + getElementSize, + toFlattenLayout } from '@idraw/util'; import type { LayoutSelectorSharedStorage, ControlType } from './types'; import { @@ -27,7 +38,7 @@ import { coreEventKeys } from '../../config'; export { keyLayoutIsSelected, keyLayoutIsBusyMoving }; -export const MiddlewareLayoutSelector: BoardMiddleware< +export const MiddlewareLayoutSelector: Middleware< LayoutSelectorSharedStorage, CoreEventMap, MiddlewareLayoutSelectorConfig @@ -43,6 +54,8 @@ export const MiddlewareLayoutSelector: BoardMiddleware< let prevIsHoverContent: boolean | null = null; let prevIsSelected: boolean | null = null; + let pointStartLayoutSize: RecursivePartial | null = null; + const clear = () => { prevPoint = null; sharer.setSharedStorage(keyLayoutActionType, null); @@ -241,6 +254,12 @@ export const MiddlewareLayoutSelector: BoardMiddleware< } sharer.setSharedStorage(keyLayoutIsSelected, false); } + const data = sharer.getActiveStorage('data'); + if (data?.layout) { + pointStartLayoutSize = getElementSize(data.layout as any); + } else { + pointStartLayoutSize = null; + } resetController(); const layoutControlType = resetControlType(e); @@ -359,11 +378,25 @@ export const MiddlewareLayoutSelector: BoardMiddleware< const layoutControlType = sharer.getSharedStorage(keyLayoutControlType); const data = sharer.getActiveStorage('data'); if (data && layoutActionType === 'resize' && layoutControlType) { + let modifyRecord: ModifyRecord<'modifyLayout'> | undefined = undefined; + if (pointStartLayoutSize) { + modifyRecord = { + type: 'modifyLayout', + time: Date.now(), + content: { + method: 'modifyLayout', + before: toFlattenLayout(pointStartLayoutSize as DataLayout), + after: toFlattenLayout(getElementSize(data.layout as any) as DataLayout) + } + }; + } eventHub.trigger(coreEventKeys.CHANGE, { type: 'dragLayout', - data + data, + modifyRecord }); } + pointStartLayoutSize = null; sharer.setSharedStorage(keyLayoutActionType, null); sharer.setSharedStorage(keyLayoutControlType, null); diff --git a/packages/core/src/middleware/layout-selector/util.ts b/packages/core/src/middleware/layout-selector/util.ts index 8a5ed7d..67ea0c3 100644 --- a/packages/core/src/middleware/layout-selector/util.ts +++ b/packages/core/src/middleware/layout-selector/util.ts @@ -1,4 +1,11 @@ -import type { ViewContext2D, LayoutSizeController, ViewRectVertexes, PointSize, ElementSize, MiddlewareLayoutSelectorStyle } from '@idraw/types'; +import type { + ViewContext2D, + LayoutSizeController, + ViewRectVertexes, + PointSize, + ElementSize, + MiddlewareLayoutSelectorStyle +} from '@idraw/types'; function drawControllerBox(ctx: ViewContext2D, boxVertexes: ViewRectVertexes, style: MiddlewareLayoutSelectorStyle) { const { activeColor } = style; @@ -10,7 +17,7 @@ function drawControllerBox(ctx: ViewContext2D, boxVertexes: ViewRectVertexes, st ctx.lineTo(boxVertexes[2].x, boxVertexes[2].y); ctx.lineTo(boxVertexes[3].x, boxVertexes[3].y); ctx.closePath(); - ctx.fill(); + ctx.fill('nonzero'); ctx.strokeStyle = activeColor; ctx.lineWidth = 2; @@ -52,9 +59,24 @@ export function drawLayoutController( const { topLeft, topRight, bottomLeft, bottomRight, topMiddle, rightMiddle, bottomMiddle, leftMiddle } = controller; drawControllerLine(ctx, { start: topLeft.center, end: topRight.center, centerVertexes: topMiddle.vertexes, style }); - drawControllerLine(ctx, { start: topRight.center, end: bottomRight.center, centerVertexes: rightMiddle.vertexes, style }); - drawControllerLine(ctx, { start: bottomRight.center, end: bottomLeft.center, centerVertexes: bottomMiddle.vertexes, style }); - drawControllerLine(ctx, { start: bottomLeft.center, end: topLeft.center, centerVertexes: leftMiddle.vertexes, style }); + drawControllerLine(ctx, { + start: topRight.center, + end: bottomRight.center, + centerVertexes: rightMiddle.vertexes, + style + }); + drawControllerLine(ctx, { + start: bottomRight.center, + end: bottomLeft.center, + centerVertexes: bottomMiddle.vertexes, + style + }); + drawControllerLine(ctx, { + start: bottomLeft.center, + end: topLeft.center, + centerVertexes: leftMiddle.vertexes, + style + }); drawControllerBox(ctx, topLeft.vertexes, style); drawControllerBox(ctx, topRight.vertexes, style); diff --git a/packages/core/src/middleware/pointer/index.ts b/packages/core/src/middleware/pointer/index.ts index 5e2ba77..64c3965 100644 --- a/packages/core/src/middleware/pointer/index.ts +++ b/packages/core/src/middleware/pointer/index.ts @@ -1,9 +1,9 @@ -import type { BoardMiddleware, CoreEventMap } from '@idraw/types'; +import type { Middleware, CoreEventMap } from '@idraw/types'; import type { DeepPointerSharedStorage } from './types'; import { keySelectedElementList } from '../selector'; import { coreEventKeys } from '../../config'; -export const MiddlewarePointer: BoardMiddleware = (opts) => { +export const MiddlewarePointer: Middleware = (opts) => { const { boardContent, eventHub, sharer } = opts; const canvas = boardContent.boardContext.canvas; const container = opts.container || document.body; @@ -15,34 +15,23 @@ export const MiddlewarePointer: BoardMiddleware = (opts, config) => { +export const MiddlewareRuler: Middleware = ( + opts, + config +) => { const { boardContent, viewer, eventHub, calculator } = opts; const { overlayContext, underlayContext } = boardContent; let innerConfig = { @@ -45,7 +56,8 @@ export const MiddlewareRuler: BoardMiddleware { - const { background, borderColor, scaleColor, textColor, gridColor, gridPrimaryColor, selectedAreaColor } = innerConfig; + const { background, borderColor, scaleColor, textColor, gridColor, gridPrimaryColor, selectedAreaColor } = + innerConfig; const style = { background, @@ -60,10 +72,10 @@ export const MiddlewareRuler: BoardMiddleware; calculator: ViewCalculator; style: MiddlewareRulerStyle } + opts: { + snapshot: BoardViewerFrameSnapshot; + calculator: ViewCalculator; + style: MiddlewareRulerStyle; + } ) { const { snapshot, calculator, style } = opts; const { sharedStore } = snapshot; @@ -285,7 +298,10 @@ export function drawScrollerSelectedArea( const selectedElementList = sharedStore[keySelectedElementList]; const actionType = sharedStore[keyActionType]; - if (['select', 'drag', 'drag-list', 'drag-list-end'].includes(actionType as string) && selectedElementList.length > 0) { + if ( + ['select', 'drag', 'drag-list', 'drag-list-end'].includes(actionType as string) && + selectedElementList.length > 0 + ) { const viewScaleInfo = getViewScaleInfoFromSnapshot(snapshot); const viewSizeInfo = getViewSizeInfoFromSnapshot(snapshot); const rangeRectInfoList: ViewRectInfo[] = []; @@ -325,7 +341,7 @@ export function drawScrollerSelectedArea( ctx.lineTo(xAreaStart, rulerSize); ctx.fillStyle = selectedAreaColor; ctx.closePath(); - ctx.fill(); + ctx.fill('nonzero'); ctx.beginPath(); ctx.moveTo(0, yAreaStart); @@ -334,6 +350,6 @@ export function drawScrollerSelectedArea( ctx.lineTo(0, yAreaEnd); ctx.fillStyle = selectedAreaColor; ctx.closePath(); - ctx.fill(); + ctx.fill('nonzero'); } } diff --git a/packages/core/src/middleware/scaler/index.ts b/packages/core/src/middleware/scaler/index.ts index f0dac37..a37319f 100644 --- a/packages/core/src/middleware/scaler/index.ts +++ b/packages/core/src/middleware/scaler/index.ts @@ -1,8 +1,8 @@ -import type { BoardMiddleware, CoreEventMap } from '@idraw/types'; +import type { Middleware, CoreEventMap } from '@idraw/types'; import { formatNumber } from '@idraw/util'; import { coreEventKeys } from '../../config'; -export const MiddlewareScaler: BoardMiddleware, CoreEventMap> = (opts) => { +export const MiddlewareScaler: Middleware, CoreEventMap> = (opts) => { const { viewer, sharer, eventHub } = opts; const maxScale = 50; const minScale = 0.05; diff --git a/packages/core/src/middleware/scroller/index.ts b/packages/core/src/middleware/scroller/index.ts index a4e32ce..1a2f19c 100644 --- a/packages/core/src/middleware/scroller/index.ts +++ b/packages/core/src/middleware/scroller/index.ts @@ -1,11 +1,29 @@ -import type { Point, BoardMiddleware, PointWatcherEvent, BoardWatherWheelEvent, MiddlewareScrollerConfig } from '@idraw/types'; +import type { + Point, + Middleware, + PointWatcherEvent, + BoardWatherWheelEvent, + MiddlewareScrollerConfig +} from '@idraw/types'; import { drawScroller, isPointInScrollThumb } from './util'; // import type { ScrollbarThumbType } from './util'; -import { keyXThumbRect, keyYThumbRect, keyPrevPoint, keyActivePoint, keyActiveThumbType, keyHoverXThumbRect, keyHoverYThumbRect, defaultStyle } from './config'; +import { + keyXThumbRect, + keyYThumbRect, + keyPrevPoint, + keyActivePoint, + keyActiveThumbType, + keyHoverXThumbRect, + keyHoverYThumbRect, + defaultStyle +} from './config'; import type { DeepScrollerSharedStorage } from './types'; import { coreEventKeys } from '../../config'; -export const MiddlewareScroller: BoardMiddleware = (opts, config) => { +export const MiddlewareScroller: Middleware = ( + opts, + config +) => { const { viewer, boardContent, sharer, eventHub } = opts; const { overlayContext } = boardContent; sharer.setSharedStorage(keyXThumbRect, null); // null | ElementSize @@ -136,7 +154,14 @@ export const MiddlewareScroller: BoardMiddleware[], opts?: Omit) { +export function drawElementListShadows( + ctx: ViewContext2D, + elements: Element[], + opts?: Omit +) { elements.forEach((elem) => { let { x, y, w, h } = elem; const { angle = 0 } = elem; @@ -164,12 +177,15 @@ export function drawElementListShadows(ctx: ViewContext2D, elements: Element & { uuid: string }> = []; let moveOriginalStartPoint: Point | null = null; let moveOriginalStartElementSize: ElementSize | null = null; let inBusyMode: 'resize' | 'drag' | 'drag-list' | 'area' | null = null; @@ -283,6 +286,8 @@ export const MiddlewareSelector: BoardMiddleware< eventHub.off(coreEventKeys.CLEAR_SELECT, selectClearCallback); eventHub.off(coreEventKeys.SELECT_IN_GROUP, selectInGroupCallback); eventHub.off(coreEventKeys.SNAP_TO_GRID, setSnapToSnapCallback); + clear(); + innerConfig = null as any; }, resetConfig(config) { @@ -428,11 +433,7 @@ export const MiddlewareSelector: BoardMiddleware< const target = getPointTarget(e.point, pointTargetBaseOptions()); const isLockedElement = target?.elements?.length === 1 && target.elements[0]?.operations?.locked === true; - // if (target?.elements?.length === 1 && target.elements[0]?.operations?.locked === true) { - // return; - // } else { updateHoverElement(null); - // } if (target?.elements?.length === 1) { moveOriginalStartElementSize = getElementSize(target?.elements[0]); @@ -442,6 +443,7 @@ export const MiddlewareSelector: BoardMiddleware< } else if (target.type === 'over-element' && target?.elements?.length === 1) { updateSelectedElementList([target.elements[0]], { triggerEvent: true }); sharer.setSharedStorage(keyActionType, 'drag'); + pointStartElementSizeList = [{ ...getElementSize(target?.elements[0]), uuid: target?.elements[0].uuid }]; } else if (target.type?.startsWith('resize-')) { sharer.setSharedStorage(keyResizeType, target.type as ResizeType); sharer.setSharedStorage(keyActionType, 'resize'); @@ -487,6 +489,7 @@ export const MiddlewareSelector: BoardMiddleware< } else if (target.type === 'over-element' && target?.elements?.length === 1) { updateSelectedElementList([target.elements[0]], { triggerEvent: true }); sharer.setSharedStorage(keyActionType, 'drag'); + pointStartElementSizeList = [{ ...getElementSize(target?.elements[0]), uuid: target?.elements[0].uuid }]; } else if (target.type?.startsWith('resize-')) { sharer.setSharedStorage(keyResizeType, target.type as ResizeType); sharer.setSharedStorage(keyActionType, 'resize'); @@ -562,7 +565,7 @@ export const MiddlewareSelector: BoardMiddleware< elems[0].y = calculator.toGridNum(moveOriginalStartElementSize.y + totalMoveY); updateSelectedElementList([elems[0]]); calculator.modifyVirtualFlatItemMap(data, { - modifyOptions: { + modifyInfo: { type: 'updateElement', content: { element: elems[0], @@ -586,7 +589,7 @@ export const MiddlewareSelector: BoardMiddleware< elem.y = calculator.toGridNum(elem.y + moveY); calculator.modifyVirtualFlatItemMap(data, { - modifyOptions: { + modifyInfo: { type: 'updateElement', content: { element: elem, @@ -675,7 +678,7 @@ export const MiddlewareSelector: BoardMiddleware< updateSelectedElementList([elems[0]]); calculator.modifyVirtualFlatItemMap(data, { - modifyOptions: { + modifyInfo: { type: 'updateElement', content: { element: elems[0], @@ -775,7 +778,37 @@ export const MiddlewareSelector: BoardMiddleware< type = 'resizeElement'; } if (hasChangedData) { - eventHub.trigger(coreEventKeys.CHANGE, { data, type, selectedElements, hoverElement }); + const startSize = pointStartElementSizeList[0] as ElementSize & { uuid: string }; + let modifyRecord: ModifyRecord | undefined = undefined; + if (selectedElements.length === 1) { + modifyRecord = { + type: 'dragElement', + time: 0, + content: { + method: 'modifyElement', + uuid: startSize.uuid, + before: toFlattenElement(startSize), + after: toFlattenElement(getElementSize(selectedElements[0])) + } + }; + } else if (selectedElements.length > 1) { + modifyRecord = { + type: 'dragElements', + time: 0, + content: { + method: 'modifyElements', + before: pointStartElementSizeList.map((item) => ({ + ...toFlattenElement(item), + uuid: item.uuid + })), + after: selectedElements.map((item) => ({ + ...toFlattenElement(getElementSize(item)), + uuid: item.uuid + })) + } + }; + } + eventHub.trigger(coreEventKeys.CHANGE, { data, type, selectedElements, hoverElement, modifyRecord }); hasChangedData = false; } } diff --git a/packages/core/src/middleware/selector/types.ts b/packages/core/src/middleware/selector/types.ts index 20b5f6b..5785f5f 100644 --- a/packages/core/src/middleware/selector/types.ts +++ b/packages/core/src/middleware/selector/types.ts @@ -10,7 +10,7 @@ import { ViewSizeInfo, ViewCalculator, PointWatcherEvent, - BoardMiddleware, + Middleware, ViewRectVertexes, ElementSizeController, ElementPosition @@ -54,7 +54,7 @@ export { ViewSizeInfo, ViewCalculator, PointWatcherEvent, - BoardMiddleware + Middleware }; export type ControllerStyle = ElementSize & { diff --git a/packages/core/src/middleware/text-editor/index.ts b/packages/core/src/middleware/text-editor/index.ts index d30a254..e178ccb 100644 --- a/packages/core/src/middleware/text-editor/index.ts +++ b/packages/core/src/middleware/text-editor/index.ts @@ -1,5 +1,5 @@ -import type { BoardMiddleware, CoreEventMap, Element, ElementSize, ViewScaleInfo, ElementPosition } from '@idraw/types'; -import { limitAngle, getDefaultElementDetailConfig, enhanceFontFamliy } from '@idraw/util'; +import type { Middleware, CoreEventMap, Element, ElementSize, ViewScaleInfo, ElementPosition } from '@idraw/types'; +import { limitAngle, getDefaultElementDetailConfig, enhanceFontFamliy, updateElementInList } from '@idraw/util'; import { coreEventKeys } from '../../config'; type TextEditEvent = { @@ -19,21 +19,23 @@ type TextChangeEvent = { position: ElementPosition; }; -type ExtendEventMap = Record & Record; +type ExtendEventMap = Record & + Record; const defaultElementDetail = getDefaultElementDetailConfig(); -export const MiddlewareTextEditor: BoardMiddleware = (opts) => { - const { eventHub, boardContent, viewer, sharer } = opts; +export const MiddlewareTextEditor: Middleware = (opts) => { + const { eventHub, boardContent, viewer, sharer, calculator } = opts; const canvas = boardContent.boardContext.canvas; - // const textarea = document.createElement('textarea'); - const textarea = document.createElement('div'); - textarea.setAttribute('contenteditable', 'true'); - const canvasWrapper = document.createElement('div'); const container = opts.container || document.body; - const mask = document.createElement('div'); + let textarea = document.createElement('div'); + textarea.setAttribute('contenteditable', 'true'); + let canvasWrapper = document.createElement('div'); + let mask = document.createElement('div'); let activeElem: Element<'text'> | null = null; let activePosition: ElementPosition = []; + let originText: string = ''; + const id = `idraw-middleware-text-editor-${Math.random().toString(26).substring(2)}`; mask.setAttribute('id', id); canvasWrapper.appendChild(textarea); @@ -53,12 +55,14 @@ export const MiddlewareTextEditor: BoardMiddleware { + const maskClickEvent = () => { hideTextArea(); - }); - textarea.addEventListener('click', (e) => { + }; + + const textareaClickEvent = (e: MouseEvent) => { e.stopPropagation(); - }); - textarea.addEventListener('input', () => { + }; + + const textareaInputEvent = () => { if (activeElem && activePosition) { // activeElem.detail.text = (e.target as any).value || ''; activeElem.detail.text = textarea.innerText || ''; @@ -220,11 +226,14 @@ export const MiddlewareTextEditor: BoardMiddleware { + }; + + const textareaBlurEvent = () => { if (activeElem && activePosition) { + activeElem.detail.text = textarea.innerText || ''; eventHub.trigger(coreEventKeys.TEXT_CHANGE, { element: { uuid: activeElem.uuid, @@ -234,23 +243,75 @@ export const MiddlewareTextEditor: BoardMiddleware { + }; + + const textareaKeyDownEvent = (e: KeyboardEvent) => { e.stopPropagation(); - }); - textarea.addEventListener('keypress', (e) => { + }; + + const textareaKeyPressEvent = (e: KeyboardEvent) => { e.stopPropagation(); - }); - textarea.addEventListener('keyup', (e) => { + }; + + const textareaKeyUpEvent = (e: KeyboardEvent) => { e.stopPropagation(); - }); - textarea.addEventListener('wheel', (e) => { + }; + + const textareaWheelEvent = (e: WheelEvent) => { e.stopPropagation(); e.preventDefault(); - }); + }; + + mask.addEventListener('click', maskClickEvent); + textarea.addEventListener('click', textareaClickEvent); + textarea.addEventListener('input', textareaInputEvent); + textarea.addEventListener('blur', textareaBlurEvent); + textarea.addEventListener('keydown', textareaKeyDownEvent); + textarea.addEventListener('keypress', textareaKeyPressEvent); + textarea.addEventListener('keyup', textareaKeyUpEvent); + textarea.addEventListener('wheel', textareaWheelEvent); const textEditCallback = (e: TextEditEvent) => { if (e?.position && e?.element && e?.element?.type === 'text') { @@ -267,6 +328,27 @@ export const MiddlewareTextEditor: BoardMiddleware> & Pick; + beforeElement: Element; +}): ModifyRecord<'modifyElement'> { + const { modifiedElement, beforeElement } = opts; + const { uuid, ...restElement } = modifiedElement; + const after = toFlattenElement(restElement); + let before: FlattenElement = {}; + Object.keys(after).forEach((key: string) => { + let val = get(beforeElement, key); + if (val === undefined && /(borderRadius|borderWidth)\[[0-9]{1,}\]$/.test(key)) { + key = key.replace(/\[[0-9]{1,}\]$/, ''); + val = get(beforeElement, key); + } + before[key] = val; + }); + before = toFlattenElement(before); + + const record: ModifyRecord<'modifyElement'> = { + type: 'modifyElement', + time: Date.now(), + content: { + method: 'modifyElement', + uuid, + before, + after + } + }; + + return record; +} diff --git a/packages/idraw/__tests__/__snapshots__/index.test.ts.snap b/packages/idraw/__tests__/__snapshots__/index.test.ts.snap deleted file mode 100644 index 0743723..0000000 --- a/packages/idraw/__tests__/__snapshots__/index.test.ts.snap +++ /dev/null @@ -1,1269 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`idraw context 1`] = ` -[ - { - "props": { - "height": 1600, - "width": 2400, - "x": 0, - "y": 0, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "clearRect", - }, - { - "props": { - "path": [ - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "beginPath", - }, - { - "props": { - "x": 120, - "y": 20, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "moveTo", - }, - { - "props": { - "cpx1": 860, - "cpx2": 860, - "cpy1": 20, - "cpy2": 460, - "radius": 100, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 860, - "cpx2": 20, - "cpy1": 460, - "cpy2": 460, - "radius": 100, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 20, - "cpx2": 20, - "cpy1": 460, - "cpy2": 20, - "radius": 100, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 20, - "cpx2": 860, - "cpy1": 20, - "cpy2": 20, - "radius": 100, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "closePath", - }, - ], - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "stroke", - }, - { - "props": { - "fillRule": "nonzero", - "path": [ - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "beginPath", - }, - { - "props": { - "x": 120, - "y": 40, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "moveTo", - }, - { - "props": { - "cpx1": 840, - "cpx2": 840, - "cpy1": 40, - "cpy2": 440, - "radius": 80, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 840, - "cpx2": 40, - "cpy1": 440, - "cpy2": 440, - "radius": 80, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 40, - "cpx2": 40, - "cpy1": 440, - "cpy2": 40, - "radius": 80, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 40, - "cpx2": 840, - "cpy1": 40, - "cpy2": 40, - "radius": 80, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "closePath", - }, - ], - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "fill", - }, - { - "props": { - "path": [ - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "beginPath", - }, - { - "props": { - "x": 560, - "y": 316, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "moveTo", - }, - { - "props": { - "cpx1": 1124, - "cpx2": 1124, - "cpy1": 316, - "cpy2": 804, - "radius": 244, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 1124, - "cpx2": 316, - "cpy1": 804, - "cpy2": 804, - "radius": 244, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 316, - "cpx2": 316, - "cpy1": 804, - "cpy2": 316, - "radius": 244, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 316, - "cpx2": 1124, - "cpy1": 316, - "cpy2": 316, - "radius": 244, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "closePath", - }, - ], - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "stroke", - }, - { - "props": { - "fillRule": "nonzero", - "path": [ - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "beginPath", - }, - { - "props": { - "x": 560, - "y": 320, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "moveTo", - }, - { - "props": { - "cpx1": 1120, - "cpx2": 1120, - "cpy1": 320, - "cpy2": 800, - "radius": 240, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 1120, - "cpx2": 320, - "cpy1": 800, - "cpy2": 800, - "radius": 240, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 320, - "cpx2": 320, - "cpy1": 800, - "cpy2": 320, - "radius": 240, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 320, - "cpx2": 1120, - "cpy1": 320, - "cpy2": 320, - "radius": 240, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "closePath", - }, - ], - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "fill", - }, - { - "props": { - "maxWidth": null, - "text": "Hello Text", - "x": 715, - "y": 520, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "fillText", - }, -] -`; - -exports[`idraw context 2`] = ` -[ - { - "props": { - "height": 1600, - "width": 2400, - "x": 0, - "y": 0, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "clearRect", - }, - { - "props": { - "height": 1600, - "width": 2400, - "x": 0, - "y": 0, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "clearRect", - }, - { - "props": { - "dHeight": 1600, - "dWidth": 2400, - "dx": 0, - "dy": 0, - "img": , - "sHeight": 1600, - "sWidth": 2400, - "sx": 0, - "sy": 0, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "drawImage", - }, - { - "props": { - "dHeight": 1600, - "dWidth": 2400, - "dx": 0, - "dy": 0, - "img": , - "sHeight": 1600, - "sWidth": 2400, - "sx": 0, - "sy": 0, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "drawImage", - }, -] -`; - -exports[`idraw undo/redo 1`] = ` -[ - { - "props": { - "height": 1600, - "width": 2400, - "x": 0, - "y": 0, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "clearRect", - }, - { - "props": { - "path": [ - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "beginPath", - }, - { - "props": { - "x": 120, - "y": 20, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "moveTo", - }, - { - "props": { - "cpx1": 860, - "cpx2": 860, - "cpy1": 20, - "cpy2": 460, - "radius": 100, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 860, - "cpx2": 20, - "cpy1": 460, - "cpy2": 460, - "radius": 100, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 20, - "cpx2": 20, - "cpy1": 460, - "cpy2": 20, - "radius": 100, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 20, - "cpx2": 860, - "cpy1": 20, - "cpy2": 20, - "radius": 100, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "closePath", - }, - ], - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "stroke", - }, - { - "props": { - "fillRule": "nonzero", - "path": [ - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "beginPath", - }, - { - "props": { - "x": 120, - "y": 40, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "moveTo", - }, - { - "props": { - "cpx1": 840, - "cpx2": 840, - "cpy1": 40, - "cpy2": 440, - "radius": 80, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 840, - "cpx2": 40, - "cpy1": 440, - "cpy2": 440, - "radius": 80, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 40, - "cpx2": 40, - "cpy1": 440, - "cpy2": 40, - "radius": 80, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 40, - "cpx2": 840, - "cpy1": 40, - "cpy2": 40, - "radius": 80, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "closePath", - }, - ], - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "fill", - }, - { - "props": { - "path": [ - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "beginPath", - }, - { - "props": { - "x": 560, - "y": 316, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "moveTo", - }, - { - "props": { - "cpx1": 1124, - "cpx2": 1124, - "cpy1": 316, - "cpy2": 804, - "radius": 244, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 1124, - "cpx2": 316, - "cpy1": 804, - "cpy2": 804, - "radius": 244, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 316, - "cpx2": 316, - "cpy1": 804, - "cpy2": 316, - "radius": 244, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 316, - "cpx2": 1124, - "cpy1": 316, - "cpy2": 316, - "radius": 244, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "closePath", - }, - ], - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "stroke", - }, - { - "props": { - "fillRule": "nonzero", - "path": [ - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "beginPath", - }, - { - "props": { - "x": 560, - "y": 320, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "moveTo", - }, - { - "props": { - "cpx1": 1120, - "cpx2": 1120, - "cpy1": 320, - "cpy2": 800, - "radius": 240, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 1120, - "cpx2": 320, - "cpy1": 800, - "cpy2": 800, - "radius": 240, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 320, - "cpx2": 320, - "cpy1": 800, - "cpy2": 320, - "radius": 240, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": { - "cpx1": 320, - "cpx2": 1120, - "cpy1": 320, - "cpy2": 320, - "radius": 240, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "arcTo", - }, - { - "props": {}, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "closePath", - }, - ], - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "fill", - }, - { - "props": { - "maxWidth": null, - "text": "Hello Text", - "x": 715, - "y": 520, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "fillText", - }, -] -`; - -exports[`idraw undo/redo 2`] = ` -[ - { - "props": { - "height": 1600, - "width": 2400, - "x": 0, - "y": 0, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "clearRect", - }, - { - "props": { - "height": 1600, - "width": 2400, - "x": 0, - "y": 0, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "clearRect", - }, - { - "props": { - "dHeight": 1600, - "dWidth": 2400, - "dx": 0, - "dy": 0, - "img": , - "sHeight": 1600, - "sWidth": 2400, - "sx": 0, - "sy": 0, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "drawImage", - }, - { - "props": { - "dHeight": 1600, - "dWidth": 2400, - "dx": 0, - "dy": 0, - "img": , - "sHeight": 1600, - "sWidth": 2400, - "sx": 0, - "sy": 0, - }, - "transform": [ - 1, - 0, - 0, - 1, - 0, - 0, - ], - "type": "drawImage", - }, -] -`; diff --git a/packages/idraw/__tests__/history-addElement.test.ts b/packages/idraw/__tests__/history-addElement.test.ts new file mode 100644 index 0000000..213b8c8 --- /dev/null +++ b/packages/idraw/__tests__/history-addElement.test.ts @@ -0,0 +1,171 @@ +import { iDraw, useHistory, deepClone, createElement, findElementFromListByPosition } from 'idraw'; + +const createData = () => ({ + elements: [ + createElement('rect', { + uuid: 'test-001', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + background: '#DDDDDD' + } + }), + createElement('group', { + uuid: 'test-005', + detail: { + children: [ + createElement('image', { uuid: 'test-004', detail: { src: 'https://example.com/001.png' } }), + createElement('circle', { uuid: 'test-007' }), + createElement('text', { + uuid: 'test-008', + detail: { + text: 'Text in Group' + } + }), + createElement('image', { uuid: 'test-009', detail: { src: 'https://example.com/002.png' } }) + ] + } + }) + ] +}); + +describe('idraw: useHistory ', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01')); + }); + + test('updateElement', () => { + const data = createData(); + const div = document.createElement('div') as HTMLDivElement; + + const idraw = new iDraw(div, { + height: 200, + width: 200 + }); + const { MiddlewareHistory, history } = useHistory({ instance: idraw }); + const { undo, redo, __getDoRecords, __getUndoRecords } = history; + idraw.use(MiddlewareHistory); + idraw.setData(data); + + // modify 1: do + const newElement1 = idraw.createElement('rect', { + x: 22, + y: 33, + h: 300, + w: 400, + name: 'new element 001', + detail: { + background: '#666666' + } + }); + const position = [1, 2]; + idraw.addElement(newElement1, { + position + }); + const record1 = { + type: 'addElement', + time: new Date().getTime(), + content: { + method: 'addElement', + uuid: newElement1.uuid, + position: [...position], + element: deepClone(newElement1) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(newElement1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 2: do + const newElement2 = idraw.createElement('text', { + x: 22, + y: 33, + h: 300, + w: 400, + name: 'new element 002', + detail: { + text: 'Hello Element' + } + }); + idraw.addElement(newElement2, { position }); + const record2 = { + type: 'addElement', + time: new Date().getTime(), + content: { + method: 'addElement', + uuid: newElement2.uuid, + position: [...position], + element: deepClone(newElement2) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(newElement2); + expect(__getDoRecords()).toStrictEqual([record1, record2]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 3: undo + undo(); + const record3 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'deleteElement', + uuid: record2.content.uuid, + position: deepClone(record2.content.position), + element: deepClone(record2.content.element) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(newElement1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 4: undo + undo(); + const record4 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'deleteElement', + uuid: record1.content.uuid, + position: deepClone(record1.content.position), + element: deepClone(record1.content.element) + } + }; + expect(idraw.getData()).toStrictEqual(createData()); + expect(__getDoRecords()).toStrictEqual([]); + expect(__getUndoRecords()).toStrictEqual([record3, record4]); + + // modify 5: redo + redo(); + const record5 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'addElement', + uuid: record4.content.uuid, + position: record4.content.position, + element: deepClone(record4.content.element) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(newElement1); + expect(__getDoRecords()).toStrictEqual([record5]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 5: redo + redo(); + const record6 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'addElement', + uuid: record3.content.uuid, + position: record3.content.position, + element: deepClone(record3.content.element) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(newElement2); + expect(__getDoRecords()).toStrictEqual([record5, record6]); + expect(__getUndoRecords()).toStrictEqual([]); + }); +}); diff --git a/packages/idraw/__tests__/history-deleteElement.test.ts b/packages/idraw/__tests__/history-deleteElement.test.ts new file mode 100644 index 0000000..498fcd0 --- /dev/null +++ b/packages/idraw/__tests__/history-deleteElement.test.ts @@ -0,0 +1,158 @@ +import { iDraw, useHistory, deepClone, createElement, findElementFromListByPosition } from 'idraw'; +import type { Element } from 'idraw'; + +const createData = () => ({ + elements: [ + createElement('rect', { + uuid: 'test-000', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + background: '#DDDDDD' + } + }), + createElement('group', { + uuid: 'test-001', + detail: { + children: [ + createElement('image', { uuid: 'test-001-000', detail: { src: 'https://example.com/001.png' } }), + createElement('circle', { uuid: 'test-001-001' }), + createElement('text', { + uuid: 'test-001-002', + detail: { + text: 'Text in Group' + } + }), + createElement('image', { uuid: 'test-001-003', detail: { src: 'https://example.com/002.png' } }), + createElement('rect', { uuid: 'test-001-004' }), + createElement('circle', { uuid: 'test-001-005' }) + ] + } + }) + ] +}); + +describe('idraw: useHistory ', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01')); + }); + + test('updateElement', () => { + const data = createData(); + const div = document.createElement('div') as HTMLDivElement; + + const idraw = new iDraw(div, { + height: 200, + width: 200 + }); + const { MiddlewareHistory, history } = useHistory({ instance: idraw }); + const { undo, redo, __getDoRecords, __getUndoRecords } = history; + idraw.use(MiddlewareHistory); + idraw.setData(data); + + const position = [1, 2]; + const nextPosition = [1, 3]; + + // modify 1: do + const deletedElem1 = deepClone(findElementFromListByPosition(position, data.elements) as Element); + const expectedElem1 = deepClone(findElementFromListByPosition(nextPosition, data.elements) as Element); + idraw.deleteElement(deletedElem1?.uuid); + const record1 = { + type: 'deleteElement', + time: new Date().getTime(), + content: { + method: 'deleteElement', + uuid: deletedElem1.uuid, + position: [...position], + element: deepClone(deletedElem1) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(expectedElem1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 2: do + const deletedElem2 = deepClone(findElementFromListByPosition(position, data.elements) as Element); + const expectedElem2 = deepClone(findElementFromListByPosition(nextPosition, data.elements) as Element); + idraw.deleteElement(deletedElem2?.uuid); + const record2 = { + type: 'deleteElement', + time: new Date().getTime(), + content: { + method: 'deleteElement', + uuid: deletedElem2.uuid, + position: [...position], + element: deepClone(deletedElem2) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(expectedElem2); + expect(__getDoRecords()).toStrictEqual([record1, record2]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 3: undo + undo(); + const record3 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'addElement', + uuid: record2.content.uuid, + position: deepClone(record2.content.position), + element: deepClone(record2.content.element) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(deletedElem2); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 4: undo + undo(); + const record4 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'addElement', + uuid: record1.content.uuid, + position: deepClone(record1.content.position), + element: deepClone(record1.content.element) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(deletedElem1); + expect(__getDoRecords()).toStrictEqual([]); + expect(__getUndoRecords()).toStrictEqual([record3, record4]); + + // modify 5: redo + redo(); + const record5 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'deleteElement', + uuid: record4.content.uuid, + position: record4.content.position, + element: deepClone(record4.content.element) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(expectedElem1); + expect(__getDoRecords()).toStrictEqual([record5]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 5: redo + redo(); + const record6 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'deleteElement', + uuid: record3.content.uuid, + position: record3.content.position, + element: deepClone(record3.content.element) + } + }; + expect(findElementFromListByPosition(position, idraw.getData()?.elements || [])).toStrictEqual(expectedElem2); + expect(__getDoRecords()).toStrictEqual([record5, record6]); + expect(__getUndoRecords()).toStrictEqual([]); + }); +}); diff --git a/packages/idraw/__tests__/history-modifyElement.test.ts b/packages/idraw/__tests__/history-modifyElement.test.ts new file mode 100644 index 0000000..0188da2 --- /dev/null +++ b/packages/idraw/__tests__/history-modifyElement.test.ts @@ -0,0 +1,204 @@ +import { iDraw, useHistory, deepClone, createElement, set, get, toFlattenElement } from 'idraw'; +import type { RecursivePartial, Element } from 'idraw'; + +const createData = () => ({ + elements: [ + createElement('rect', { + uuid: 'test-001', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + background: '#DDDDDD' + } + }), + createElement('circle', { uuid: 'test-002' }), + createElement('text', { + uuid: 'test-003', + detail: { + text: 'Hello World' + } + }), + createElement('image', { uuid: 'test-004', detail: { src: 'https://example.com/001.png' } }), + createElement('group', { + uuid: 'test-005', + detail: { + children: [ + createElement('rect', { uuid: 'test-006' }), + createElement('circle', { uuid: 'test-007' }), + createElement('text', { + uuid: 'test-008', + detail: { + text: 'Text in Group' + } + }), + createElement('image', { uuid: 'test-009', detail: { src: 'https://example.com/002.png' } }) + ] + } + }) + ] +}); + +describe('idraw: useHistory ', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01')); + }); + + test('modifyElement', () => { + const data = createData(); + const div = document.createElement('div') as HTMLDivElement; + + const idraw = new iDraw(div, { + height: 200, + width: 200 + }); + const { MiddlewareHistory, history } = useHistory({ instance: idraw }); + const { undo, redo, __getDoRecords, __getUndoRecords } = history; + idraw.use(MiddlewareHistory); + idraw.setData(data); + const targetElement = deepClone(data.elements[0]); + + // modify 1: do + const modifiedInfo1 = { + x: targetElement.x + 1, + y: targetElement.y + 2, + detail: { + background: '#123456', + borderRadius: 3 + } + }; + idraw.modifyElement({ + uuid: targetElement.uuid, + ...deepClone(modifiedInfo1) + }); + const expectedData1 = createData(); + const flattenModifiedInfo1 = toFlattenElement(modifiedInfo1); + const beforeInfo1: Record = {}; + const afterInfo1 = { ...flattenModifiedInfo1 }; + Object.keys(flattenModifiedInfo1).forEach((key) => { + beforeInfo1[key] = get(expectedData1.elements[0], key); + set(expectedData1.elements[0], key, flattenModifiedInfo1[key]); + }); + const record1 = { + type: 'modifyElement', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: beforeInfo1, + after: afterInfo1 + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 2: do + const modifiedInfo2 = { + x: modifiedInfo1.x + 3, + y: modifiedInfo1.y + 4, + detail: { + borderRadius: [2, 4, 6, 8] + } + } as unknown as RecursivePartial>; + + idraw.modifyElement({ + uuid: targetElement.uuid, + ...deepClone(modifiedInfo2) + } as RecursivePartial> & Pick); + + const expectedData2 = deepClone(expectedData1); + const flattenModifiedInfo2 = toFlattenElement(modifiedInfo2); + const beforeInfo2: Record = {}; + const afterInfo2 = { ...flattenModifiedInfo2 }; + + Object.keys(flattenModifiedInfo2).forEach((key) => { + let beforeVal = get(expectedData1.elements[0], key); + let beforeKey = key; + if (beforeVal === undefined && /(borderRadius|borderWidth)\[[0-9]{1,}\]$/.test(beforeKey)) { + beforeKey = beforeKey.replace(/\[[0-9]{1,}\]$/, ''); + beforeVal = get(expectedData1.elements[0], beforeKey); + } + beforeInfo2[beforeKey] = beforeVal; + set(expectedData2.elements[0], key, flattenModifiedInfo2[key]); + }); + const record2 = { + type: 'modifyElement', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: beforeInfo2, + after: afterInfo2 + } + }; + expect(idraw.getData()).toStrictEqual(expectedData2); + expect(__getDoRecords()).toStrictEqual([record1, record2]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 3: undo + undo(); + const record3 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: deepClone(record2.content.after), + after: deepClone(record2.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 4: undo + undo(); + const record4 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: deepClone(record1.content.after), + after: deepClone(record1.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(createData()); + expect(__getDoRecords()).toStrictEqual([]); + expect(__getUndoRecords()).toStrictEqual([record3, record4]); + + // modify 5: redo + redo(); + const record5 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: deepClone(record4.content.after), + after: deepClone(record4.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record5]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 5: redo + redo(); + const record6 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: deepClone(record3.content.after), + after: deepClone(record3.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData2); + expect(__getDoRecords()).toStrictEqual([record5, record6]); + expect(__getUndoRecords()).toStrictEqual([]); + }); +}); diff --git a/packages/idraw/__tests__/history-modifyGlobal.test.ts b/packages/idraw/__tests__/history-modifyGlobal.test.ts new file mode 100644 index 0000000..e7dfedc --- /dev/null +++ b/packages/idraw/__tests__/history-modifyGlobal.test.ts @@ -0,0 +1,180 @@ +import { iDraw, useHistory, deepClone, createElement, set, get, toFlattenGlobal } from 'idraw'; +import type { Data, DataGlobal, RecursivePartial } from 'idraw'; + +const createData = () => + ({ + elements: [ + createElement('rect', { + uuid: 'test-001', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + background: '#DDDDDD' + } + }), + createElement('circle', { uuid: 'test-002' }), + createElement('text', { + uuid: 'test-003', + detail: { + text: 'Hello World' + } + }), + createElement('image', { uuid: 'test-004', detail: { src: 'https://example.com/001.png' } }), + createElement('group', { + uuid: 'test-005', + detail: { + children: [ + createElement('rect', { uuid: 'test-006' }), + createElement('circle', { uuid: 'test-007' }), + createElement('text', { + uuid: 'test-008', + detail: { + text: 'Text in Group' + } + }), + createElement('image', { uuid: 'test-009', detail: { src: 'https://example.com/002.png' } }) + ] + } + }) + ] + } as Data); + +describe('idraw: useHistory ', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01')); + }); + + test('modifyGlobal', () => { + const data = createData(); + const div = document.createElement('div') as HTMLDivElement; + + const idraw = new iDraw(div, { + height: 200, + width: 200 + }); + const { MiddlewareHistory, history } = useHistory({ instance: idraw }); + const { undo, redo, __getDoRecords, __getUndoRecords } = history; + idraw.use(MiddlewareHistory); + idraw.setData(data); + + // modify 1: do + const modifiedInfo1 = { + background: '#123456' + }; + idraw.modifyGlobal({ + ...deepClone(modifiedInfo1) + }); + const expectedData1 = createData(); + const flattenModifiedInfo1 = toFlattenGlobal(modifiedInfo1); + const beforeInfo1: Record | null = null; + const afterInfo1 = { ...flattenModifiedInfo1 }; + + Object.keys(flattenModifiedInfo1).forEach((k) => { + const key = `global.${k}`; + set(expectedData1, key, flattenModifiedInfo1[k]); + }); + const record1 = { + type: 'modifyGlobal', + time: new Date().getTime(), + content: { + method: 'modifyGlobal', + before: beforeInfo1, + after: afterInfo1 + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 2: do + const modifiedInfo2 = { + background: '#AAAAAA' + } as unknown as RecursivePartial; + + idraw.modifyGlobal({ ...modifiedInfo2 }); + + const expectedData2 = deepClone(expectedData1); + const flattenModifiedInfo2 = toFlattenGlobal(modifiedInfo2); + const beforeInfo2: Record = {}; + const afterInfo2 = { ...flattenModifiedInfo2 }; + + Object.keys(flattenModifiedInfo2).forEach((key) => { + beforeInfo2[key] = get(expectedData1.global, key); + set(expectedData2.global, key, flattenModifiedInfo2[key]); + }); + const record2 = { + type: 'modifyGlobal', + time: new Date().getTime(), + content: { + method: 'modifyGlobal', + before: beforeInfo2, + after: afterInfo2 + } + }; + expect(idraw.getData()).toStrictEqual(expectedData2); + expect(__getDoRecords()).toStrictEqual([record1, record2]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 3: undo + undo(); + const record3 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'modifyGlobal', + before: deepClone(record2.content.after), + after: deepClone(record2.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 4: undo + undo(); + const record4 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'modifyGlobal', + before: deepClone(record1.content.after), + after: deepClone(record1.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(createData()); + expect(__getDoRecords()).toStrictEqual([]); + expect(__getUndoRecords()).toStrictEqual([record3, record4]); + + // modify 5: redo + redo(); + const record5 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'modifyGlobal', + before: deepClone(record4.content.after), + after: deepClone(record4.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record5]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 5: redo + redo(); + const record6 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'modifyGlobal', + before: deepClone(record3.content.after), + after: deepClone(record3.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData2); + expect(__getDoRecords()).toStrictEqual([record5, record6]); + expect(__getUndoRecords()).toStrictEqual([]); + }); +}); diff --git a/packages/idraw/__tests__/history-modifyLayout.test.ts b/packages/idraw/__tests__/history-modifyLayout.test.ts new file mode 100644 index 0000000..4cd88ad --- /dev/null +++ b/packages/idraw/__tests__/history-modifyLayout.test.ts @@ -0,0 +1,197 @@ +import { iDraw, useHistory, deepClone, createElement, set, get, toFlattenLayout } from 'idraw'; +import type { Data, DataLayout, RecursivePartial } from 'idraw'; + +const createData = () => + ({ + elements: [ + createElement('rect', { + uuid: 'test-001', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + background: '#DDDDDD' + } + }), + createElement('circle', { uuid: 'test-002' }), + createElement('text', { + uuid: 'test-003', + detail: { + text: 'Hello World' + } + }), + createElement('image', { uuid: 'test-004', detail: { src: 'https://example.com/001.png' } }), + createElement('group', { + uuid: 'test-005', + detail: { + children: [ + createElement('rect', { uuid: 'test-006' }), + createElement('circle', { uuid: 'test-007' }), + createElement('text', { + uuid: 'test-008', + detail: { + text: 'Text in Group' + } + }), + createElement('image', { uuid: 'test-009', detail: { src: 'https://example.com/002.png' } }) + ] + } + }) + ] + } as Data); + +describe('idraw: useHistory ', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01')); + }); + + test('modifyLayout', () => { + const data = createData(); + const div = document.createElement('div') as HTMLDivElement; + + const idraw = new iDraw(div, { + height: 200, + width: 200 + }); + const { MiddlewareHistory, history } = useHistory({ instance: idraw }); + const { undo, redo, __getDoRecords, __getUndoRecords } = history; + idraw.use(MiddlewareHistory); + idraw.setData(data); + + // modify 1: do + const modifiedInfo1 = { + x: 1, + y: 2, + w: 100, + h: 200, + detail: { + background: '#123456', + borderRadius: 3 + } + }; + idraw.modifyLayout({ + ...deepClone(modifiedInfo1) + }); + const expectedData1 = createData(); + const flattenModifiedInfo1 = toFlattenLayout(modifiedInfo1); + const beforeInfo1: Record | null = null; + const afterInfo1 = { ...flattenModifiedInfo1 }; + + Object.keys(flattenModifiedInfo1).forEach((k) => { + const key = `layout.${k}`; + set(expectedData1, key, flattenModifiedInfo1[k]); + }); + const record1 = { + type: 'modifyLayout', + time: new Date().getTime(), + content: { + method: 'modifyLayout', + before: beforeInfo1, + after: afterInfo1 + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 2: do + const modifiedInfo2 = { + x: modifiedInfo1.x + 3, + y: modifiedInfo1.y + 4, + detail: { + borderRadius: [2, 4, 6, 8] + } + } as unknown as RecursivePartial; + + idraw.modifyLayout({ ...modifiedInfo2 }); + + const expectedData2 = deepClone(expectedData1); + const flattenModifiedInfo2 = toFlattenLayout(modifiedInfo2); + const beforeInfo2: Record = {}; + const afterInfo2 = { ...flattenModifiedInfo2 }; + + Object.keys(flattenModifiedInfo2).forEach((key) => { + let beforeVal = get(expectedData1.layout, key); + let beforeKey = key; + if (beforeVal === undefined && /(borderRadius|borderWidth)\[[0-9]{1,}\]$/.test(beforeKey)) { + beforeKey = beforeKey.replace(/\[[0-9]{1,}\]$/, ''); + beforeVal = get(expectedData1.layout, beforeKey); + } + beforeInfo2[beforeKey] = beforeVal; + set(expectedData2.layout, key, flattenModifiedInfo2[key]); + }); + const record2 = { + type: 'modifyLayout', + time: new Date().getTime(), + content: { + method: 'modifyLayout', + before: beforeInfo2, + after: afterInfo2 + } + }; + expect(idraw.getData()).toStrictEqual(expectedData2); + expect(__getDoRecords()).toStrictEqual([record1, record2]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 3: undo + undo(); + const record3 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'modifyLayout', + before: deepClone(record2.content.after), + after: deepClone(record2.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 4: undo + undo(); + const record4 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'modifyLayout', + before: deepClone(record1.content.after), + after: deepClone(record1.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(createData()); + expect(__getDoRecords()).toStrictEqual([]); + expect(__getUndoRecords()).toStrictEqual([record3, record4]); + + // modify 5: redo + redo(); + const record5 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'modifyLayout', + before: deepClone(record4.content.after), + after: deepClone(record4.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record5]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 5: redo + redo(); + const record6 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'modifyLayout', + before: deepClone(record3.content.after), + after: deepClone(record3.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData2); + expect(__getDoRecords()).toStrictEqual([record5, record6]); + expect(__getUndoRecords()).toStrictEqual([]); + }); +}); diff --git a/packages/idraw/__tests__/history-moveElement.test.ts b/packages/idraw/__tests__/history-moveElement.test.ts new file mode 100644 index 0000000..5f9a848 --- /dev/null +++ b/packages/idraw/__tests__/history-moveElement.test.ts @@ -0,0 +1,315 @@ +import { iDraw, useHistory, findElementFromListByPosition, calcResultMovePosition } from 'idraw'; +import type { Elements } from 'idraw'; + +const getElemBase = () => { + return { + x: 0, + y: 0, + w: 1, + h: 1 + }; +}; + +function generateElements(list: any[]): Elements { + const elements: Elements = list.map((item) => { + if (Array.isArray(item)) { + const groupIds = item[0].split('-'); + groupIds.pop(); + return { + ...getElemBase(), + uuid: groupIds.join('-'), + type: 'group', + detail: { + children: generateElements(item) + } + }; + } else { + return { + ...getElemBase(), + uuid: item, + type: 'rect', + detail: {} + }; + } + }) as Elements; + return elements; +} + +const createData = (list: any[]) => ({ + elements: generateElements(list) +}); + +describe('idraw: useHistory ', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01')); + }); + + test('moveElement', () => { + const getList1 = () => ['0', '1', '2', ['3-0', '3-1', ['3-2-0', '3-2-1', '3-2-2', '3-2-3'], '3-3'], '4', '5']; + const data = createData(getList1()); + const div = document.createElement('div') as HTMLDivElement; + + const idraw = new iDraw(div, { + height: 200, + width: 200 + }); + const { MiddlewareHistory, history } = useHistory({ instance: idraw }); + const { undo, redo, __getDoRecords, __getUndoRecords } = history; + idraw.use(MiddlewareHistory); + idraw.setData(data); + + // modify 1: do + const from1 = [3, 2, 1]; + const to1 = [2]; + // result from: [ 4, 2, 1 ], to: [ 2 ] + const uuid1 = findElementFromListByPosition(from1, data.elements)?.uuid as string; + idraw.moveElement(uuid1, to1); + + const record1 = { + type: 'moveElement', + time: new Date().getTime(), + content: { + method: 'moveElement', + uuid: uuid1, + from: [...from1], + to: [...to1] + } + }; + + // ['0', '1', '3-2-1', '2', ['3-0', '3-1', ['3-2-0', '3-2-2', '3-2-3'], '3-3'], '4', '5']; + const expectedElements1 = generateElements([ + '0', + '1', + '3-2-1', + '2', + ['3-0', '3-1', ['3-2-0', '3-2-2', '3-2-3'], '3-3'], + '4', + '5' + ]); + // const expectedElements1 = moveElementPosition(generateElements(getList1()), { + // from: [...from1], + // to: [...to1] + // }).elements; + + expect(idraw.getData()?.elements).toStrictEqual(expectedElements1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 2: do + const from2 = [2]; + const to2 = [4]; + const uuid2 = findElementFromListByPosition(from2, data.elements)?.uuid as string; + // console.log('uuid2 ----- ', uuid2, findElementFromListByPosition(to2, data.elements)?.uuid); + idraw.moveElement(uuid1, to2); + const record2 = { + type: 'moveElement', + time: new Date().getTime(), + content: { + method: 'moveElement', + uuid: uuid2, + from: [...from2], + to: [...to2] + } + }; + const expectedElements2 = generateElements([ + '0', + '1', + '2', + '3-2-1', + ['3-0', '3-1', ['3-2-0', '3-2-2', '3-2-3'], '3-3'], + '4', + '5' + ]); + // const expectedElements2 = moveElementPosition(expectedElements1, { + // from: [...from2], + // to: [...to2] + // }).elements; + expect(idraw.getData()?.elements).toStrictEqual(expectedElements2); + expect(__getDoRecords()).toStrictEqual([record1, record2]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 3: undo + undo(); + const moveResult2 = calcResultMovePosition({ + from: [...from2], + to: [...to2] + }) as { from: number[]; to: number[] }; + const record3 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'moveElement', + uuid: record2.content.uuid, + from: [...moveResult2.to], + to: [...moveResult2.from] + } + }; + const expectedElements3 = generateElements([ + '0', + '1', + '3-2-1', + '2', + ['3-0', '3-1', ['3-2-0', '3-2-2', '3-2-3'], '3-3'], + '4', + '5' + ]); + // const expectedElements3 = moveElementPosition(expectedElements1, { + // from: [...moveResult2.to], + // to: [...moveResult2.from] + // }).elements; + expect(idraw.getData()?.elements).toStrictEqual(expectedElements3); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 4: undo + undo(); + const moveResult3 = calcResultMovePosition({ + from: [...from1], + to: [...to1] + }) as { from: number[]; to: number[] }; + const record4 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'moveElement', + uuid: record1.content.uuid, + from: [...moveResult3.to], + to: [...moveResult3.from] + } + }; + const expectedElements4 = generateElements([ + '0', + '1', + '2', + ['3-0', '3-1', ['3-2-0', '3-2-1', '3-2-2', '3-2-3'], '3-3'], + '4', + '5' + ]); + // const expectedElements4 = moveElementPosition(expectedElements3, { + // from: [...moveResult3.to], + // to: [...moveResult3.from] + // }).elements; + expect(idraw.getData()?.elements).toStrictEqual(expectedElements4); + expect(__getDoRecords()).toStrictEqual([]); + expect(__getUndoRecords()).toStrictEqual([record3, record4]); + + // modify 5: redo + redo(); + const moveResult4 = calcResultMovePosition({ + from: [...record4.content.from], + to: [...record4.content.to] + }) as { from: number[]; to: number[] }; + const record5 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'moveElement', + uuid: record4.content.uuid, + from: [...moveResult4.to], + to: [...moveResult4.from] + } + }; + const expectedElements5 = generateElements([ + '0', + '1', + '3-2-1', + '2', + ['3-0', '3-1', ['3-2-0', '3-2-2', '3-2-3'], '3-3'], + '4', + '5' + ]); + // const expectedElements5 = moveElementPosition(expectedElements3, { + // from: [...moveResult4.from], + // to: [...moveResult4.to] + // }).elements; + expect(idraw.getData()?.elements).toStrictEqual(expectedElements5); + expect(__getDoRecords()).toStrictEqual([record5]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 6: redo + redo(); + const moveResult5 = calcResultMovePosition({ + from: [...record3.content.from], + to: [...record3.content.to] + }) as { from: number[]; to: number[] }; + const record6 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'moveElement', + uuid: record4.content.uuid, + from: [...moveResult5.to], + to: [...moveResult5.from] + } + }; + const expectedElements6 = generateElements([ + '0', + '1', + '2', + '3-2-1', + ['3-0', '3-1', ['3-2-0', '3-2-2', '3-2-3'], '3-3'], + '4', + '5' + ]); + expect(idraw.getData()?.elements).toStrictEqual(expectedElements6); + expect(__getDoRecords()).toStrictEqual([record5, record6]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 7: undo + undo(); + const moveResult6 = calcResultMovePosition({ + from: [...record6.content.from], + to: [...record6.content.to] + }) as { from: number[]; to: number[] }; + const record7 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'moveElement', + uuid: record6.content.uuid, + from: [...moveResult6.to], + to: [...moveResult6.from] + } + }; + const expectedElements7 = generateElements([ + '0', + '1', + '3-2-1', + '2', + ['3-0', '3-1', ['3-2-0', '3-2-2', '3-2-3'], '3-3'], + '4', + '5' + ]); + expect(idraw.getData()?.elements).toStrictEqual(expectedElements7); + expect(__getDoRecords()).toStrictEqual([record5]); + expect(__getUndoRecords()).toStrictEqual([record7]); + + // modify 8: undo + undo(); + const moveResult7 = calcResultMovePosition({ + from: [...record5.content.from], + to: [...record5.content.to] + }) as { from: number[]; to: number[] }; + const record8 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'moveElement', + uuid: record5.content.uuid, + from: [...moveResult7.to], + to: [...moveResult7.from] + } + }; + const expectedElements8 = generateElements([ + '0', + '1', + '2', + ['3-0', '3-1', ['3-2-0', '3-2-1', '3-2-2', '3-2-3'], '3-3'], + '4', + '5' + ]); + expect(idraw.getData()?.elements).toStrictEqual(expectedElements8); + expect(__getDoRecords()).toStrictEqual([]); + expect(__getUndoRecords()).toStrictEqual([record7, record8]); + }); +}); diff --git a/packages/idraw/__tests__/history-updateElement.test.ts b/packages/idraw/__tests__/history-updateElement.test.ts new file mode 100644 index 0000000..dd3e4de --- /dev/null +++ b/packages/idraw/__tests__/history-updateElement.test.ts @@ -0,0 +1,177 @@ +import { iDraw, useHistory, deepClone, createElement, toFlattenElement, mergeElement } from 'idraw'; + +const createData = () => ({ + elements: [ + createElement('rect', { + uuid: 'test-001', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + background: '#DDDDDD' + } + }), + createElement('circle', { uuid: 'test-002' }), + createElement('text', { + uuid: 'test-003', + detail: { + text: 'Hello World' + } + }), + createElement('image', { uuid: 'test-004', detail: { src: 'https://example.com/001.png' } }), + createElement('group', { + uuid: 'test-005', + detail: { + children: [ + createElement('rect', { uuid: 'test-006' }), + createElement('circle', { uuid: 'test-007' }), + createElement('text', { + uuid: 'test-008', + detail: { + text: 'Text in Group' + } + }), + createElement('image', { uuid: 'test-009', detail: { src: 'https://example.com/002.png' } }) + ] + } + }) + ] +}); + +describe('idraw: useHistory ', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01')); + }); + + test('updateElement', () => { + const data = createData(); + const div = document.createElement('div') as HTMLDivElement; + + const idraw = new iDraw(div, { + height: 200, + width: 200 + }); + const { MiddlewareHistory, history } = useHistory({ instance: idraw }); + const { undo, redo, __getDoRecords, __getUndoRecords } = history; + idraw.use(MiddlewareHistory); + idraw.setData(data); + const targetElement = deepClone(data.elements[0]); + + // modify 1: do + const updatedElement1 = deepClone(targetElement); + updatedElement1.x += 1; + updatedElement1.y += 2; + updatedElement1.detail.background = '#123456'; + updatedElement1.detail.borderRadius = 3; + idraw.updateElement(updatedElement1); + + const beforeInfo1: Record = toFlattenElement(targetElement); + const afterInfo1: Record = toFlattenElement(updatedElement1); + + const expectedData1 = createData(); + mergeElement(expectedData1.elements[0], updatedElement1); + const record1 = { + type: 'updateElement', + time: new Date().getTime(), + content: { + method: 'updateElement', + uuid: targetElement.uuid, + before: beforeInfo1, + after: afterInfo1 + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 2: do + const updatedElement2 = deepClone(updatedElement1); + updatedElement2.x += 3; + updatedElement2.y += 4; + updatedElement2.detail.borderRadius = [2, 4, 6, 8]; + idraw.updateElement(updatedElement2); + const beforeInfo2: Record = toFlattenElement(updatedElement1); + const afterInfo2: Record = toFlattenElement(updatedElement2); + + const expectedData2 = createData(); + mergeElement(expectedData2.elements[0], updatedElement2); + const record2 = { + type: 'updateElement', + time: new Date().getTime(), + content: { + method: 'updateElement', + uuid: targetElement.uuid, + before: beforeInfo2, + after: afterInfo2 + } + }; + expect(idraw.getData()).toStrictEqual(expectedData2); + expect(__getDoRecords()).toStrictEqual([record1, record2]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 3: undo + undo(); + const record3 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'updateElement', + uuid: targetElement.uuid, + before: deepClone(record2.content.after), + after: deepClone(record2.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 4: undo + undo(); + const record4 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'updateElement', + uuid: targetElement.uuid, + before: deepClone(record1.content.after), + after: deepClone(record1.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(createData()); + expect(__getDoRecords()).toStrictEqual([]); + expect(__getUndoRecords()).toStrictEqual([record3, record4]); + + // modify 5: redo + redo(); + const record5 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'updateElement', + uuid: targetElement.uuid, + before: deepClone(record4.content.after), + after: deepClone(record4.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record5]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 5: redo + redo(); + const record6 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'updateElement', + uuid: targetElement.uuid, + before: deepClone(record3.content.after), + after: deepClone(record3.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData2); + expect(__getDoRecords()).toStrictEqual([record5, record6]); + expect(__getUndoRecords()).toStrictEqual([]); + }); +}); diff --git a/packages/idraw/__tests__/history.test.ts b/packages/idraw/__tests__/history.test.ts new file mode 100644 index 0000000..0188da2 --- /dev/null +++ b/packages/idraw/__tests__/history.test.ts @@ -0,0 +1,204 @@ +import { iDraw, useHistory, deepClone, createElement, set, get, toFlattenElement } from 'idraw'; +import type { RecursivePartial, Element } from 'idraw'; + +const createData = () => ({ + elements: [ + createElement('rect', { + uuid: 'test-001', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + background: '#DDDDDD' + } + }), + createElement('circle', { uuid: 'test-002' }), + createElement('text', { + uuid: 'test-003', + detail: { + text: 'Hello World' + } + }), + createElement('image', { uuid: 'test-004', detail: { src: 'https://example.com/001.png' } }), + createElement('group', { + uuid: 'test-005', + detail: { + children: [ + createElement('rect', { uuid: 'test-006' }), + createElement('circle', { uuid: 'test-007' }), + createElement('text', { + uuid: 'test-008', + detail: { + text: 'Text in Group' + } + }), + createElement('image', { uuid: 'test-009', detail: { src: 'https://example.com/002.png' } }) + ] + } + }) + ] +}); + +describe('idraw: useHistory ', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01')); + }); + + test('modifyElement', () => { + const data = createData(); + const div = document.createElement('div') as HTMLDivElement; + + const idraw = new iDraw(div, { + height: 200, + width: 200 + }); + const { MiddlewareHistory, history } = useHistory({ instance: idraw }); + const { undo, redo, __getDoRecords, __getUndoRecords } = history; + idraw.use(MiddlewareHistory); + idraw.setData(data); + const targetElement = deepClone(data.elements[0]); + + // modify 1: do + const modifiedInfo1 = { + x: targetElement.x + 1, + y: targetElement.y + 2, + detail: { + background: '#123456', + borderRadius: 3 + } + }; + idraw.modifyElement({ + uuid: targetElement.uuid, + ...deepClone(modifiedInfo1) + }); + const expectedData1 = createData(); + const flattenModifiedInfo1 = toFlattenElement(modifiedInfo1); + const beforeInfo1: Record = {}; + const afterInfo1 = { ...flattenModifiedInfo1 }; + Object.keys(flattenModifiedInfo1).forEach((key) => { + beforeInfo1[key] = get(expectedData1.elements[0], key); + set(expectedData1.elements[0], key, flattenModifiedInfo1[key]); + }); + const record1 = { + type: 'modifyElement', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: beforeInfo1, + after: afterInfo1 + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 2: do + const modifiedInfo2 = { + x: modifiedInfo1.x + 3, + y: modifiedInfo1.y + 4, + detail: { + borderRadius: [2, 4, 6, 8] + } + } as unknown as RecursivePartial>; + + idraw.modifyElement({ + uuid: targetElement.uuid, + ...deepClone(modifiedInfo2) + } as RecursivePartial> & Pick); + + const expectedData2 = deepClone(expectedData1); + const flattenModifiedInfo2 = toFlattenElement(modifiedInfo2); + const beforeInfo2: Record = {}; + const afterInfo2 = { ...flattenModifiedInfo2 }; + + Object.keys(flattenModifiedInfo2).forEach((key) => { + let beforeVal = get(expectedData1.elements[0], key); + let beforeKey = key; + if (beforeVal === undefined && /(borderRadius|borderWidth)\[[0-9]{1,}\]$/.test(beforeKey)) { + beforeKey = beforeKey.replace(/\[[0-9]{1,}\]$/, ''); + beforeVal = get(expectedData1.elements[0], beforeKey); + } + beforeInfo2[beforeKey] = beforeVal; + set(expectedData2.elements[0], key, flattenModifiedInfo2[key]); + }); + const record2 = { + type: 'modifyElement', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: beforeInfo2, + after: afterInfo2 + } + }; + expect(idraw.getData()).toStrictEqual(expectedData2); + expect(__getDoRecords()).toStrictEqual([record1, record2]); + expect(__getUndoRecords()).toStrictEqual([]); + + // modify 3: undo + undo(); + const record3 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: deepClone(record2.content.after), + after: deepClone(record2.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record1]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 4: undo + undo(); + const record4 = { + type: 'undo', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: deepClone(record1.content.after), + after: deepClone(record1.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(createData()); + expect(__getDoRecords()).toStrictEqual([]); + expect(__getUndoRecords()).toStrictEqual([record3, record4]); + + // modify 5: redo + redo(); + const record5 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: deepClone(record4.content.after), + after: deepClone(record4.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData1); + expect(__getDoRecords()).toStrictEqual([record5]); + expect(__getUndoRecords()).toStrictEqual([record3]); + + // modify 5: redo + redo(); + const record6 = { + type: 'redo', + time: new Date().getTime(), + content: { + method: 'modifyElement', + uuid: targetElement.uuid, + before: deepClone(record3.content.after), + after: deepClone(record3.content.before) + } + }; + expect(idraw.getData()).toStrictEqual(expectedData2); + expect(__getDoRecords()).toStrictEqual([record5, record6]); + expect(__getUndoRecords()).toStrictEqual([]); + }); +}); diff --git a/packages/idraw/__tests__/index.test.ts b/packages/idraw/__tests__/index.test.ts deleted file mode 100644 index 3d9d907..0000000 --- a/packages/idraw/__tests__/index.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { requestAnimationFrameMock } from './../../../__tests__/polyfill/requestanimateframe'; -import './../../../__tests__/polyfill/image'; - -import IDraw from './../src'; -import { getData } from './data'; - -function delay(time: number): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, time); - }); -} - -describe('idraw', () => { - beforeEach(() => { - requestAnimationFrameMock.reset(); - }); - - test('context', async () => { - document.body.innerHTML = ` -
- `; - const opts = { - width: 600, - height: 400, - contextWidth: 600, - contextHeight: 400, - devicePixelRatio: 4 - }; - const mount = document.querySelector('#mount') as HTMLDivElement; - const idraw = new IDraw(mount, opts); - const data = getData(); - idraw.setData(data, { triggerChangeEvent: true }); - - requestAnimationFrameMock.triggerNextAnimationFrame(); - - const originCtx = idraw.$getOriginContext2D(); - // @ts-ignore; - const originCalls = originCtx.__getDrawCalls(); - expect(originCalls).toMatchSnapshot(); - - const displayCtx = idraw.$getDisplayContext2D(); - // @ts-ignore; - const displayCalls = displayCtx.__getDrawCalls(); - expect(displayCalls).toMatchSnapshot(); - }); - - test('undo/redo', async () => { - document.body.innerHTML = ` -
- `; - const opts = { - width: 600, - height: 400, - contextWidth: 600, - contextHeight: 400, - devicePixelRatio: 4 - }; - const mount = document.querySelector('#mount') as HTMLDivElement; - const idraw = new IDraw(mount, opts); - const data = getData(); - idraw.setData(data, { triggerChangeEvent: true }); - idraw.moveDownElement('svg-004'); - idraw.moveDownElement('image-003'); - await delay(10); - - const undo1 = idraw.undo(); - expect(undo1.doRecordCount).toBe(2); - expect(undo1.data?.elements?.length).toBe(4); - - const undo2 = idraw.undo(); - expect(undo2.doRecordCount).toBe(1); - expect(undo2.data?.elements?.length).toBe(4); - - const redo1 = idraw.redo(); - expect(redo1.undoRecordCount).toBe(1); - expect(redo1.data?.elements?.length).toBe(4); - - idraw.moveDownElement('image-003'); - - const redo2 = idraw.redo(); - expect(redo2.undoRecordCount).toBe(0); - expect(redo2.data).toBe(null); - - requestAnimationFrameMock.triggerNextAnimationFrame(); - - const originCtx = idraw.$getOriginContext2D(); - // @ts-ignore; - const originCalls = originCtx.__getDrawCalls(); - expect(originCalls).toMatchSnapshot(); - - const displayCtx = idraw.$getDisplayContext2D(); - // @ts-ignore; - const displayCalls = displayCtx.__getDrawCalls(); - expect(displayCalls).toMatchSnapshot(); - }); -}); diff --git a/packages/idraw/src/file.ts b/packages/idraw/src/file.ts index fbf5379..fb76f77 100644 --- a/packages/idraw/src/file.ts +++ b/packages/idraw/src/file.ts @@ -25,9 +25,12 @@ export type ExportImageFileResult = { 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 tempContext: ViewContext2D | null = createOffscreenContext2D({ width, height, devicePixelRatio }); + // let calculator: Calculator | null = new Calculator({ viewContext }); let renderer: Renderer | null = new Renderer({ - viewContext + viewContext, + tempContext }); renderer.setLoadItemMap(loadItemMap); renderer.drawData(data, { @@ -35,6 +38,7 @@ export async function exportImageFileBlobURL(opts: ExportImageFileOptions): Prom viewSizeInfo, forceDrawAll: true }); + let blobURL: string | null = null; let offScreenCanvas = viewContext.$getOffscreenCanvas(); if (offScreenCanvas) { @@ -44,6 +48,7 @@ export async function exportImageFileBlobURL(opts: ExportImageFileOptions): Prom offScreenCanvas = null; viewContext = null; + tempContext = null; renderer = null; return { diff --git a/packages/idraw/src/idraw.ts b/packages/idraw/src/idraw.ts index c17bcc8..9218a24 100644 --- a/packages/idraw/src/idraw.ts +++ b/packages/idraw/src/idraw.ts @@ -13,35 +13,21 @@ import type { RecursivePartial, ElementPosition, IDrawStorage, - DataLayout + DataLayout, + DataGlobal, + Middleware } from '@idraw/types'; -import { - createElement, - insertElementToListByPosition, - updateElementInList, - deleteElementInList, - moveElementPosition, - getElementPositionFromList, - calcElementListSize, - filterCompactData, - calcViewCenterContent, - calcViewCenter, - Store, - merge -} from '@idraw/util'; -import { - defaultSettings, - defaultOptions, - getDefaultStorage, - defaultMode, - parseStyles, - parseSettings -} from './setting/config'; -import { exportImageFileBlobURL } from './file'; +import { filterCompactData, calcViewCenterContent, calcViewCenter, Store } from '@idraw/util'; +import { defaultSettings, defaultOptions, getDefaultStorage, defaultMode, parseStyles } from './setting/config'; import type { ExportImageFileBaseOptions, ExportImageFileResult } from './file'; import type { IDrawEvent } from './event'; -import { changeMode, runMiddlewares } from './setting/mode'; -import { changeStyles } from './setting/style'; +import { changeMode } from './setting/mode'; +import { createElement, updateElement, modifyElement, addElement, deleteElement, moveElement } from './methods/element'; +import { modifyLayout } from './methods/layout'; +import { modifyGlobal } from './methods/global'; +import { reset } from './methods/reset'; +import { setFeature } from './methods/feature'; +import { getImageBlobURL } from './methods/image'; export class iDraw { #core: Core; @@ -67,54 +53,20 @@ export class iDraw { } #setFeature(feat: IDrawFeature, status: boolean) { - const store = this.#store; - if (['ruler', 'scroll', 'scale', 'info'].includes(feat)) { - const map: Record> = { - ruler: 'enableRuler', - scroll: 'enableScroll', - scale: 'enableScale', - info: 'enableInfo' - }; - store.set(map[feat], !!status); - runMiddlewares(this.#core, store); - this.#core.refresh(); - } else if (feat === 'selectInGroup') { - this.#core.trigger(coreEventKeys.SELECT_IN_GROUP, { - enable: !!status - }); - } else if (feat === 'snapToGrid') { - this.#core.trigger(coreEventKeys.SNAP_TO_GRID, { - enable: !!status - }); - } + return setFeature({ core: this.#core, store: this.#store }, feat, status); + } + + use(middleware: Middleware, config?: C) { + this.#core.use(middleware, config); + } + + disuse(middleware: Middleware) { + this.#core.disuse(middleware); } reset(opts: IDrawSettings) { - const core = this.#core; - const store = this.#store; - const { mode, styles } = parseSettings(opts); - let needFresh = false; - const newOpts: IDrawSettings = {}; - store.clear(); - if (mode) { - changeMode(mode, core, store); - newOpts.mode = mode; - needFresh = true; - } - if (styles) { - changeStyles(styles, core, store); - newOpts.styles = styles; - needFresh = true; - } - - if (needFresh === true) { - core.refresh(); - } - - this.#opts = { - ...this.#opts, - ...newOpts - }; + const newOpts = reset({ core: this.#core, store: this.#store }, opts); + this.#opts = { ...this.#opts, ...newOpts }; } setMode(mode: IDrawMode) { @@ -148,10 +100,7 @@ export class iDraw { return data; } - getViewInfo(): { - viewSizeInfo: ViewSizeInfo; - viewScaleInfo: ViewScaleInfo; - } { + getViewInfo(): { viewSizeInfo: ViewSizeInfo; viewScaleInfo: ViewScaleInfo } { return this.#core.getViewInfo(); } @@ -212,108 +161,44 @@ export class iDraw { createElement( type: T, - opts?: { - element?: RecursivePartial>; - viewCenter?: boolean; - } + element: RecursivePartial>, + opts?: { viewCenter?: boolean } ): Element { - const { viewScaleInfo, viewSizeInfo } = this.#core.getViewInfo(); - return createElement( - type, - opts?.element || {}, - opts?.viewCenter === true - ? { - viewScaleInfo, - viewSizeInfo - } - : undefined - ); + return createElement({ core: this.#core }, type, element, opts); } updateElement(element: Element) { - const core = this.#core; - const data: Data = core.getData() || { elements: [] }; - updateElementInList(element.uuid, element, data.elements); - core.setData(data); - core.refresh(); - core.trigger(coreEventKeys.CHANGE, { data, type: 'updateElement' }); + return updateElement({ core: this.#core }, element); } - updateElementName(uuid: string, name: string) { - const core = this.#core; - const data: Data = core.getData() || { elements: [] }; - updateElementInList(uuid, { name }, data.elements); - core.setData(data); - core.trigger(coreEventKeys.CHANGE, { data, type: 'updateElementName' }); + modifyElement(element: RecursivePartial> & Pick) { + return modifyElement({ core: this.#core }, element); } - addElement( - element: Element, - opts?: { - position: ElementPosition; - } - ): Data { - const core = this.#core; - const data: Data = core.getData() || { elements: [] }; - if (!opts || !opts?.position?.length) { - data.elements.push(element); - } else if (opts?.position) { - const position = [...(opts?.position || [])]; - insertElementToListByPosition(element, position, data.elements); - } - core.setData(data); - core.refresh(); - core.trigger(coreEventKeys.CHANGE, { data, type: 'addElement' }); - return data; + addElement(element: Element, opts?: { position: ElementPosition }): Data { + return addElement({ core: this.#core }, element, opts); } deleteElement(uuid: string) { - const core = this.#core; - const data: Data = core.getData() || { elements: [] }; - deleteElementInList(uuid, data.elements); - core.setData(data); - core.refresh(); - core.trigger(coreEventKeys.CHANGE, { data, type: 'deleteElement' }); + return deleteElement({ core: this.#core }, uuid); } moveElement(uuid: string, to: ElementPosition) { - const core = this.#core; - const data: Data = core.getData() || { elements: [] }; - const from = getElementPositionFromList(uuid, data.elements); - const { elements: list } = moveElementPosition(data.elements, { from, to }); - data.elements = list; - core.setData(data); - core.refresh(); - core.trigger(coreEventKeys.CHANGE, { data, type: 'moveElement' }); + return moveElement({ core: this.#core }, uuid, to); } - updateLayout(layout: Partial) { - const core = this.#core; - const data: Data = core.getData() || { elements: [] }; - data.layout = merge(data.layout || {}, layout) as DataLayout; - core.setData(data); - core.refresh(); - core.trigger(coreEventKeys.CHANGE, { data, type: 'updateLayout' }); + modifyLayout(layout: RecursivePartial | null) { + return modifyLayout({ core: this.#core }, layout); + } + + modifyGlobal(global: RecursivePartial | null) { + return modifyGlobal({ core: this.#core }, global); } async getImageBlobURL(opts?: ExportImageFileBaseOptions): Promise { const data = this.getData() || { elements: [] }; - const { devicePixelRatio } = opts || { devicePixelRatio: 1 }; - - 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: { - ...viewSizeInfo, - ...{ devicePixelRatio } - }, - loadItemMap: this.#core.getLoadItemMap() - }); + return await getImageBlobURL({ data, viewSizeInfo, core: this.#core }, opts); } isDestroyed() { @@ -321,10 +206,8 @@ export class iDraw { } destroy() { - const core = this.#core; - const store = this.#store; - core.destroy(); - store.destroy(); + this.#core.destroy(); + this.#store.destroy(); } getViewCenter(): PointSize { @@ -333,11 +216,15 @@ export class iDraw { return pointSize; } - $onBoardWatcherEvents() { - this.#core.onBoardWatcherEvents(); - } + // $onBoardWatcherEvents() { + // this.#core.onBoardWatcherEvents(); + // } - $offBoardWatcherEvents() { - this.#core.offBoardWatcherEvents(); + // $offBoardWatcherEvents() { + // this.#core.offBoardWatcherEvents(); + // } + + getCore() { + return this.#core; } } diff --git a/packages/idraw/src/index.ts b/packages/idraw/src/index.ts index f69408f..cdef852 100644 --- a/packages/idraw/src/index.ts +++ b/packages/idraw/src/index.ts @@ -3,6 +3,7 @@ export { Sharer, Calculator, Core, + Board, MiddlewareSelector, MiddlewareScroller, MiddlewareScaler, @@ -118,15 +119,25 @@ export { deepCloneElement, calcViewCenterContent, calcViewCenter, - modifyElement, calcElementViewRectInfo, calcElementOriginRectInfo, flatElementList, calcPointMoveElementInGroup, merge, - omit + omit, + toFlattenElement, + toFlattenGlobal, + toFlattenLayout, + flatObject, + unflatObject, + set, + get, + mergeElement, + calcResultMovePosition, + calcRevertMovePosition } from '@idraw/util'; export { iDraw } from './idraw'; export { eventKeys } from './event'; export type { IDrawEvent, IDrawEventKeys } from './event'; export type { ExportImageFileResult, ExportImageFileBaseOptions } from './file'; +export { useHistory } from './middlewares/use-history'; diff --git a/packages/idraw/src/methods/element.ts b/packages/idraw/src/methods/element.ts new file mode 100644 index 0000000..1b1e1a4 --- /dev/null +++ b/packages/idraw/src/methods/element.ts @@ -0,0 +1,84 @@ +import type { Data, Element, ElementType, ElementPosition, RecursivePartial } from '@idraw/types'; +import { Core, coreEventKeys } from '@idraw/core'; +import { IDrawEvent } from '../event'; + +export function createElement( + depOptions: { + core: Core; + }, + type: T, + element: RecursivePartial>, + opts?: { + viewCenter?: boolean; + } +): Element { + const { core } = depOptions; + return core.createElement(type, element, opts); +} + +export function updateElement( + depOptions: { + core: Core; + }, + element: Element +) { + const { core } = depOptions; + + const modifyRecord = core.updateElement(element); + if (!modifyRecord) { + return; + } + const data = core.getData(); + if (!data) { + return; + } + core.trigger(coreEventKeys.CHANGE, { data, type: 'updateElement', modifyRecord }); +} + +export function modifyElement( + depOptions: { + core: Core; + }, + element: RecursivePartial> & Pick +) { + const { core } = depOptions; + const modifyRecord = core.modifyElement(element); + if (!modifyRecord) { + return; + } + const data = core.getData(); + if (!data) { + return; + } + core.trigger(coreEventKeys.CHANGE, { data, type: 'updateElement', modifyRecord }); +} + +export function addElement( + depOptions: { + core: Core; + }, + element: Element, + opts?: { + position: ElementPosition; + } +): Data { + const { core } = depOptions; + const modifyRecord = core.addElement(element, opts); + const data = core.getData() as Data; + core.trigger(coreEventKeys.CHANGE, { data, type: 'addElement', modifyRecord }); + return data; +} + +export function deleteElement(depOptions: { core: Core }, uuid: string) { + const { core } = depOptions; + const modifyRecord = core.deleteElement(uuid); + const data = core.getData() as Data; + core.trigger(coreEventKeys.CHANGE, { data, type: 'deleteElement', modifyRecord }); +} + +export function moveElement(depOptions: { core: Core }, uuid: string, to: ElementPosition) { + const { core } = depOptions; + const modifyRecord = core.moveElement(uuid, to); + const data = core.getData() as Data; + core.trigger(coreEventKeys.CHANGE, { data, type: 'moveElement', modifyRecord }); +} diff --git a/packages/idraw/src/methods/feature.ts b/packages/idraw/src/methods/feature.ts new file mode 100644 index 0000000..ca5d727 --- /dev/null +++ b/packages/idraw/src/methods/feature.ts @@ -0,0 +1,32 @@ +import type { IDrawFeature, IDrawStorage } from '@idraw/types'; +import { Core, coreEventKeys } from '@idraw/core'; +import { Store } from '@idraw/util'; +import { IDrawEvent } from '../event'; +import { runMiddlewares } from '../setting/mode'; + +export function setFeature( + depOptions: { core: Core; store: Store }, + feat: IDrawFeature, + status: boolean +) { + const { core, store } = depOptions; + if (['ruler', 'scroll', 'scale', 'info'].includes(feat)) { + const map: Record> = { + ruler: 'enableRuler', + scroll: 'enableScroll', + scale: 'enableScale', + info: 'enableInfo' + }; + store.set(map[feat], !!status); + runMiddlewares(core, store); + core.refresh(); + } else if (feat === 'selectInGroup') { + core.trigger(coreEventKeys.SELECT_IN_GROUP, { + enable: !!status + }); + } else if (feat === 'snapToGrid') { + core.trigger(coreEventKeys.SNAP_TO_GRID, { + enable: !!status + }); + } +} diff --git a/packages/idraw/src/methods/global.ts b/packages/idraw/src/methods/global.ts new file mode 100644 index 0000000..6b9aac5 --- /dev/null +++ b/packages/idraw/src/methods/global.ts @@ -0,0 +1,10 @@ +import type { Data, DataGlobal, RecursivePartial } from '@idraw/types'; +import { Core, coreEventKeys } from '@idraw/core'; +import { IDrawEvent } from '../event'; + +export function modifyGlobal(depOptions: { core: Core }, global: RecursivePartial | null) { + const { core } = depOptions; + const modifyRecord = core.modifyGlobal(global); + const data = core.getData() as Data; + core.trigger(coreEventKeys.CHANGE, { data, type: 'modifyGlobal', modifyRecord }); +} diff --git a/packages/idraw/src/methods/image.ts b/packages/idraw/src/methods/image.ts new file mode 100644 index 0000000..2696ed4 --- /dev/null +++ b/packages/idraw/src/methods/image.ts @@ -0,0 +1,29 @@ +import type { Data, ViewSizeInfo } from '@idraw/types'; +import { Core } from '@idraw/core'; +import { calcElementListSize } from '@idraw/util'; +import { IDrawEvent } from '../event'; +import { exportImageFileBlobURL } from '../file'; +import type { ExportImageFileBaseOptions, ExportImageFileResult } from '../file'; + +export async function getImageBlobURL( + depOptions: { data: Data; viewSizeInfo: ViewSizeInfo; core: Core }, + opts?: ExportImageFileBaseOptions +): Promise { + const { data, viewSizeInfo, core } = depOptions; + const { devicePixelRatio } = opts || { devicePixelRatio: 1 }; + + const outputSize = calcElementListSize(data.elements); + + 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: { + ...viewSizeInfo, + ...{ devicePixelRatio } + }, + loadItemMap: core.getLoadItemMap() + }); +} diff --git a/packages/idraw/src/methods/layout.ts b/packages/idraw/src/methods/layout.ts new file mode 100644 index 0000000..5502857 --- /dev/null +++ b/packages/idraw/src/methods/layout.ts @@ -0,0 +1,10 @@ +import type { Data, DataLayout, RecursivePartial } from '@idraw/types'; +import { Core, coreEventKeys } from '@idraw/core'; +import { IDrawEvent } from '../event'; + +export function modifyLayout(depOptions: { core: Core }, layout: RecursivePartial | null) { + const { core } = depOptions; + const modifyRecord = core.modifyLayout(layout); + const data = core.getData() as Data; + core.trigger(coreEventKeys.CHANGE, { data, type: 'modifyLayout', modifyRecord }); +} diff --git a/packages/idraw/src/methods/reset.ts b/packages/idraw/src/methods/reset.ts new file mode 100644 index 0000000..8230d74 --- /dev/null +++ b/packages/idraw/src/methods/reset.ts @@ -0,0 +1,34 @@ +import type { IDrawSettings, IDrawStorage } from '@idraw/types'; +import { Core } from '@idraw/core'; +import { Store } from '@idraw/util'; +import { IDrawEvent } from '../event'; +import { parseSettings } from '../setting/config'; +import { changeMode } from '../setting/mode'; +import { changeStyles } from '../setting/style'; + +export function reset( + depOptions: { core: Core; store: Store }, + opts: IDrawSettings +): IDrawSettings { + const { core, store } = depOptions; + const { mode, styles } = parseSettings(opts); + let needFresh = false; + const newOpts: IDrawSettings = {}; + store.clear(); + if (mode) { + changeMode(mode, core, store); + newOpts.mode = mode; + needFresh = true; + } + if (styles) { + changeStyles(styles, core, store); + newOpts.styles = styles; + needFresh = true; + } + + if (needFresh === true) { + core.refresh(); + } + + return newOpts; +} diff --git a/packages/idraw/src/middlewares/use-history.ts b/packages/idraw/src/middlewares/use-history.ts new file mode 100644 index 0000000..2d193cc --- /dev/null +++ b/packages/idraw/src/middlewares/use-history.ts @@ -0,0 +1,221 @@ +import type { + Middleware, + ModifyRecord, + Element, + DataLayout, + RecursivePartial, + DataGlobal, + IDrawHistory +} from '@idraw/types'; +import { unflatObject, calcResultMovePosition } from '@idraw/util'; +import type { IDrawEvent } from '../event'; +import { eventKeys } from '../event'; +import type { iDraw } from '../idraw'; + +const supportRecordTypes = [ + 'updateElement', + 'modifyElement', + 'deleteElement', + 'moveElement', + 'addElement', + 'dragElement', + 'resizeElement', + 'dragLayout', + 'modifyLayout', + 'modifyGlobal' +]; + +export const useHistory = (opts: { instance: iDraw }) => { + const { instance } = opts; + const core = instance.getCore(); + let doRecords: ModifyRecord[] = []; + let undoRecords: ModifyRecord[] = []; + + const doAction = (record: ModifyRecord) => { + doRecords.push(record); + }; + + const undoAction = () => { + if (doRecords?.length > 0) { + const record = doRecords.pop(); + if (!record) { + return; + } + let undoRecord: ModifyRecord = { ...record }; + if (record.content.method === 'modifyElement') { + const info = unflatObject(record.content.before || {}); + undoRecord = core.modifyElement({ + ...info, + uuid: (record as ModifyRecord<'modifyElement'>).content?.uuid + }) as ModifyRecord; + } else if (record.content.method === 'updateElement') { + const info = unflatObject(record.content.before || {}) as Element; + undoRecord = core.updateElement({ ...info, uuid: record.content.uuid }) as ModifyRecord; + } else if (record.content.method === 'addElement') { + const uuid = record.content.uuid; + undoRecord = core.deleteElement(uuid) as ModifyRecord; + } else if (record.content.method === 'deleteElement') { + const { element, position } = record.content; + if (!element) { + return; + } + if (!element) { + return; + } + undoRecord = core.addElement(element, { position }) as ModifyRecord; + } else if (record.content.method === 'moveElement') { + const uuid = record.content.uuid; + const moveResult = calcResultMovePosition({ + from: record.content.from, + to: record.content.to + }); + if (!moveResult) { + return; + } + undoRecord = core.moveElement(uuid, moveResult.from) as ModifyRecord; + } else if (record.content.method === 'modifyLayout') { + const info = + record.content.before === null + ? null + : (unflatObject(record.content.before || {}) as RecursivePartial); + undoRecord = core.modifyLayout(info) as ModifyRecord; + } else if (record.content.method === 'modifyGlobal') { + const info = + record.content.before === null + ? null + : (unflatObject(record.content.before || {}) as RecursivePartial); + undoRecord = core.modifyGlobal(info) as ModifyRecord; + } else if (record.content.method === 'modifyElements') { + undoRecord = core.modifyElements( + record.content.before.forEach((item) => unflatObject(item)) as unknown as Array< + RecursivePartial> & Pick + > + ) as ModifyRecord; + } + + undoRecord = { ...undoRecord, type: 'undo' } as ModifyRecord<'undo'>; + undoRecords.push(undoRecord); + } + }; + + const redoAction = () => { + if (undoRecords?.length > 0) { + const record = undoRecords.pop(); + if (!record) { + return; + } + let redoRecord: ModifyRecord = { ...record }; + if (record.content.method === 'modifyElement') { + const info = unflatObject(record.content.before || {}); + redoRecord = core.modifyElement({ + ...info, + uuid: (record as ModifyRecord<'modifyElement'>).content.uuid + }) as ModifyRecord; + } else if (record.content.method === 'updateElement') { + const info = unflatObject(record.content.before || {}) as Element; + redoRecord = core.updateElement({ ...info, uuid: record.content.uuid }) as ModifyRecord; + } else if (record.content.method === 'addElement') { + const uuid = record.content.uuid; + redoRecord = core.deleteElement(uuid) as ModifyRecord; + } else if (record.content.method === 'deleteElement') { + const { element, position } = record.content; + if (!element) { + return; + } + redoRecord = core.addElement(element, { position }) as ModifyRecord; + } else if (record.content.method === 'moveElement') { + const uuid = record.content.uuid; + const moveResult = calcResultMovePosition({ + from: record.content.from, + to: record.content.to + }); + if (!moveResult) { + return; + } + redoRecord = core.moveElement(uuid, moveResult.from) as ModifyRecord; + } else if (record.content.method === 'modifyLayout') { + const info = + record.content.before === null + ? null + : (unflatObject(record.content.before || {}) as RecursivePartial); + redoRecord = core.modifyLayout(info) as ModifyRecord; + } else if (record.content.method === 'modifyGlobal') { + const info = + record.content.before === null + ? null + : (unflatObject(record.content.before || {}) as RecursivePartial); + redoRecord = core.modifyGlobal(info) as ModifyRecord; + } else if (record.content.method === 'modifyElements') { + redoRecord = core.modifyElements( + record.content.before.forEach((item) => unflatObject(item)) as unknown as Array< + RecursivePartial> & Pick + > + ) as ModifyRecord; + } + redoRecord = { ...redoRecord, type: 'redo' } as ModifyRecord<'redo'>; + doRecords.push(redoRecord); + } + }; + + const MiddlewareHistory: Middleware = (opts) => { + const { eventHub } = opts; + const changeEvent = (e: IDrawEvent['change']) => { + const { modifyRecord } = e; + if (modifyRecord && supportRecordTypes.includes(modifyRecord?.type)) { + doAction(modifyRecord); + } + }; + + const onEvents = () => { + eventHub.on(eventKeys.CHANGE, changeEvent); + }; + + const offEvents = () => { + eventHub.off(eventKeys.CHANGE, changeEvent); + }; + + return { + name: '@middleware/history', + use() { + onEvents(); + }, + disuse() { + offEvents(); + } + }; + }; + + const destroy = () => { + clear(); + doRecords = null as any; + undoRecords = null as any; + }; + + const clear = () => { + while (doRecords?.length > 0) { + doRecords.pop(); + } + while (undoRecords?.length > 0) { + undoRecords.pop(); + } + }; + + const getDoRecords = () => doRecords; + const getUndoRecords = () => undoRecords; + + const history: IDrawHistory = { + undo: undoAction, + redo: redoAction, + destroy, + clear, + canUndo: () => doRecords.length > 0, + canRedo: () => undoRecords.length > 0, + __getDoRecords: getDoRecords, + __getUndoRecords: getUndoRecords + }; + + return { + MiddlewareHistory, + history + } as const; +}; diff --git a/packages/idraw/src/setting/config.ts b/packages/idraw/src/setting/config.ts index b1fb9a0..dd1fa15 100644 --- a/packages/idraw/src/setting/config.ts +++ b/packages/idraw/src/setting/config.ts @@ -3,8 +3,9 @@ import { istype } from '@idraw/util'; export const defaultMode: IDrawMode = 'select'; -export const defaultSettings: Required> = { - mode: defaultMode +export const defaultSettings: Required> = { + mode: defaultMode, + history: false }; export const defaultOptions: Required> = { diff --git a/packages/idraw/src/setting/mode.ts b/packages/idraw/src/setting/mode.ts index 1959306..ed16baa 100644 --- a/packages/idraw/src/setting/mode.ts +++ b/packages/idraw/src/setting/mode.ts @@ -19,7 +19,8 @@ function isValidMode(mode: string | IDrawMode) { } export function runMiddlewares(core: Core, store: Store) { - const { enableRuler, enableScale, enableScroll, enableSelect, enableTextEdit, enableDrag, enableInfo } = store.getSnapshot(); + const { enableRuler, enableScale, enableScroll, enableSelect, enableTextEdit, enableDrag, enableInfo } = + store.getSnapshot(); const styles = store.get('middlewareStyles'); if (enableScroll === true) { core.use(MiddlewareScroller, styles?.scroller); diff --git a/packages/renderer/src/calculator.ts b/packages/renderer/src/calculator.ts index 8015c10..68c4b12 100644 --- a/packages/renderer/src/calculator.ts +++ b/packages/renderer/src/calculator.ts @@ -9,7 +9,7 @@ import type { ViewSizeInfo, VirtualFlatStorage, ViewRectInfo, - ModifyOptions, + ModifyInfo, VirtualFlatItem } from '@idraw/types'; import { @@ -25,6 +25,7 @@ import { } from '@idraw/util'; import { sortElementsViewVisiableInfoMap, updateVirtualFlatItemMapStatus } from './view-visible'; import { calcVirtualFlatDetail } from './virtual-flat'; +import { calcVirtualTextDetail } from './virtual-flat/text'; export class Calculator implements ViewCalculator { #opts: ViewCalculatorOptions; @@ -169,20 +170,35 @@ export class Calculator implements ViewCalculator { return viewRectInfo; } + modifyText(element: Element<'text'>): void { + const virtualFlatItemMap = this.#store.get('virtualFlatItemMap'); + const flatItem = virtualFlatItemMap[element.uuid]; + if (element && element.type === 'text') { + const newVirtualFlatItem: VirtualFlatItem = { + ...flatItem, + ...calcVirtualTextDetail(element, { + tempContext: this.#opts.tempContext + }) + }; + virtualFlatItemMap[element.uuid] = newVirtualFlatItem; + this.#store.set('virtualFlatItemMap', virtualFlatItemMap); + } + } + modifyVirtualFlatItemMap( data: Data, opts: { - modifyOptions: ModifyOptions; // TODO + modifyInfo: ModifyInfo; // TODO viewScaleInfo: ViewScaleInfo; viewSizeInfo: ViewSizeInfo; } ): void { - const { modifyOptions, viewScaleInfo, viewSizeInfo } = opts; - const { type, content } = modifyOptions; + const { modifyInfo, viewScaleInfo, viewSizeInfo } = opts; + const { type, content } = modifyInfo; const list = data.elements; const virtualFlatItemMap = this.#store.get('virtualFlatItemMap'); if (type === 'deleteElement') { - const { element } = content as ModifyOptions<'deleteElement'>['content']; + const { element } = content as ModifyInfo<'deleteElement'>['content']; const uuids: string[] = []; const _walk = (e: Element) => { uuids.push(e.uuid); @@ -203,7 +219,7 @@ export class Calculator implements ViewCalculator { // this.resetVirtualFlatItemMap(data, { viewScaleInfo, viewSizeInfo }); // } else if (type === 'addElement' || type === 'updateElement') { - const { position } = content as ModifyOptions<'addElement'>['content']; + const { position } = content as ModifyInfo<'addElement'>['content']; const element = findElementFromListByPosition(position, data.elements); const groupQueue = getGroupQueueByElementPosition(list, position); if (element) { diff --git a/packages/renderer/src/draw/box.ts b/packages/renderer/src/draw/box.ts index 01bda23..5d4647d 100644 --- a/packages/renderer/src/draw/box.ts +++ b/packages/renderer/src/draw/box.ts @@ -112,7 +112,7 @@ function drawClipPath( ctx.scale(totalScale * scaleW, totalScale * scaleH); const pathStr = generateSVGPath(clipPath.commands || []); const path2d = new Path2D(pathStr); - ctx.clip(path2d); + ctx.clip(path2d, 'nonzero'); ctx.translate(0 - (internalX as number), 0 - (internalY as number)); ctx.setTransform(1, 0, 0, 1, 0, 0); @@ -236,7 +236,7 @@ export function drawBoxBackground( } } } - ctx.fill(); + ctx.fill('nonzero'); if (transform && transform.length > 0) { ctx.setTransform(1, 0, 0, 1, 0, 0); @@ -322,7 +322,7 @@ export function drawBoxBorder( // ctx.quadraticCurveTo(op3.x, op3.y, op3e.x, op3e.y); // ctx.lineTo(op0s.x, op0s.y); // ctx.closePath(); - // ctx.fill(); + // ctx.fill('nonzero'); // ctx.fillStyle = '#000000'; // ctx.globalCompositeOperation = 'destination-out'; @@ -337,7 +337,7 @@ export function drawBoxBorder( // ctx.quadraticCurveTo(ip3.x, ip3.y, ip3e.x, ip3e.y); // ctx.lineTo(ip0s.x, ip0s.y); // ctx.closePath(); - // ctx.fill(); + // ctx.fill('nonzero'); // ctx.globalCompositeOperation = 'source-over'; // return; // // TODO diff --git a/packages/renderer/src/draw/circle.ts b/packages/renderer/src/draw/circle.ts index 90239f4..88df609 100644 --- a/packages/renderer/src/draw/circle.ts +++ b/packages/renderer/src/draw/circle.ts @@ -59,7 +59,7 @@ export function drawCircle(ctx: ViewContext2D, elem: Element<'circle'>, opts: Re ctx.fillStyle = fillStyle; ctx.circle(centerX, centerY, radiusA, radiusB, 0, 0, 2 * Math.PI); ctx.closePath(); - ctx.fill(); + ctx.fill('nonzero'); ctx.globalAlpha = parentOpacity; // draw border diff --git a/packages/renderer/src/draw/global.ts b/packages/renderer/src/draw/global.ts index c009ec8..3294104 100644 --- a/packages/renderer/src/draw/global.ts +++ b/packages/renderer/src/draw/global.ts @@ -1,6 +1,10 @@ -import type { RendererDrawElementOptions, ViewContext2D, ElementGlobalDetail } from '@idraw/types'; +import type { RendererDrawElementOptions, ViewContext2D, ElementGlobal } from '@idraw/types'; -export function drawGlobalBackground(ctx: ViewContext2D, global: ElementGlobalDetail | undefined, opts: RendererDrawElementOptions) { +export function drawGlobalBackground( + ctx: ViewContext2D, + global: ElementGlobal | undefined, + opts: RendererDrawElementOptions +) { if (typeof global?.background === 'string') { const { viewSizeInfo } = opts; const { width, height } = viewSizeInfo; diff --git a/packages/renderer/src/draw/group.ts b/packages/renderer/src/draw/group.ts index bac066e..65a6cd1 100644 --- a/packages/renderer/src/draw/group.ts +++ b/packages/renderer/src/draw/group.ts @@ -110,8 +110,8 @@ export function drawGroup(ctx: ViewContext2D, elem: Element<'group'>, opts: Rend ctx.arcTo(x, y + h, x, y, radiusList[3]); ctx.arcTo(x, y, x + w, y, radiusList[0]); ctx.closePath(); - ctx.fill(); - ctx.clip(); + ctx.fill('nonzero'); + ctx.clip('nonzero'); } if (Array.isArray(elem.detail.children)) { diff --git a/packages/renderer/src/draw/image.ts b/packages/renderer/src/draw/image.ts index 533c61c..2a117c8 100644 --- a/packages/renderer/src/draw/image.ts +++ b/packages/renderer/src/draw/image.ts @@ -43,8 +43,8 @@ export function drawImage(ctx: ViewContext2D, elem: Element<'image'>, opts: Rend ctx.arcTo(x, y + h, x, y, radiusList[3]); ctx.arcTo(x, y, x + w, y, radiusList[0]); ctx.closePath(); - ctx.fill(); - ctx.clip(); + ctx.fill('nonzero'); + ctx.clip('nonzero'); if (scaleMode && originH && originW) { let sx = 0; diff --git a/packages/renderer/src/draw/layout.ts b/packages/renderer/src/draw/layout.ts index fbe7bb4..73d763c 100644 --- a/packages/renderer/src/draw/layout.ts +++ b/packages/renderer/src/draw/layout.ts @@ -41,8 +41,8 @@ export function drawLayout( ctx.arcTo(x, y + h, x, y, radiusList[3]); ctx.arcTo(x, y, x + w, y, radiusList[0]); ctx.closePath(); - ctx.fill(); - ctx.clip(); + ctx.fill('nonzero'); + ctx.clip('nonzero'); } renderContent(ctx); diff --git a/packages/renderer/src/draw/path.ts b/packages/renderer/src/draw/path.ts index b7e43fe..ef11a93 100644 --- a/packages/renderer/src/draw/path.ts +++ b/packages/renderer/src/draw/path.ts @@ -1,4 +1,10 @@ -import type { Element, RendererDrawElementOptions, ViewContext2D, LinearGradientColor, RadialGradientColor } from '@idraw/types'; +import type { + Element, + RendererDrawElementOptions, + ViewContext2D, + LinearGradientColor, + RadialGradientColor +} from '@idraw/types'; import { rotateElement, generateSVGPath, calcViewElementSize } from '@idraw/util'; import { drawBox, drawBoxShadow } from './box'; @@ -23,7 +29,11 @@ export function drawPath(ctx: ViewContext2D, elem: Element<'path'>, opts: Render let boxOriginElem = { ...elem }; boxOriginElem.detail = restDetail; - if (detail.fill && detail.fill !== 'string' && (detail.fill as LinearGradientColor | RadialGradientColor)?.type?.includes('gradient')) { + if ( + detail.fill && + detail.fill !== 'string' && + (detail.fill as LinearGradientColor | RadialGradientColor)?.type?.includes('gradient') + ) { boxViewElem = { ...viewElem, ...{ @@ -65,7 +75,7 @@ export function drawPath(ctx: ViewContext2D, elem: Element<'path'>, opts: Render // ctx.lineTo(viewOriginX + w, viewOriginY + h); // ctx.lineTo(viewOriginX, viewOriginY + h); // ctx.closePath(); - // ctx.clip(); + // ctx.clip('nonzero'); ctx.scale((scaleNum * scaleW) / viewScaleInfo.scale, (scaleNum * scaleH) / viewScaleInfo.scale); const pathStr = generateSVGPath(detail.commands || []); const path2d = new Path2D(pathStr); @@ -79,7 +89,7 @@ export function drawPath(ctx: ViewContext2D, elem: Element<'path'>, opts: Render } if (detail.fill) { - ctx.fill(path2d, fillRule as CanvasFillRule); + ctx.fill(path2d, (fillRule as CanvasFillRule) || 'nonzero'); } if (detail.stroke && detail.strokeWidth !== 0) { diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index 3b9e0b8..f3d2f52 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -70,6 +70,14 @@ export class Renderer extends EventEmitter implements BoardRen // ...opts // }); // } + + if (opts.forceDrawAll === true) { + this.#calculator.resetVirtualFlatItemMap(data, { + viewScaleInfo: opts.viewScaleInfo, + viewSizeInfo: opts.viewSizeInfo + }); + } + const drawOpts = { loader, calculator, diff --git a/packages/renderer/src/virtual-flat/text.ts b/packages/renderer/src/virtual-flat/text.ts index 06492fb..d144158 100644 --- a/packages/renderer/src/virtual-flat/text.ts +++ b/packages/renderer/src/virtual-flat/text.ts @@ -131,9 +131,9 @@ export function calcVirtualTextDetail(elem: Element<'text'>, opts: CalcVirtualDe eachLineStartY = (fontHeight - fontSize) / 2; } if (lines.length * fontHeight < h) { - if (elem.detail.verticalAlign === 'top') { + if (detail.verticalAlign === 'top') { startY = 0; - } else if (elem.detail.verticalAlign === 'bottom') { + } else if (detail.verticalAlign === 'bottom') { startY += h - lines.length * fontHeight; } else { // middle and default diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f358e57..9d3188b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -18,4 +18,5 @@ export * from './lib/html'; export * from './lib/svg-path'; export * from './lib/config'; export * from './lib/modify'; +export * from './lib/modify-info'; export * from './lib/virtual-flat'; diff --git a/packages/types/src/lib/board.ts b/packages/types/src/lib/board.ts index 966cd1d..2523821 100644 --- a/packages/types/src/lib/board.ts +++ b/packages/types/src/lib/board.ts @@ -143,22 +143,6 @@ export interface BoardViewer extends UtilEventEmitter { scroll(opts: { moveX?: number; moveY?: number; ignoreUpdateVisibleStatus?: boolean }): ViewScaleInfo; resize(viewSize: Partial, opts?: { ignoreUpdateVisibleStatus?: boolean }): ViewSizeInfo; updateViewScaleInfo(opts: { scale: number; offsetX: number; offsetY: number }): ViewScaleInfo; - - // resetVirtualFlatItemMap( - // data: Data, - // opts: { - // viewScaleInfo: ViewScaleInfo; - // viewSizeInfo: ViewSizeInfo; - // } - // ): void; - // modifyVirtualFlatItemMap( - // data: Data, - // opts: { - // modifyOptions: ModifyOptions; - // viewScaleInfo: ViewScaleInfo; - // viewSizeInfo: ViewSizeInfo; - // } - // ): void; } export interface BoardRenderer extends UtilEventEmitter { diff --git a/packages/types/src/lib/core.ts b/packages/types/src/lib/core.ts index 5a03b5a..62fde69 100644 --- a/packages/types/src/lib/core.ts +++ b/packages/types/src/lib/core.ts @@ -2,6 +2,7 @@ import type { Element, ElementSize, ElementType, ElementPosition } from './eleme import type { ViewScaleInfo } from './view'; import type { Data } from './data'; import type { BoardBaseEventMap } from './board'; +import type { ModifyType, ModifyRecord } from './modify'; export interface CoreOptions { width: number; @@ -32,24 +33,12 @@ export interface CoreEventCursor { // uuids: string[]; // positions?: Array>; // } -export interface CoreEventChange { +export interface CoreEventChange { data: Data; - type: - | 'updateElement' - | 'deleteElement' - | 'moveElement' - | 'addElement' - | 'dragElement' - | 'resizeElement' - | 'setData' - | 'undo' - | 'redo' - | 'dragLayout' - | 'updateLayout' - | 'updateElementName' - | 'other'; + type: T | 'setData' | 'other' | string; selectedElements?: Element[] | null; hoverElement?: Element | null; + modifyRecord?: ModifyRecord; } export interface CoreEventScale { scale: number; diff --git a/packages/types/src/lib/data.ts b/packages/types/src/lib/data.ts index ef87813..77b6a33 100644 --- a/packages/types/src/lib/data.ts +++ b/packages/types/src/lib/data.ts @@ -19,7 +19,7 @@ export type DataLayout = Pick & { }; }; -export interface DataGlobalDetail { +export interface DataGlobal { background?: string; } @@ -28,7 +28,7 @@ export type Data = Record> = { elements: Element[]; assets?: ElementAssets; layout?: DataLayout; - global?: DataGlobalDetail; + global?: DataGlobal; }; export type Matrix = [ diff --git a/packages/types/src/lib/element.ts b/packages/types/src/lib/element.ts index 7fa6179..721b221 100644 --- a/packages/types/src/lib/element.ts +++ b/packages/types/src/lib/element.ts @@ -178,19 +178,19 @@ export interface ElementOperations { lastModified?: number; } -export interface ElementGlobalDetail { +export interface ElementGlobal { background?: string; } export interface Element = Record> extends ElementSize { uuid: string; - name?: string; + name?: string | null; type: T; detail: ElementDetailMap[T]; operations?: ElementOperations; extends?: E; - // global?: ElementGlobalDetail; + // global?: ElementGlobal; } export type Elements = Element[]; diff --git a/packages/types/src/lib/idraw.ts b/packages/types/src/lib/idraw.ts index 85829d6..e12331d 100644 --- a/packages/types/src/lib/idraw.ts +++ b/packages/types/src/lib/idraw.ts @@ -1,5 +1,12 @@ +import { ModifyRecord } from './modify'; import type { CoreOptions } from './core'; -import type { MiddlewareSelectorStyle, MiddlewareInfoStyle, MiddlewareRulerStyle, MiddlewareScrollerStyle, MiddlewareLayoutSelectorStyle } from './middleware'; +import type { + MiddlewareSelectorStyle, + MiddlewareInfoStyle, + MiddlewareRulerStyle, + MiddlewareScrollerStyle, + MiddlewareLayoutSelectorStyle +} from './middleware'; export type IDrawMode = 'select' | 'drag' | 'readOnly'; @@ -14,10 +21,22 @@ export interface IDrawSettings { scroller?: Partial; layoutSelector?: Partial; }; + history?: boolean; } export type IDrawOptions = CoreOptions & IDrawSettings; +export type IDrawHistory = { + undo: () => void; + redo: () => void; + canUndo: () => void; + canRedo: () => void; + destroy: () => void; + clear: () => void; + __getDoRecords: () => ModifyRecord[]; + __getUndoRecords: () => ModifyRecord[]; +}; + export interface IDrawStorage { mode: IDrawMode; enableRuler: boolean; diff --git a/packages/types/src/lib/middleware.ts b/packages/types/src/lib/middleware.ts index b434841..ed7025f 100644 --- a/packages/types/src/lib/middleware.ts +++ b/packages/types/src/lib/middleware.ts @@ -1,6 +1,10 @@ -import type { BoardMiddlewareObject, BoardMiddleware } from './board'; +import type { BoardExtendEventMap, BoardMiddlewareObject, BoardMiddleware } from './board'; -export type Middleware = BoardMiddleware; +export type Middleware< + S extends Record = any, + E extends BoardExtendEventMap = BoardExtendEventMap, + C extends any = undefined +> = BoardMiddleware; export type MiddlewareObject = BoardMiddlewareObject; diff --git a/packages/types/src/lib/modify-info.ts b/packages/types/src/lib/modify-info.ts new file mode 100644 index 0000000..4017761 --- /dev/null +++ b/packages/types/src/lib/modify-info.ts @@ -0,0 +1,22 @@ +import type { Element, ElementPosition } from './element'; +import type { RecursivePartial } from './util'; + +type ModifyInfoType = 'updateElement' | 'addElement' | 'deleteElement' | 'moveElement'; + +type ModifiedElement = Omit, 'uuid'>; + +interface ModifyInfoContentMap { + updateElement: { + position: ElementPosition; + beforeModifiedElement: ModifiedElement; + afterModifiedElement: ModifiedElement; + }; + addElement: { position: ElementPosition; element: Element }; + deleteElement: { position: ElementPosition; element: Element }; + moveElement: { from: ElementPosition; to: ElementPosition }; +} + +export interface ModifyInfo { + type: T; + content: ModifyInfoContentMap[T]; +} diff --git a/packages/types/src/lib/modify.ts b/packages/types/src/lib/modify.ts index 2a0ff85..fb83a15 100644 --- a/packages/types/src/lib/modify.ts +++ b/packages/types/src/lib/modify.ts @@ -1,43 +1,196 @@ -import type { Element, ElementPosition } from './element'; -import type { RecursivePartial } from './util'; +import type { Element, ElementPosition, ElementOperations } from './element'; +import type { DataGlobal } from './data'; -export type ModifyType = 'updateElement' | 'addElement' | 'deleteElement' | 'moveElement'; +export type ModifyMethod = + | 'updateElement' + | 'modifyElement' + | 'deleteElement' + | 'moveElement' + | 'addElement' + | 'dragElement' + | 'dragElements' + | 'modifyElements' + | 'dragLayout' + | 'modifyLayout' + | 'modifyGlobal'; -export type ModifiedElement = Omit, 'uuid'>; +export type ModifyType = ModifyMethod | 'undo' | 'redo'; -export type ModifiedTargetElement = ModifiedElement & { uuid: string }; +/** + * FlattenElement + For example: + { + "x": 0, + "y": 0, + "w": 0, + "h": 0, + "detail.color": "#FFFFFF", + "detail.borderWidth[0]": 10, + "detail.borderWidth[1]": 20, + "detail.borderWidth[2]": 30, + "detail.borderWidth[3]": 40, + } + */ +export type FlattenElement = Record; + +/** + * ModifiedLayoutDetail + For example: + { + "x": 0, + "y": 0, + "w": 0, + "h": 0, + "detail.color": "#FFFFFF", + "detail.borderWidth[0]": 10, + "detail.borderWidth[1]": 20, + "detail.borderWidth[2]": 30, + "detail.borderWidth[3]": 40, + } + */ +export type FlattenLayout = Record; + +/** + * FlattenGlobal + For example: + { + "background": "#FFFFFF", + } + */ +export type FlattenGlobal = Partial; + +export type ModifiedElementOperations = Partial; export interface ModifyContentMap { - updateElement: { position: ElementPosition; beforeModifiedElement: ModifiedElement; afterModifiedElement: ModifiedElement }; - addElement: { position: ElementPosition; element: Element }; - deleteElement: { position: ElementPosition; element: Element }; - moveElement: { from: ElementPosition; to: ElementPosition }; -} - -export interface ModifyOptions { - type: T; - content: ModifyContentMap[T]; + updateElement: { + method: 'updateElement'; + uuid: string; + before: FlattenElement | null; + after: FlattenElement | null; + }; + modifyElement: { + method: 'modifyElement'; + uuid: string; + before: FlattenElement | null; + after: FlattenElement | null; + }; + addElement: { + method: 'addElement'; + uuid: string; + position: ElementPosition; + element: Element; + }; + deleteElement: { + method: 'deleteElement'; + uuid: string; + position: ElementPosition; + element: Element | null; + }; + moveElement: { + method: 'moveElement'; + uuid: string; + from: ElementPosition; + to: ElementPosition; + }; + dragElement: { + method: 'modifyElement'; + uuid: string; + before: FlattenElement | null; + after: FlattenElement | null; + }; + dragElements: { + method: 'modifyElements'; + before: (FlattenLayout & { uuid: string })[]; + after: (FlattenLayout & { uuid: string })[]; + }; + dragLayout: { + method: 'modifyElement'; + before: FlattenLayout; + after: FlattenLayout; + }; + modifyLayout: { + method: 'modifyLayout'; + before: FlattenLayout | null; + after: FlattenLayout | null; + }; + modifyElements: { + method: 'modifyElements'; + before: (FlattenLayout & { uuid: string })[]; + after: (FlattenLayout & { uuid: string })[]; + }; + modifyGlobal: { + method: 'modifyGlobal'; + before: FlattenGlobal | null; + after: FlattenGlobal | null; + }; } export interface ModifyRecordMap { updateElement: { type: 'updateElement'; time: number; - } & Required; + content: ModifyContentMap['updateElement']; + }; + modifyElement: { + type: 'modifyElement'; + time: number; + content: ModifyContentMap['modifyElement']; + }; addElement: { type: 'addElement'; time: number; - } & Required; + content: ModifyContentMap['addElement']; + }; deleteElement: { type: 'deleteElement'; time: number; - } & Required; + content: ModifyContentMap['deleteElement']; + }; moveElement: { type: 'moveElement'; time: number; - afterModifiedFrom: ElementPosition; - afterModifiedTo: ElementPosition; - } & Required; + content: ModifyContentMap['moveElement']; + }; + dragElement: { + type: 'dragElement'; + time: number; + content: ModifyContentMap['modifyElement']; + }; + dragElements: { + type: 'dragElements'; + time: number; + content: ModifyContentMap['modifyElements']; + }; + modifyElements: { + type: 'modifyElements'; + time: number; + content: ModifyContentMap['modifyElements']; + }; + dragLayout: { + type: 'dragLayout'; + time: number; + content: ModifyContentMap['dragLayout']; + }; + modifyLayout: { + type: 'modifyLayout'; + time: number; + content: ModifyContentMap['modifyLayout']; + }; + modifyGlobal: { + type: 'modifyGlobal'; + time: number; + content: ModifyContentMap['modifyGlobal']; + }; + undo: { + type: 'undo'; + time: number; + content: ModifyContentMap[ModifyMethod]; + }; + redo: { + type: 'redo'; + time: number; + content: ModifyContentMap[ModifyMethod]; + }; } export type ModifyRecord = ModifyRecordMap[T]; diff --git a/packages/types/src/lib/view.ts b/packages/types/src/lib/view.ts index 7694d41..74fac5c 100644 --- a/packages/types/src/lib/view.ts +++ b/packages/types/src/lib/view.ts @@ -2,7 +2,7 @@ import type { Element, ElementType } from './element'; import type { Point, PointSize } from './point'; import type { Data } from './data'; import type { ViewContext2D } from './context2d'; -import type { ModifyOptions } from './modify'; +import type { ModifyInfo } from './modify-info'; import { VirtualFlatItem } from './virtual-flat'; // import type { BoxInfo } from './box'; @@ -72,7 +72,7 @@ export interface ViewCalculator { modifyVirtualFlatItemMap( data: Data, opts: { - modifyOptions: ModifyOptions; + modifyInfo: ModifyInfo; viewScaleInfo: ViewScaleInfo; viewSizeInfo: ViewSizeInfo; } @@ -80,6 +80,7 @@ export interface ViewCalculator { toGridNum(num: number, opts?: { ignore?: boolean }): number; getVirtualFlatItem: (uuid: string) => VirtualFlatItem | null; + modifyText(element: Element<'text'>): void; } export type ViewRectVertexes = [PointSize, PointSize, PointSize, PointSize]; diff --git a/packages/util/__tests__/lib/color.test.ts b/packages/util/__tests__/lib/color.test.ts index 3472723..b6d5e69 100644 --- a/packages/util/__tests__/lib/color.test.ts +++ b/packages/util/__tests__/lib/color.test.ts @@ -1,9 +1,4 @@ -import { - toColorHexNum, - toColorHexStr, - isColorStr -} from '../../src/lib/color'; - +import { toColorHexNum, toColorHexStr, isColorStr } from '@idraw/util'; describe('@idraw/util: lib/color', () => { const hex = '#f0f0f0'; @@ -23,6 +18,4 @@ describe('@idraw/util: lib/color', () => { const result = isColorStr(hex); expect(result).toStrictEqual(true); }); - }); - diff --git a/packages/util/__tests__/lib/context.test.ts b/packages/util/__tests__/lib/context.test.ts index 5db0869..446b452 100644 --- a/packages/util/__tests__/lib/context.test.ts +++ b/packages/util/__tests__/lib/context.test.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { Context2D } from './../../src/lib/context2d'; -import { deepClone } from './../../src/index'; +import { Context2D, deepClone } from '@idraw/util'; import { getData } from './data'; describe('@idraw/board: src/lib/context', () => { diff --git a/packages/util/__tests__/lib/data.test.ts b/packages/util/__tests__/lib/data.test.ts index 238694e..8eca2b7 100644 --- a/packages/util/__tests__/lib/data.test.ts +++ b/packages/util/__tests__/lib/data.test.ts @@ -1,128 +1,92 @@ -import { deepClone, filterCompactData } from '../../src/lib/data'; -import { imageBase64, html, svg } from '../_assets/base'; +import { deepClone, filterCompactData } from '@idraw/util'; import type { Data } from '@idraw/types'; +import { imageBase64, html, svg } from '../_assets/base'; -describe('@idraw/util: lib/data', () => { - const json = { - num: 123, - str: 'abc', - bool: true, - arr: [ - { - num: 1, - str: 'a', - bool: false - }, - { - num: 2, - str: 'b', - bool: false +const originData: Data = { + elements: [ + { + uuid: 'b37213ce-d711-cbb3-51ac-d8081c19f127', + type: 'image', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + src: imageBase64 } - ], - json: { - num: 10, - str: 'aaaa', - bool: false, - json: { - num: 11, - str: 'bbbb', - bool: false + }, + { + uuid: '39308517-e10f-76df-43a9-50ed7295e61e', + type: 'svg', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + svg: svg + } + }, + { + uuid: 'ef934ab7-a32e-040c-9ac0-ed193405e6e4', + type: 'html', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + html: html + } + }, + { + uuid: '063e3a80-1ede-7912-f919-975e34a9bd01', + type: 'group', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + children: [ + { + uuid: 'e0889472-1f16-d6cd-3c7a-4b827d52279d', + type: 'image', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + src: imageBase64 + } + }, + { + uuid: 'b60e64e8-833e-e112-d7eb-1ab6e7d6870c', + type: 'svg', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + svg: svg + } + }, + { + uuid: '61f2a61e-cdd5-ae36-983f-686ba8e35973', + type: 'html', + x: 0, + y: 0, + w: 100, + h: 100, + detail: { + html: html + } + } + ] } } - }; - - const json2 = deepClone(json); - json2.json.json.num *= 2; - - test('deepClone', async () => { - const result = deepClone(json); - result.json.json.num *= 2; - expect(result).toStrictEqual(json2); - }); + ] +}; +describe('@idraw/util: data ', () => { test('filterCompactData', () => { - const originData: Data = { - elements: [ - { - uuid: 'b37213ce-d711-cbb3-51ac-d8081c19f127', - type: 'image', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - src: imageBase64 - } - }, - { - uuid: '39308517-e10f-76df-43a9-50ed7295e61e', - type: 'svg', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - svg: svg - } - }, - { - uuid: 'ef934ab7-a32e-040c-9ac0-ed193405e6e4', - type: 'html', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - html: html - } - }, - { - uuid: '063e3a80-1ede-7912-f919-975e34a9bd01', - type: 'group', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - children: [ - { - uuid: 'e0889472-1f16-d6cd-3c7a-4b827d52279d', - type: 'image', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - src: imageBase64 - } - }, - { - uuid: 'b60e64e8-833e-e112-d7eb-1ab6e7d6870c', - type: 'svg', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - svg: svg - } - }, - { - uuid: '61f2a61e-cdd5-ae36-983f-686ba8e35973', - type: 'html', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - html: html - } - } - ] - } - } - ] - }; const data = deepClone(originData); const compactData = filterCompactData(data); @@ -135,7 +99,7 @@ describe('@idraw/util: lib/data', () => { y: 0, w: 100, h: 100, - detail: { src: '@assets/1919ff71-124e-2766-23bb-9a251bf3241c' } + detail: { src: '@assets/1k7sknuo56gr0h9ug9hs5g5xxgzeee07' } }, { uuid: '39308517-e10f-76df-43a9-50ed7295e61e', @@ -144,7 +108,7 @@ describe('@idraw/util: lib/data', () => { y: 0, w: 100, h: 100, - detail: { svg: '@assets/b9b92016-5290-54e8-9668-807574952823' } + detail: { svg: '@assets/36jxqyevkyph8yveb6zalsgxj5vc8not' } }, { uuid: 'ef934ab7-a32e-040c-9ac0-ed193405e6e4', @@ -153,7 +117,7 @@ describe('@idraw/util: lib/data', () => { y: 0, w: 100, h: 100, - detail: { html: '@assets/34017fa0-2d48-2506-3464-238f34642b5c' } + detail: { html: '@assets/cevdw4d1r85ynahctsjex89y03yev87a' } }, { uuid: '063e3a80-1ede-7912-f919-975e34a9bd01', @@ -171,7 +135,7 @@ describe('@idraw/util: lib/data', () => { y: 0, w: 100, h: 100, - detail: { src: '@assets/1919ff71-124e-2766-23bb-9a251bf3241c' } + detail: { src: '@assets/1k7sknuo56gr0h9ug9hs5g5xxgzeee07' } }, { uuid: 'b60e64e8-833e-e112-d7eb-1ab6e7d6870c', @@ -180,7 +144,7 @@ describe('@idraw/util: lib/data', () => { y: 0, w: 100, h: 100, - detail: { svg: '@assets/b9b92016-5290-54e8-9668-807574952823' } + detail: { svg: '@assets/36jxqyevkyph8yveb6zalsgxj5vc8not' } }, { uuid: '61f2a61e-cdd5-ae36-983f-686ba8e35973', @@ -189,22 +153,22 @@ describe('@idraw/util: lib/data', () => { y: 0, w: 100, h: 100, - detail: { html: '@assets/34017fa0-2d48-2506-3464-238f34642b5c' } + detail: { html: '@assets/cevdw4d1r85ynahctsjex89y03yev87a' } } ] } } ], assets: { - '@assets/1919ff71-124e-2766-23bb-9a251bf3241c': { + '@assets/1k7sknuo56gr0h9ug9hs5g5xxgzeee07': { type: 'image', value: imageBase64 }, - '@assets/b9b92016-5290-54e8-9668-807574952823': { + '@assets/36jxqyevkyph8yveb6zalsgxj5vc8not': { type: 'svg', value: svg }, - '@assets/34017fa0-2d48-2506-3464-238f34642b5c': { + '@assets/cevdw4d1r85ynahctsjex89y03yev87a': { type: 'html', value: html } diff --git a/packages/util/__tests__/lib/file.test.ts b/packages/util/__tests__/lib/file.test.ts index c4881d9..d44f73c 100644 --- a/packages/util/__tests__/lib/file.test.ts +++ b/packages/util/__tests__/lib/file.test.ts @@ -1,28 +1,21 @@ -import { - downloadImageFromCanvas -} from '../../src/lib/file'; - +import { downloadImageFromCanvas } from '@idraw/util'; describe('@idraw/util: lib/file', () => { - const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; const opts = { width: 600, - height: 400, - } + height: 400 + }; ctx.clearRect(0, 0, opts.width, opts.height); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, opts.width / 2, opts.height / 2); - test('downloadImageFromCanvas', async () => { + test('downloadImageFromCanvas', async () => { downloadImageFromCanvas(canvas, { - filename: 'hello', - type: 'image/png', - }); + fileName: 'hello', + type: 'image/png' + }); expect(canvas).toMatchSnapshot(); }); - - }); - diff --git a/packages/util/__tests__/lib/flat-object.test.ts b/packages/util/__tests__/lib/flat-object.test.ts new file mode 100644 index 0000000..9d5d5b8 --- /dev/null +++ b/packages/util/__tests__/lib/flat-object.test.ts @@ -0,0 +1,42 @@ +import { flatObject } from '@idraw/util'; + +describe('flatObject', () => { + test('Basic object flattening', () => { + expect(flatObject({ a: { b: { c: 1 } } })).toEqual({ 'a.b.c': 1 }); + }); + + test('Array flattening', () => { + expect(flatObject({ a: { b: ['c', 'd'] } })).toEqual({ + 'a.b[0]': 'c', + 'a.b[1]': 'd' + }); + }); + + test('Mixed types handling', () => { + const date = new Date(); + expect( + flatObject({ + a: { + b: 1, + c: [{ d: date }, null] + } + }) + ).toStrictEqual({ + 'a.b': 1, + 'a.c[0].d': date, + 'a.c[1]': null + }); + }); + + test('Primitive values handling', () => { + expect(flatObject(42 as any)).toEqual({ '': 42 }); + expect(flatObject('text' as any)).toEqual({ '': 'text' }); + expect(flatObject(null as any)).toEqual({ '': null }); + }); + + test('Circular reference', () => { + const obj: Record = { a: 1 }; + obj.self = obj; + expect(() => flatObject(obj)).toThrow(); + }); +}); diff --git a/packages/util/__tests__/lib/get-set.test.ts b/packages/util/__tests__/lib/get-set.test.ts new file mode 100644 index 0000000..05bfab8 --- /dev/null +++ b/packages/util/__tests__/lib/get-set.test.ts @@ -0,0 +1,114 @@ +// get-set.test.ts +import { get, set, toPath } from '@idraw/util'; + +describe('toPath()', () => { + test('should handle string path with dots and brackets', () => { + expect(toPath('a[0].b.c')).toEqual(['a', '0', 'b', 'c']); + expect(toPath('x.y.z')).toEqual(['x', 'y', 'z']); + expect(toPath('arr[3].prop')).toEqual(['arr', '3', 'prop']); + }); + + test('should handle array path', () => { + expect(toPath(['a', '0', 'b'])).toEqual(['a', '0', 'b']); + }); + + test('should handle empty path', () => { + expect(toPath('')).toEqual([]); + expect(toPath([])).toEqual([]); + }); +}); + +describe('get()', () => { + const testObj = { + a: { + b: { + c: 42, + d: [null, { e: 'value' }] + } + }, + x: null, + y: undefined + }; + + test('should retrieve nested values', () => { + expect(get(testObj, 'a.b.c')).toBe(42); + expect(get(testObj, ['a', 'b', 'd', '1', 'e'])).toBe('value'); + expect(get(testObj, 'a.b.d[0]')).toBe(null); + }); + + test('should handle invalid paths', () => { + expect(get(testObj, 'a.b.z')).toBeUndefined(); + expect(get(testObj, 'x.y.z')).toBeUndefined(); + expect(get(testObj, 'y.z')).toBeUndefined(); + }); + + test('should return defaultValue for missing paths', () => { + expect(get(testObj, 'a.missing', 'default')).toBe('default'); + expect(get({}, 'any.path', 123)).toBe(123); + }); + + test('should handle edge cases', () => { + expect(get(null, 'any.path', 'default')).toBe('default'); + expect(get(undefined, 'any.path', 'default')).toBe('default'); + expect(get({ a: 1 }, '')).toBeUndefined(); + }); +}); + +describe('set()', () => { + test('should set nested values in existing structure', () => { + const obj = { a: { b: { c: 1 } } }; + set(obj, 'a.b.c', 2); + expect(obj.a.b.c).toBe(2); + }); + + test('should create missing path structures', () => { + const obj = {}; + set(obj, 'x[0].y.z', 'value'); + expect(obj).toEqual({ + x: [ + { + y: { + z: 'value' + } + } + ] + }); + }); + + test('should handle array indexes', () => { + const obj = { arr: [] }; + set(obj, 'arr[2].name', 'third'); + expect(obj.arr).toEqual([, , { name: 'third' }]); // eslint-disable-line no-sparse-arrays + }); + + test('should overwrite existing primitives', () => { + const obj = { a: 1 }; + set(obj, 'a.b.c', 'new-value'); + expect(obj).toEqual({ a: { b: { c: 'new-value' } } }); + }); + + test('should handle empty path', () => { + const obj = { a: 1 }; + set(obj, '', 42); + expect(obj).toEqual({ a: 1 }); + }); + + test('should handle null/undefined objects', () => { + const obj = null; + set(obj, 'a.b.c', 'value'); + expect(obj).toBeNull(); + }); + + test('should create arrays when next key is numeric', () => { + const obj: any = {}; + set(obj, 'arr[1].prop', 'test'); + expect(Array.isArray(obj.arr)).toBe(true); + expect(obj.arr[1].prop).toBe('test'); + }); + + test('should handle intermediate null values', () => { + const obj: any = { a: null }; + set(obj, 'a.b.c', 'value'); + expect(obj.a.b.c).toBe('value'); + }); +}); diff --git a/packages/util/__tests__/lib/istype.test.ts b/packages/util/__tests__/lib/istype.test.ts index ae136b8..a35ac57 100644 --- a/packages/util/__tests__/lib/istype.test.ts +++ b/packages/util/__tests__/lib/istype.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import { istype } from '../../src/lib/istype'; +import { istype } from '@idraw/util'; describe('@idraw/util: lib/istype', () => { const _num = 123; diff --git a/packages/util/__tests__/lib/loader.test.ts b/packages/util/__tests__/lib/loader.test.ts index e52fdac..e38f2e5 100644 --- a/packages/util/__tests__/lib/loader.test.ts +++ b/packages/util/__tests__/lib/loader.test.ts @@ -1,6 +1,6 @@ import '../../../../__tests__/polyfill/image'; -import { loadHTML, loadImage, loadSVG } from '../../src/lib/load'; -import { parseHTMLToDataURL, parseSVGToDataURL } from '../../src/lib/parser'; +import { loadHTML, loadImage, loadSVG } from '@idraw/util'; +import { parseHTMLToDataURL, parseSVGToDataURL } from '../../src/view/parser'; describe('@idraw/util: lib/loader', () => { test('loadHTML', async () => { diff --git a/packages/util/__tests__/lib/modify-recorder.test.ts b/packages/util/__tests__/lib/modify-recorder.test.ts deleted file mode 100644 index 858b954..0000000 --- a/packages/util/__tests__/lib/modify-recorder.test.ts +++ /dev/null @@ -1,441 +0,0 @@ -import type { Data, Element, ModifiedElement } from '@idraw/types'; -import { deepClone } from '@idraw/util'; -import { ModifyRecorder } from '../../src/lib/modify-recorder'; -import { imageBase64, html, svg } from '../_assets/base'; - -const originData: Data = { - elements: [ - { - uuid: 'b37213ce-d711-cbb3-51ac-d8081c19f127', - type: 'rect', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - background: '#A0A0A0' - } - }, - { - uuid: '39308517-e10f-76df-43a9-50ed7295e61e', - type: 'circle', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - background: '#B0B0B0' - } - }, - { - uuid: 'ef934ab7-a32e-040c-9ac0-ed193405e6e4', - type: 'text', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - text: 'Hello World' - } - }, - { - uuid: '063e3a80-1ede-7912-f919-975e34a9bd01', - type: 'group', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - children: [ - { - uuid: '76a9bc55-6400-5aff-3147-d6c9b0d68753', - type: 'rect', - x: 10, - y: 20, - w: 100, - h: 100, - detail: { - background: '#C0C0C0' - } - }, - { - uuid: 'e0889472-1f16-d6cd-3c7a-4b827d52279d', - type: 'image', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - src: imageBase64 - } - }, - { - uuid: 'b60e64e8-833e-e112-d7eb-1ab6e7d6870c', - type: 'svg', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - svg: svg - } - }, - { - uuid: '61f2a61e-cdd5-ae36-983f-686ba8e35973', - type: 'html', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - html: html - } - } - ] - } - } - ] -}; - -describe('ModifyRecorder', () => { - let dateSpy: any; - const mockTime = Date.parse('2024-01-01'); - - beforeAll(() => { - dateSpy = jest.spyOn(global.Date, 'now').mockImplementation(() => mockTime); - }); - - afterAll(() => { - dateSpy.mockRestore(); - }); - - test('do-undo-redo addElement', () => { - const data = deepClone(originData); - const recorder = new ModifyRecorder({ recordable: true }); - const elem: Element<'rect'> = { - uuid: '2d2c5333-352d-7734-9ad6-53faa0ba36fc', - type: 'rect', - x: 0, - y: 0, - w: 10, - h: 10, - detail: { - background: 'red' - } - }; - let resultData: Data | null = null; - // do - { - resultData = recorder.do(data, { - type: 'addElement', - content: { - position: [1], - element: deepClone(elem) - } - }); - const expectData = deepClone(originData); - expectData.elements.splice(1, 0, deepClone(elem)); - expect(resultData).toStrictEqual(expectData); - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([ - { - position: [1], - element: deepClone(elem), - type: 'addElement', - time: mockTime - } - ]); - const undoRecords = recorder.$getUndoStack(); - expect(undoRecords).toStrictEqual([]); - } - - // undo - { - resultData = recorder.undo(resultData as unknown as Data); - expect(resultData).toStrictEqual(originData); - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([]); - const unRecords = recorder.$getUndoStack(); - expect(unRecords).toStrictEqual([ - { - position: [1], - element: deepClone(elem), - type: 'addElement', - time: mockTime - } - ]); - } - - // redo - { - resultData = recorder.redo(resultData as unknown as Data); - const expectData = deepClone(originData); - expectData.elements.splice(1, 0, deepClone(elem)); - expect(resultData).toStrictEqual(expectData); - - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([ - { - position: [1], - element: deepClone(elem), - type: 'addElement', - time: mockTime - } - ]); - const undoRecords = recorder.$getUndoStack(); - expect(undoRecords).toStrictEqual([]); - } - }); - - test('do-undo-redo updateElement', () => { - const data = deepClone(originData); - const recorder = new ModifyRecorder({ recordable: true }); - const targetElem: Element<'rect'> = (data.elements?.[3] as Element<'group'>).detail.children[0] as Element<'rect'>; - - const beforeElem: ModifiedElement = { - x: targetElem.x, - y: targetElem.y, - w: targetElem.w, - h: targetElem.h, - detail: { - background: targetElem.detail.background - } - }; - - const afterElem: ModifiedElement = { - x: 5, - y: 15, - w: 25, - h: 35, - detail: { - background: 'red' - } - }; - - let resultData: Data | null = null; - // do - { - resultData = recorder.do(data, { - type: 'updateElement', - content: { - position: [3, 0], - beforeModifiedElement: deepClone(beforeElem), - afterModifiedElement: deepClone(afterElem) - } - }); - const expectData = deepClone(originData); - const expectTargetElem: Element<'rect'> = (expectData.elements?.[3] as Element<'group'>).detail.children[0] as Element<'rect'>; - expectTargetElem.x = afterElem.x as number; - expectTargetElem.y = afterElem.y as number; - expectTargetElem.w = afterElem.w as number; - expectTargetElem.h = afterElem.h as number; - expectTargetElem.detail.background = (afterElem as Element<'rect'>).detail.background as string; - expect(resultData).toStrictEqual(expectData); - - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([ - { - position: [3, 0], - beforeModifiedElement: deepClone(beforeElem), - afterModifiedElement: deepClone(afterElem), - type: 'updateElement', - time: mockTime - } - ]); - const undoRecords = recorder.$getUndoStack(); - expect(undoRecords).toStrictEqual([]); - } - - // undo - { - resultData = recorder.undo(resultData as unknown as Data); - const expectData = deepClone(originData); - expect(resultData).toStrictEqual(expectData); - - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([]); - - const unRecords = recorder.$getUndoStack(); - expect(unRecords).toStrictEqual([ - { - position: [3, 0], - beforeModifiedElement: deepClone(beforeElem), - afterModifiedElement: deepClone(afterElem), - type: 'updateElement', - time: mockTime - } - ]); - } - - // redo - { - resultData = recorder.redo(resultData as unknown as Data); - const expectData = deepClone(originData); - const expectTargetElem: Element<'rect'> = (expectData.elements?.[3] as Element<'group'>).detail.children[0] as Element<'rect'>; - expectTargetElem.x = afterElem.x as number; - expectTargetElem.y = afterElem.y as number; - expectTargetElem.w = afterElem.w as number; - expectTargetElem.h = afterElem.h as number; - expectTargetElem.detail.background = (afterElem as Element<'rect'>).detail.background as string; - expect(resultData).toStrictEqual(expectData); - - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([ - { - position: [3, 0], - beforeModifiedElement: deepClone(beforeElem), - afterModifiedElement: deepClone(afterElem), - type: 'updateElement', - time: mockTime - } - ]); - const undoRecords = recorder.$getUndoStack(); - expect(undoRecords).toStrictEqual([]); - } - }); - - test('do-undo-redo deleteElement', () => { - const data = deepClone(originData); - const recorder = new ModifyRecorder({ recordable: true }); - const targetElem: Element<'rect'> = (data.elements?.[3] as Element<'group'>).detail.children[0] as Element<'rect'>; - let resultData: Data | null = null; - // do - { - resultData = recorder.do(data, { - type: 'deleteElement', - content: { - position: [3, 0], - element: deepClone(targetElem) - } - }); - const expectData = deepClone(originData); - (expectData.elements?.[3] as Element<'group'>).detail.children.splice(0, 1); - expect(resultData).toStrictEqual(expectData); - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([ - { - position: [3, 0], - element: deepClone(targetElem), - type: 'deleteElement', - time: mockTime - } - ]); - const undoRecords = recorder.$getUndoStack(); - expect(undoRecords).toStrictEqual([]); - } - - // undo - { - resultData = recorder.undo(resultData as unknown as Data); - expect(resultData).toStrictEqual(originData); - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([]); - const unRecords = recorder.$getUndoStack(); - expect(unRecords).toStrictEqual([ - { - position: [3, 0], - element: deepClone(targetElem), - type: 'deleteElement', - time: mockTime - } - ]); - } - - // redo - { - resultData = recorder.redo(resultData as unknown as Data); - const expectData = deepClone(originData); - (expectData.elements?.[3] as Element<'group'>).detail.children.splice(0, 1); - expect(resultData).toStrictEqual(expectData); - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([ - { - position: [3, 0], - element: deepClone(targetElem), - type: 'deleteElement', - time: mockTime - } - ]); - const undoRecords = recorder.$getUndoStack(); - expect(undoRecords).toStrictEqual([]); - } - }); - - test('do-undo-redo moveElement', () => { - const data = deepClone(originData); - const recorder = new ModifyRecorder({ recordable: true }); - let resultData: Data | null = null; - // const targetElem: Element<'rect'> = (data.elements?.[3] as Element<'group'>).detail.children[0] as Element<'rect'>; - - // do - { - resultData = recorder.do(data, { - type: 'moveElement', - content: { - from: [3, 0], - to: [1] - } - }); - const expectData = deepClone(originData); - const [fromElem] = (expectData.elements?.[3] as Element<'group'>).detail.children.splice(0, 1); - expectData.elements.splice(1, 0, fromElem); - - expect(resultData).toStrictEqual(expectData); - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([ - { - from: [3, 0], - to: [1], - afterModifiedFrom: [4, 0], - afterModifiedTo: [1], - type: 'moveElement', - time: mockTime - } - ]); - const undoRecords = recorder.$getUndoStack(); - expect(undoRecords).toStrictEqual([]); - } - - // undo - { - resultData = recorder.undo(resultData as unknown as Data); - expect(resultData).toStrictEqual(originData); - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([]); - const unRecords = recorder.$getUndoStack(); - expect(unRecords).toStrictEqual([ - { - from: [3, 0], - to: [1], - afterModifiedFrom: [4, 0], - afterModifiedTo: [1], - type: 'moveElement', - time: mockTime - } - ]); - } - - // redo - { - resultData = recorder.redo(resultData as unknown as Data); - const expectData = deepClone(originData); - const [fromElem] = (expectData.elements?.[3] as Element<'group'>).detail.children.splice(0, 1); - expectData.elements.splice(1, 0, fromElem); - - expect(resultData).toStrictEqual(expectData); - const doRecords = recorder.$getDoStack(); - expect(doRecords).toStrictEqual([ - { - from: [3, 0], - to: [1], - afterModifiedFrom: [4, 0], - afterModifiedTo: [1], - type: 'moveElement', - time: mockTime - } - ]); - const undoRecords = recorder.$getUndoStack(); - expect(undoRecords).toStrictEqual([]); - } - }); -}); diff --git a/packages/util/__tests__/lib/modify.test.ts b/packages/util/__tests__/lib/modify.test.ts deleted file mode 100644 index 5bb859a..0000000 --- a/packages/util/__tests__/lib/modify.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getModifiedElement } from '@idraw/util'; -import type { ModifiedElement, Element } from '@idraw/types'; - -describe('modify', () => { - const originElement: Element = { - uuid: 'b37213ce-d711-cbb3-51ac-d8081c19f127', - type: 'rect', - x: 0, - y: 0, - w: 100, - h: 100, - detail: { - background: '#A0A0A0' - } - }; - - test('getModifiedElement', () => { - const elem: ModifiedElement = { - uuid: 'xxxxxx', - x: 5, - y: 10, - detail: { - background: 'red' - } - } as ModifiedElement; - const modifiedElem = getModifiedElement(elem, originElement); - expect(modifiedElem).toStrictEqual({ - x: 0, - y: 0, - detail: { - background: '#A0A0A0' - } - }); - }); -}); diff --git a/packages/util/__tests__/lib/move-element.test.ts b/packages/util/__tests__/lib/move-element.test.ts new file mode 100644 index 0000000..9692039 --- /dev/null +++ b/packages/util/__tests__/lib/move-element.test.ts @@ -0,0 +1,199 @@ +import { moveElementPosition, calcRevertMovePosition } from '@idraw/util'; +import type { Elements } from '@idraw/types'; +const getElemBase = () => { + return { + x: 0, + y: 0, + w: 1, + h: 1 + }; +}; + +function generateElements(list: any[]): Elements { + const elements: Elements = list.map((item) => { + if (Array.isArray(item)) { + return { + ...getElemBase(), + uuid: `group`, + type: 'group', + detail: { + children: generateElements(item) + } + }; + } else { + return { + ...getElemBase(), + uuid: `rect-${item}`, + type: 'rect', + detail: {} + }; + } + }) as Elements; + return elements; +} + +describe('@idraw/util: handle-element ', () => { + // [2] -> [4] + // [0, 1, 2, 3, 2, 4, 5] + // [0, 1, 3, 2, 4, 5] + test('moveElementPosition, move-down [2] -> [4]', () => { + const list: Elements = generateElements([0, 1, 2, 3, 4, 5]); + const from = [2]; + const to = [4]; + moveElementPosition(list, { + from, + to + }); + const expectResult = generateElements([0, 1, 3, 2, 4, 5]); + expect(list).toStrictEqual(expectResult); + // revert action + // result [2] [3] + const revertInfo = calcRevertMovePosition({ from, to }) as { from: number[]; to: number[] }; + // revert [3] -> [2] + moveElementPosition(list, { + from: revertInfo.from, + to: revertInfo.to + }); + expect(list).toStrictEqual(generateElements([0, 1, 2, 3, 4, 5])); + }); + + // [4] -> [2] yes + // [0, 1, 4, 2, 3, 4, 5] + // [0, 1, 4, 2, 3, 5] + test('moveElementPosition, move-up [4] -> [2]', () => { + const list: Elements = generateElements([0, 1, 2, 3, 4, 5]); + const from = [4]; + const to = [2]; + moveElementPosition(list, { + from, + to + }); + const expectResult = generateElements([0, 1, 4, 2, 3, 5]); + expect(list).toStrictEqual(expectResult); + // revert action + // result [5] [2] + const revertInfo = calcRevertMovePosition({ from, to }) as { from: number[]; to: number[] }; + // revert [2] -> [5] + moveElementPosition(list, { + from: revertInfo.from, + to: revertInfo.to + }); + expect(list).toStrictEqual(generateElements([0, 1, 2, 3, 4, 5])); + }); + + // [3, 2, 1] -> [2] + test('moveElementPosition, move-up [3, 2, 1] -> [2]', () => { + const list: Elements = generateElements([0, 1, 2, [0, 1, [0, 1, 2, 3], 3], 4, 5]); + const from = [3, 2, 1]; + const to = [2]; + moveElementPosition(list, { + from, + to + }); + const expectResult = generateElements([0, 1, 1, 2, [0, 1, [0, 2, 3], 3], 4, 5]); + expect(list).toStrictEqual(expectResult); + // revert action + // result from: [ 4, 2, 1 ], to: [ 2 ] + const revertInfo = calcRevertMovePosition({ from, to }) as { from: number[]; to: number[] }; + // revert [2] -> [ 4, 2, 1 ] + moveElementPosition(list, { + from: revertInfo.from, + to: revertInfo.to + }); + expect(list).toStrictEqual(generateElements([0, 1, 2, [0, 1, [0, 1, 2, 3], 3], 4, 5])); + }); + + // [1] -> [1, 2, 3] + test('moveElementPosition, move-up [1] -> [1, 2, 3]', () => { + const list: Elements = generateElements([0, [0, 1, [0, 1, 2, 3, 4, 5], 3, 4, 5], 2, 3, 4, 5]); + const from = [1]; + const to = [1, 2, 3]; + moveElementPosition(list, { + from, + to + }); + const expectResult = generateElements([0, [0, 1, [0, 1, 2, 3, 4, 5], 3, 4, 5], 2, 3, 4, 5]); + expect(list).toStrictEqual(expectResult); + // revert action null + const revertInfo = calcRevertMovePosition({ from, to }); + expect(revertInfo).toBeNull(); + }); + + // [1, 2, 3, 4, 5] -> [1, 2, 2] + test('moveElementPosition, move-up [1, 2, 3, 4, 5] -> [1, 2, 2]', () => { + const list: Elements = generateElements([ + 0, + [0, 1, [0, 1, 2, [0, 1, 2, 3, [0, 1, 2, 3, 4, 5], 5], 4, 5], 3, 4, 5], + 2, + 3, + 4, + 5 + ]); + const from = [1, 2, 3, 4, 5]; + const to = [1, 2, 2]; + moveElementPosition(list, { + from, + to + }); + const expectResult = generateElements([ + 0, + [0, 1, [0, 1, 5, 2, [0, 1, 2, 3, [0, 1, 2, 3, 4], 5], 4, 5], 3, 4, 5], + 2, + 3, + 4, + 5 + ]); + expect(list).toStrictEqual(expectResult); + // revert action + // result from: [ 1, 2, 4, 4, 5 ], to: [ 1, 2, 2 ] + const revertInfo = calcRevertMovePosition({ from, to }) as { from: number[]; to: number[] }; + // revert [ 1, 2, 2 ] -> [ 1, 2, 4, 4, 5 ] + moveElementPosition(list, { + from: revertInfo.from, + to: revertInfo.to + }); + expect(list).toStrictEqual( + generateElements([0, [0, 1, [0, 1, 2, [0, 1, 2, 3, [0, 1, 2, 3, 4, 5], 5], 4, 5], 3, 4, 5], 2, 3, 4, 5]) + ); + }); + + // [1] -> [1] + test('moveElementPosition, move-up [1] -> [1]', () => { + const list: Elements = generateElements([0, 1, 2, [0, 1, [0, 1, 2, 3], 3], 4, 5]); + const from = [1]; + const to = [1]; + moveElementPosition(list, { + from, + to + }); + const expectResult = generateElements([0, 1, 2, [0, 1, [0, 1, 2, 3], 3], 4, 5]); + expect(list).toStrictEqual(expectResult); + // revert action null + const revertInfo = calcRevertMovePosition({ from, to }); + expect(revertInfo).toBeNull(); + }); + + // [2, 4] -> [1, 2] + test('moveElementPosition, move-up [1, 2] -> [2, 4]', () => { + const list: Elements = generateElements([0, [0, 1, 2, 3, 4], [0, 1, 2, 3, 4], 3, 4]); + const from = [2, 4]; + const to = [1, 2]; + moveElementPosition(list, { + from, + to + }); + const expectResult = generateElements([0, [0, 1, 4, 2, 3, 4], [0, 1, 2, 3], 3, 4]); + expect(list).toStrictEqual(expectResult); + + // revert action + // result from: [ 2, 4 ], to: [ 1, 2 ] + const revertInfo = calcRevertMovePosition({ from, to }) as { from: number[]; to: number[] }; + + // revert [ 1, 2 ] -> [ 2, 4 ] + moveElementPosition(list, { + from: revertInfo.from, + to: revertInfo.to + }); + expect(list).toStrictEqual(generateElements([0, [0, 1, 2, 3, 4], [0, 1, 2, 3, 4], 3, 4])); + }); +}); diff --git a/packages/util/__tests__/lib/parser.test.ts b/packages/util/__tests__/lib/parser.test.ts index 80b2fec..6bae1ac 100644 --- a/packages/util/__tests__/lib/parser.test.ts +++ b/packages/util/__tests__/lib/parser.test.ts @@ -1,4 +1,4 @@ -import { parseHTMLToDataURL, parseSVGToDataURL } from '../../src/lib/parser'; +import { parseHTMLToDataURL, parseSVGToDataURL } from '../../src/view/parser'; describe('@idraw/util: lib/parser', () => { test('parseHTMLToDataURL', async () => { diff --git a/packages/util/__tests__/lib/time.test.ts b/packages/util/__tests__/lib/time.test.ts index d4aa1c5..42a6cf1 100644 --- a/packages/util/__tests__/lib/time.test.ts +++ b/packages/util/__tests__/lib/time.test.ts @@ -1,58 +1,53 @@ -import { - delay, throttle, compose, -} from '../../src/lib/time'; - +import { delay, throttle, compose } from '@idraw/util'; describe('@idraw/util: lib/delay', () => { - - test('delay', async () => { + test('delay', async () => { const start = Date.now(); const time = 1000; await delay(time); const count = Date.now() - start; expect(count >= time).toStrictEqual(true); }); - - test('throttle', async () => { + + test('throttle', async () => { let count = 0; function doThrottle() { return new Promise((resolve) => { - const func = throttle(function() { - count ++; + const func = throttle(function () { + count++; }, 100); let interval = setInterval(() => { if (count >= 5) { clearInterval(interval); - resolve(null) + resolve(null); } func(); }, 5); - }) + }); } await doThrottle(); expect(count).toStrictEqual(5); }); - - test('compose', async () => { + test('compose', async () => { let middleware = []; let context = { data: [] }; - middleware.push(async(ctx: any, next: any) => { + middleware.push(async (ctx: any, next: any) => { ctx.data.push(1); await next(); ctx.data.push(6); }); - middleware.push(async(ctx: any, next: any) => { + middleware.push(async (ctx: any, next: any) => { ctx.data.push(2); await next(); ctx.data.push(5); }); - middleware.push(async(ctx: any, next: any) => { + middleware.push(async (ctx: any, next: any) => { ctx.data.push(3); await next(); ctx.data.push(4); @@ -61,8 +56,6 @@ describe('@idraw/util: lib/delay', () => { const fn = compose(middleware); await fn(context); - expect(context).toStrictEqual({data: [1, 2, 3, 4, 5, 6]}); + expect(context).toStrictEqual({ data: [1, 2, 3, 4, 5, 6] }); }); - }); - diff --git a/packages/util/__tests__/lib/unflat-object.test.ts b/packages/util/__tests__/lib/unflat-object.test.ts new file mode 100644 index 0000000..9a8ce77 --- /dev/null +++ b/packages/util/__tests__/lib/unflat-object.test.ts @@ -0,0 +1,50 @@ +import { unflatObject } from '@idraw/util'; + +describe('unflatObject', () => { + test('unflat object', () => { + expect(unflatObject({ 'a.b.c': 1 })).toStrictEqual({ + a: { b: { c: 1 } } + }); + }); + + test('unflat array', () => { + const input = { + 'list[0]': 'a', + 'list[1].name': 'b' + }; + expect(unflatObject(input)).toEqual({ list: ['a', { name: 'b' }] }); + }); + + test('unflat array and object', () => { + const input = { + 'user.name': 'Alice', + 'posts[0].title': 'First', + 'posts[1].tags[0]': 'tech' + }; + expect(unflatObject(input)).toEqual({ + user: { name: 'Alice' }, + posts: [{ title: 'First' }, { tags: ['tech'] }] + }); + }); + + test('Error', () => { + expect(() => + unflatObject({ + 'a.b': 1, + 'a[0]': 2 + }) + ).toThrow(`Structure conflict at path 'a.0': Expected array but found object`); + + // expect(() => + // unflatObject({ + // 'a..b': 1 + // }) + // ).toStrictEqual({}); + }); + + test('Side cases', () => { + // expect(unflatObject({})).toEqual({}); + // expect(unflatObject({ '': 42 })).toEqual({ '': 42 }); + expect(unflatObject({ '': 42 })).toEqual({}); + }); +}); diff --git a/packages/util/__tests__/lib/uuid.test.ts b/packages/util/__tests__/lib/uuid.test.ts index c6c4221..2ac1909 100644 --- a/packages/util/__tests__/lib/uuid.test.ts +++ b/packages/util/__tests__/lib/uuid.test.ts @@ -1,15 +1,15 @@ -import { - createUUID -} from '../../src/lib/uuid'; +import { createAssetId, isAssetId } from '@idraw/util'; +describe('@idraw/util: createAssetId ', () => { + test('url', () => { + const url1 = 'https://example.com/2025/01/01/000001.jpg'; + const assetId1 = createAssetId(url1); + expect(isAssetId(assetId1)).toBeTruthy(); -describe('@idraw/util: lib/uuid', () => { - - test('createUUID', async () => { - const uuid = createUUID(); - expect(typeof uuid).toStrictEqual('string'); - expect(uuid.length).toStrictEqual(36); + const url2 = 'https://example.com/2025/01/01/000002.jpg'; + const assetId2 = createAssetId(url2); + expect(isAssetId(assetId2)).toBeTruthy(); + + expect(url1).not.toBe(url2); }); - }); - diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 68c9161..ddc2a31 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -1,11 +1,11 @@ -export { delay, compose, throttle, debounce } from './lib/time'; +export { delay, compose, throttle, debounce } from './tool/time'; export { downloadImageFromCanvas, parseFileToBase64, pickFile, parseFileToText, downloadFileFromText -} from './lib/file'; +} from './tool/file'; export { toColorHexStr, toColorHexNum, @@ -14,15 +14,15 @@ export { colorToCSS, colorToLinearGradientCSS, mergeHexColorAlpha -} from './lib/color'; -export { createUUID, isAssetId, createAssetId } from './lib/uuid'; -export { deepClone, sortDataAsserts, deepCloneElement, deepCloneData, filterCompactData } from './lib/data'; -export { istype } from './lib/istype'; -export { loadImage, loadSVG, loadHTML } from './lib/load'; -export { is } from './lib/is'; -export { check } from './lib/check'; -export { createBoardContent, createContext2D, createOffscreenContext2D } from './lib/canvas'; -export { EventEmitter } from './lib/event'; +} from './tool/color'; +export { createUUID, isAssetId, createAssetId } from './tool/uuid'; +export { deepClone, sortDataAsserts, deepCloneElement, deepCloneData, filterCompactData } from './view/data'; +export { istype } from './tool/istype'; +export { loadImage, loadSVG, loadHTML } from './view/load'; +export { is } from './view/is'; +export { check } from './view/check'; +export { createBoardContent, createContext2D, createOffscreenContext2D } from './view/canvas'; +export { EventEmitter } from './tool/event'; export { calcDistance, calcSpeed, @@ -31,10 +31,10 @@ export { vaildPoint, vaildTouchPoint, getCenterFromTwoPoints -} from './lib/point'; -export { Store } from './lib/store'; -export { getViewScaleInfoFromSnapshot, getViewSizeInfoFromSnapshot } from './lib/middleware'; -export { Context2D } from './lib/context2d'; +} from './view/point'; +export { Store } from './tool/store'; +export { getViewScaleInfoFromSnapshot, getViewSizeInfoFromSnapshot } from './view/middleware'; +export { Context2D } from './view/context2d'; export { rotateElement, parseRadianToAngle, @@ -46,7 +46,7 @@ export { rotatePointInGroup, limitAngle, calcRadian -} from './lib/rotate'; +} from './view/rotate'; export { getSelectedElementUUIDs, validateElements, @@ -68,8 +68,8 @@ export { getElementPositionMapFromList, calcElementListSize, isSameElementSize -} from './lib/element'; -export { checkRectIntersect } from './lib/rect'; +} from './view/element'; +export { checkRectIntersect } from './view/rect'; export { viewScale, viewScroll, @@ -85,23 +85,24 @@ export { originRectInfoToRangeRectInfo, isViewPointInElementSize, isViewPointInVertexes -} from './lib/view-calc'; -export { rotatePoint, rotateVertexes, rotateByCenter } from './lib/rotate'; +} from './view/view-calc'; +export { rotatePoint, rotateVertexes, rotateByCenter } from './view/rotate'; export { getElementVertexes, calcElementVertexesInGroup, calcElementVertexesQueueInGroup, calcElementQueueVertexesQueueInGroup -} from './lib/vertex'; -export { calcElementSizeController, calcLayoutSizeController } from './lib/controller'; -export { generateSVGPath, parseSVGPath } from './lib/svg-path'; -export { generateHTML, parseHTML } from './lib/html'; -export { compressImage } from './lib/image'; -export { formatNumber } from './lib/number'; -export { matrixToAngle, matrixToRadian } from './lib/matrix'; -export { getDefaultElementDetailConfig, getDefaultElementRectDetail } from './lib/config'; -export { calcViewBoxSize } from './lib/view-box'; +} from './view/vertex'; +export { calcElementSizeController, calcLayoutSizeController } from './view/controller'; +export { generateSVGPath, parseSVGPath } from './view/svg-path'; +export { generateHTML, parseHTML } from './tool/html'; +export { compressImage } from './tool/image'; +export { formatNumber } from './tool/number'; +export { matrixToAngle, matrixToRadian } from './view/matrix'; +export { getDefaultElementDetailConfig, getDefaultElementRectDetail } from './view/config'; +export { calcViewBoxSize } from './view/view-box'; export { + mergeElement, createElement, insertElementToListByPosition, deleteElementInListByPosition, @@ -109,15 +110,21 @@ export { moveElementPosition, updateElementInList, updateElementInListByPosition -} from './lib/handle-element'; -export { deepResizeGroupElement } from './lib/resize-element'; -export { calcViewCenterContent, calcViewCenter } from './lib/view-content'; -export { modifyElement, getModifiedElement } from './lib/modify'; -// export { ModifyRecorder } from './lib/modify-recorder'; -export { enhanceFontFamliy } from './lib/text'; -export { flatElementList } from './lib/flat'; -export { groupElementsByPosition, ungroupElementsByPosition } from './lib/group'; -export { calcPointMoveElementInGroup } from './lib/point-move-element'; -export { merge } from './lib/merge'; -export { omit } from './lib/omit'; -export { elementToBoxInfo } from './lib/box'; +} from './view/handle-element'; +export { deepResizeGroupElement } from './view/resize-element'; +export { calcViewCenterContent, calcViewCenter } from './view/view-content'; +export { toFlattenElement, toFlattenLayout, toFlattenGlobal } from './view/modify-record'; +export { enhanceFontFamliy } from './view/text'; +export { flatElementList } from './view/flat'; +export { groupElementsByPosition, ungroupElementsByPosition } from './view/group'; +export { calcPointMoveElementInGroup } from './view/point-move-element'; +export { mergeLayout } from './view/handle-layout'; +export { mergeGlobal } from './view/handle-global'; +export { calcRevertMovePosition, calcResultMovePosition } from './view/position'; + +export { merge } from './tool/merge'; +export { omit } from './tool/omit'; +export { elementToBoxInfo } from './view/box'; +export { get, set, toPath } from './tool/get-set-del'; +export { flatObject } from './tool/flat-object'; +export { unflatObject } from './tool/unflat-object'; diff --git a/packages/util/src/lib/modify-recorder.ts b/packages/util/src/lib/modify-recorder.ts deleted file mode 100644 index 89c8c63..0000000 --- a/packages/util/src/lib/modify-recorder.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { Data, ModifyType, ModifyContentMap, ModifyOptions, ModifyRecord } from '@idraw/types'; -import { deepClone } from './data'; -import { modifyElement } from './modify'; - -export interface ModifyRecorderOptions { - recordable: boolean; -} - -export class ModifyRecorder { - #doStack: ModifyRecord[] = []; - #undoStack: ModifyRecord[] = []; - #opts: ModifyRecorderOptions; - - constructor(opts: ModifyRecorderOptions) { - this.#opts = opts; - } - - #wrapRecord(opts: ModifyOptions, modifiedContent: ModifyContentMap[T]): ModifyRecord { - const content = opts.content as ModifyContentMap[T]; - const modifyRecord: ModifyRecord = { - ...deepClone(content), - // ...deepClone(modifiedContent), - type: opts.type, - time: Date.now() - } as ModifyRecord; - const record = modifyRecord as ModifyRecord; - if (opts.type === 'moveElement') { - (modifyRecord as ModifyRecord<'moveElement'>).afterModifiedFrom = [...(modifiedContent as ModifyContentMap['moveElement']).from]; - (modifyRecord as ModifyRecord<'moveElement'>).afterModifiedTo = [...(modifiedContent as ModifyContentMap['moveElement']).to]; - } - return record; - } - - $getDoStack() { - return this.#doStack; - } - - $getUndoStack() { - return this.#undoStack; - } - - clear() { - this.#doStack = []; - this.#undoStack = []; - } - - destroy() { - this.clear(); - this.#doStack = null as any; - this.#undoStack = null as any; - } - - do(data: Data, opts: ModifyOptions): Data { - const { data: modifiedData, content } = modifyElement(data, opts); - if (this.#opts.recordable === true) { - const record = this.#wrapRecord(opts, content); - this.#doStack.push(record); - } - return modifiedData; - } - - undo(data: Data): Data | null { - if (this.#opts.recordable !== true) { - return null; - } - let modifiedData: Data | null = null; - if (this.#doStack.length === 0) { - return data; - } - const item = this.#doStack.pop(); - if (!item) { - return data; - } - if (item?.type === 'addElement') { - const record = item as ModifyRecord<'addElement'>; - const { position, element } = record; - modifiedData = modifyElement<'deleteElement'>(data, { - type: 'deleteElement', - content: { - position, - element - } - }).data; - } else if (item?.type === 'updateElement') { - const record = item as ModifyRecord<'updateElement'>; - const { position, beforeModifiedElement, afterModifiedElement } = record; - modifiedData = modifyElement<'updateElement'>(data, { - type: 'updateElement', - content: { - position, - beforeModifiedElement: afterModifiedElement, - afterModifiedElement: beforeModifiedElement - } - }).data; - } else if (item?.type === 'deleteElement') { - const record = item as ModifyRecord<'deleteElement'>; - const { position, element } = record; - modifiedData = modifyElement<'addElement'>(data, { - type: 'addElement', - content: { - position, - element - } - }).data; - } else if (item?.type === 'moveElement') { - const record = item as ModifyRecord<'moveElement'>; - const { afterModifiedFrom, afterModifiedTo } = record; - const modifiedResult = modifyElement<'moveElement'>(data, { - type: 'moveElement', - content: { - from: afterModifiedTo, - to: afterModifiedFrom - } - }); - modifiedData = modifiedResult.data; - } - this.#undoStack.push(deepClone(item as ModifyRecord)); - return modifiedData; - } - - redo(data: Data): Data | null { - if (this.#opts.recordable !== true) { - return null; - } - let modifiedData: Data | null = null; - if (this.#undoStack.length === 0) { - return modifiedData; - } - const item = this.#undoStack.pop(); - if (!item) { - return modifiedData; - } - - if (item?.type === 'addElement') { - const record = item as ModifyRecord<'addElement'>; - const { position, element } = record; - modifiedData = modifyElement<'addElement'>(data, { - type: 'addElement', - content: { - position, - element - } - }).data; - } else if (item?.type === 'updateElement') { - const record = item as ModifyRecord<'updateElement'>; - const { position, beforeModifiedElement, afterModifiedElement } = record; - modifiedData = modifyElement<'updateElement'>(data, { - type: 'updateElement', - content: { - position, - beforeModifiedElement, - afterModifiedElement - } - }).data; - } else if (item?.type === 'deleteElement') { - const record = item as ModifyRecord<'deleteElement'>; - const { position, element } = record; - modifiedData = modifyElement<'deleteElement'>(data, { - type: 'deleteElement', - content: { - position, - element - } - }).data; - } else if (item?.type === 'moveElement') { - const record = item as ModifyRecord<'moveElement'>; - const { from, to } = record; - modifiedData = modifyElement<'moveElement'>(data, { - type: 'moveElement', - content: { - from, - to - } - }).data; - } - this.#doStack.push(deepClone(item as ModifyRecord)); - return modifiedData; - } -} diff --git a/packages/util/src/lib/modify.ts b/packages/util/src/lib/modify.ts deleted file mode 100644 index 4fd1911..0000000 --- a/packages/util/src/lib/modify.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Data, Element, ModifyOptions, ModifyType, ModifyContentMap, ModifiedElement } from '@idraw/types'; -import { insertElementToListByPosition, deleteElementInListByPosition, moveElementPosition, updateElementInListByPosition } from './handle-element'; -import { istype } from './istype'; - -export function modifyElement(data: Data, options: ModifyOptions): { data: Data; content: ModifyContentMap[T] } { - const { type } = options; - const content: ModifyContentMap[T] = { ...options.content } as ModifyContentMap[T]; - if (type === 'addElement') { - const opts: ModifyOptions<'addElement'> = options as ModifyOptions<'addElement'>; - const { element, position } = opts.content; - if (position?.length > 0) { - insertElementToListByPosition(element, [...position], data.elements); - } else { - data.elements.push(element); - } - } else if (type === 'deleteElement') { - const opts: ModifyOptions<'deleteElement'> = options as ModifyOptions<'deleteElement'>; - const { position } = opts.content; - deleteElementInListByPosition(position, data.elements); - } else if (type === 'moveElement') { - const opts: ModifyOptions<'moveElement'> = options as ModifyOptions<'moveElement'>; - const { from, to } = opts.content; - const movedResult = moveElementPosition(data.elements, { from, to }); - (content as ModifyContentMap['moveElement']).from = movedResult.from; - (content as ModifyContentMap['moveElement']).to = movedResult.to; - data.elements = movedResult.elements; - } else if (type === 'updateElement') { - const opts: ModifyOptions<'updateElement'> = options as ModifyOptions<'updateElement'>; - const { position, afterModifiedElement } = opts.content; - updateElementInListByPosition(position, afterModifiedElement, data.elements); - } - return { data, content }; -} - -function _get(source: any, path: string, defaultValue = undefined) { - // a.0.b -> ['a', '0', 'b'] - const keyList = path.split('.'); - const result = keyList.reduce((obj, key) => { - return Object(obj)[key]; - }, source); - return result === undefined ? defaultValue : result; -} - -function _set(obj: any, path: string, value: any) { - // a.0.b -> ['a', '0', 'b'] - const keys = path.split('.'); - - if (typeof obj !== 'object') return obj; - keys.reduce((o, k, i, _) => { - if (i === _.length - 1) { - o[k] = value; - return null; - } else if (k in o) { - return o[k]; - } else { - o[k] = /^[0-9]{1,}$/.test(_[i + 1]) ? [] : {}; - return o[k]; - } - }, obj); - return obj; -} - -export function getModifiedElement(target: ModifiedElement, originElement: Element): ModifiedElement { - const modifiedElement: ModifiedElement = {}; - const pathList: Array = []; - const _walk = (t: any) => { - if (istype.json(t)) { - const keys = Object.keys(t); - keys.forEach((key: string) => { - pathList.push(key); - if (istype.json(t[key]) || istype.array(t[key])) { - _walk(t[key]); - } else { - const pathStr = pathList.join('.'); - if (pathStr !== 'uuid') { - const value = _get(originElement, pathStr); - _set(modifiedElement, pathList.join('.'), value); - } - } - pathList.pop(); - }); - } else if (istype.array(t)) { - t.forEach((index: number) => { - pathList.push(index); - if (istype.json(t[index]) || istype.array(t[index])) { - _walk(t[index]); - } else { - const value = _get(originElement, pathList.join('.')); - _set(modifiedElement, pathList.join('.'), value); - } - pathList.pop(); - }); - } - }; - _walk(target); - - return modifiedElement; -} diff --git a/packages/util/src/lib/uuid.ts b/packages/util/src/lib/uuid.ts deleted file mode 100644 index 950fb94..0000000 --- a/packages/util/src/lib/uuid.ts +++ /dev/null @@ -1,34 +0,0 @@ -export function createUUID(): string { - function _createStr() { - return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); - } - return `${_createStr()}${_createStr()}-${_createStr()}-${_createStr()}-${_createStr()}-${_createStr()}${_createStr()}${_createStr()}`; -} - -function limitHexStr(str: string) { - let count = 0; - for (let i = 0; i < str.length; i++) { - count += str.charCodeAt(i) * str.charCodeAt(i) * i * i; - } - return count.toString(16).substring(0, 4); -} - -export function createAssetId(assetStr: string): string { - const len = assetStr.length; - const mid = Math.floor(len / 2); - const start4 = assetStr.substring(0, 4).padEnd(4, '0'); - const end4 = assetStr.substring(0, 4).padEnd(4, '0'); - const str1 = limitHexStr(len.toString(16).padEnd(4, start4)); - const str2 = limitHexStr(assetStr.substring(mid - 4, mid).padEnd(4, start4)).padEnd(4, 'f'); - const str3 = limitHexStr(assetStr.substring(mid - 8, mid - 4).padEnd(4, start4)).padEnd(4, 'f'); - const str4 = limitHexStr(assetStr.substring(mid - 12, mid - 8).padEnd(4, start4)).padEnd(4, 'f'); - const str5 = limitHexStr(assetStr.substring(mid - 16, mid - 12).padEnd(4, end4)).padEnd(4, 'f'); - const str6 = limitHexStr(assetStr.substring(mid, mid + 4).padEnd(4, end4)).padEnd(4, 'f'); - const str7 = limitHexStr(assetStr.substring(mid + 4, mid + 8).padEnd(4, end4)).padEnd(4, 'f'); - const str8 = limitHexStr(end4.padEnd(4, start4).padEnd(4, end4)); - return `@assets/${str1}${str2}-${str3}-${str4}-${str5}-${str6}${str7}${str8}`; -} - -export function isAssetId(id: any | string): boolean { - return /^@assets\/[0-9a-z]{8,8}\-[0-9a-z]{4,4}\-[0-9a-z]{4,4}\-[0-9a-z]{4,4}\-[0-9a-z]{12,12}$/.test(`${id}`); -} diff --git a/packages/util/src/lib/color.ts b/packages/util/src/tool/color.ts similarity index 100% rename from packages/util/src/lib/color.ts rename to packages/util/src/tool/color.ts diff --git a/packages/util/src/lib/event.ts b/packages/util/src/tool/event.ts similarity index 100% rename from packages/util/src/lib/event.ts rename to packages/util/src/tool/event.ts diff --git a/packages/util/src/lib/file.ts b/packages/util/src/tool/file.ts similarity index 100% rename from packages/util/src/lib/file.ts rename to packages/util/src/tool/file.ts diff --git a/packages/util/src/tool/flat-object.ts b/packages/util/src/tool/flat-object.ts new file mode 100644 index 0000000..9547ff3 --- /dev/null +++ b/packages/util/src/tool/flat-object.ts @@ -0,0 +1,59 @@ +type FlattenedObject = Record; + +type FlatObjectOptions = { + ignorePaths?: string[]; // eg. ["detail.children"] +}; + +/** + * Flattens a nested object/array into a path-based object + * @param obj The input object + * @param parentKey The parent path (used internally for recursion) + * @param result The result object (used internally for recursion) + */ +function flattenObject>( + obj: T, + parentKey: string = '', + result: FlattenedObject = {}, + opts?: FlatObjectOptions +): FlattenedObject { + // Iterate over each key in the object + Object.keys(obj).forEach((key) => { + // Compute the current full path + const currentKey = parentKey ? `${parentKey}${isArrayIndex(key) ? `[${key}]` : `.${key}`}` : key; + if (!opts?.ignorePaths?.includes(currentKey)) { + // Get the current value + const value = obj[key]; + + // Handle flattenable types (objects/arrays) + if (isFlattenable(value)) { + flattenObject(value, Array.isArray(value) ? currentKey : currentKey, result, opts); + } + // Handle primitive values that cannot be flattened further + else { + result[currentKey] = value; + } + } + }); + + return result; +} + +// Type guard to check if the value can be flattened +function isFlattenable(value: unknown): value is object | Array { + return (typeof value === 'object' && value !== null && !(value instanceof Date)) || Array.isArray(value); +} + +// Check if the key is an array index (for path formatting) +function isArrayIndex(key: string): boolean { + return /^\d+$/.test(key) && !isNaN(Number(key)); +} + +// Main entry function to flatten an object with enhanced type support +export function flatObject>(obj: T, opts?: FlatObjectOptions): FlattenedObject { + // Defensive check for non-object input + if (typeof obj !== 'object' || obj === null) { + return { '': obj }; + } + + return flattenObject(obj, '', {}, opts); +} diff --git a/packages/util/src/tool/get-set-del.ts b/packages/util/src/tool/get-set-del.ts new file mode 100644 index 0000000..de50603 --- /dev/null +++ b/packages/util/src/tool/get-set-del.ts @@ -0,0 +1,101 @@ +/** + * Convert path to array format + * @example 'a[0].b.c' => ['a', '0', 'b', 'c'] + */ +export function toPath(path: string | string[]): string[] { + if (Array.isArray(path)) return [...path]; + return path.split(/\.|\[|\]/).filter((key) => key !== ''); +} + +/** + * Lodash-style get method to retrieve nested object values + * @param obj Target object + * @param path Path string or array + * @param defaultValue Fallback value if path not found + */ +export function get(obj: T, path: string | string[], defaultValue?: D): D | undefined { + if (!path) { + return undefined; + } + const pathArray = toPath(path); + let current: any = obj; + + for (const key of pathArray) { + if (current === null || current === undefined) { + return defaultValue as D; + } + current = current[key]; + } + + return current !== undefined ? current : defaultValue; +} + +/** + * Lodash-style set method to assign values to nested paths (mutates original object) + * @param obj Target object + * @param path Path string or array + * @param value Value to assign + */ +export function set(obj: T, path: string | string[], value: any): T { + const pathArray = toPath(path); + if (pathArray.length === 0) { + return obj; + } + + let current: any = obj; + + if (current) { + for (let i = 0; i < pathArray.length; i++) { + const key = pathArray[i]; + + if (i === pathArray.length - 1) { + // Final path segment - assign value + current[key] = value; + break; + } + + // Create missing path structures + if (current && (current?.[key] === undefined || typeof current?.[key] !== 'object' || current?.[key] === null)) { + const nextKey = pathArray[i + 1]; + const isNextNumeric = /^\d+$/.test(nextKey); + current[key] = isNextNumeric ? [] : {}; + } + + current = current?.[key]; + } + } + + return obj; +} + +export function del(obj: T, path: string | string[]): T { + const pathArray = toPath(path); + if (pathArray.length === 0) { + return obj; + } + + let current: any = obj; + + if (current) { + for (let i = 0; i < pathArray.length; i++) { + const key = pathArray[i]; + + if (i === pathArray.length - 1) { + // Final path segment - delete value + delete current[key]; + break; + } + + // Create missing path structures + if (current && (current?.[key] === undefined || typeof current?.[key] !== 'object' || current?.[key] === null)) { + const nextKey = pathArray[i + 1]; + const isNextNumeric = /^\d+$/.test(nextKey); + current[key] = isNextNumeric ? [] : {}; + } + + current = current?.[key]; + } + } + + return obj; +} diff --git a/packages/util/src/tool/hash.ts b/packages/util/src/tool/hash.ts new file mode 100644 index 0000000..52e9004 --- /dev/null +++ b/packages/util/src/tool/hash.ts @@ -0,0 +1,52 @@ +/** + * Synchronously generates 32-character Base36 encoded hash (enhanced 256-bit algorithm) + * @param str - Input string (any length) + * @returns 32-character lowercase Base36 string (0-9a-z) + */ +export function generate32Base36Hash(str: string): string { + // Generate 256-bit hash (4x64-bit FNV hashes) + const hash256 = generate256BitHash(str); + // Convert to Base36 and format to 32 characters + return bigIntToBase36(hash256).padStart(32, '0').slice(0, 32); +} +// // Usage example +// console.log(generate32Base36Hash('hello world')); +// // Sample output: 2yj8q4z7kpr6s9d5m2w3x1g6h8j4n0q + +// Core algorithm for generating 256-bit hash +function generate256BitHash(str: string): bigint { + let h1 = 0xcbf29ce484222325n, + h2 = 0x84222325cbf29ce4n; + let h3 = 0x1b3n * h1, + h4 = 0x1000000n * h2; // Different initial values + const prime = 0x100000001b3n; + + // Chunk processing for large texts (per 4096 characters) + const chunkSize = 4096; + for (let i = 0; i < str.length; i += chunkSize) { + const chunk = str.slice(i, i + chunkSize); + for (let j = 0; j < chunk.length; j++) { + const code = BigInt(chunk.charCodeAt(j) + i + j); // Position-sensitive + h1 = (h1 ^ code) * prime; + h2 = ((h2 ^ h1) * prime) ^ h3; + h3 = (h3 ^ h2) * prime + h4; + h4 = ((h4 ^ h3) * prime) | 0x1234567890abcdefn; + } + } + + // Combine 4x64-bit hashes into 256-bit + return (h1 << 192n) | (h2 << 128n) | (h3 << 64n) | h4; +} + +// Utility function for BigInt to Base36 conversion +function bigIntToBase36(num: bigint): string { + const chars = '0123456789abcdefghijklmnopqrstuvwxyz'; + if (num === 0n) return '0'; + let result = ''; + while (num > 0n) { + const rem = num % 36n; + result = chars[Number(rem)] + result; + num = num / 36n; + } + return result; +} diff --git a/packages/util/src/lib/html.ts b/packages/util/src/tool/html.ts similarity index 95% rename from packages/util/src/lib/html.ts rename to packages/util/src/tool/html.ts index 27fd8fe..1151e17 100644 --- a/packages/util/src/lib/html.ts +++ b/packages/util/src/tool/html.ts @@ -2,6 +2,7 @@ import { HTMLNode } from '@idraw/types'; const attrRegExp = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g; +// eslint-disable-next-line no-useless-escape const elemRegExp = /<[a-zA-Z0-9\-\!\/](?:"[^"]*"|'[^']*'|[^'">])*>/g; const whitespaceReg = /^\s*$/; @@ -163,7 +164,7 @@ export function parseHTML(html: string) { function attrString(attrs: HTMLNode['attributes']) { const buff = []; - for (let key in attrs) { + for (const key in attrs) { buff.push(key + '="' + attrs[key] + '"'); } if (!buff.length) { @@ -177,7 +178,11 @@ function stringify(buff: string, htmlNode: HTMLNode): string { case 'text': return buff + htmlNode.textContent; case 'element': - buff += '<' + htmlNode.name + (htmlNode.attributes ? attrString(htmlNode.attributes) : '') + (htmlNode.isVoid ? '/>' : '>'); + buff += + '<' + + htmlNode.name + + (htmlNode.attributes ? attrString(htmlNode.attributes) : '') + + (htmlNode.isVoid ? '/>' : '>'); if (htmlNode.isVoid) { return buff; } diff --git a/packages/util/src/lib/image.ts b/packages/util/src/tool/image.ts similarity index 100% rename from packages/util/src/lib/image.ts rename to packages/util/src/tool/image.ts diff --git a/packages/util/src/lib/istype.ts b/packages/util/src/tool/istype.ts similarity index 100% rename from packages/util/src/lib/istype.ts rename to packages/util/src/tool/istype.ts diff --git a/packages/util/src/lib/merge.ts b/packages/util/src/tool/merge.ts similarity index 100% rename from packages/util/src/lib/merge.ts rename to packages/util/src/tool/merge.ts diff --git a/packages/util/src/lib/number.ts b/packages/util/src/tool/number.ts similarity index 100% rename from packages/util/src/lib/number.ts rename to packages/util/src/tool/number.ts diff --git a/packages/util/src/lib/omit.ts b/packages/util/src/tool/omit.ts similarity index 100% rename from packages/util/src/lib/omit.ts rename to packages/util/src/tool/omit.ts diff --git a/packages/util/src/lib/store.ts b/packages/util/src/tool/store.ts similarity index 96% rename from packages/util/src/lib/store.ts rename to packages/util/src/tool/store.ts index 5637aee..1dd447a 100644 --- a/packages/util/src/lib/store.ts +++ b/packages/util/src/tool/store.ts @@ -1,5 +1,5 @@ import type { RecursivePartial } from '@idraw/types'; -import { deepClone } from './data'; +import { deepClone } from '../view/data'; export class Store< T extends Record = Record, diff --git a/packages/util/src/lib/time.ts b/packages/util/src/tool/time.ts similarity index 100% rename from packages/util/src/lib/time.ts rename to packages/util/src/tool/time.ts diff --git a/packages/util/src/tool/unflat-object.ts b/packages/util/src/tool/unflat-object.ts new file mode 100644 index 0000000..fbdb342 --- /dev/null +++ b/packages/util/src/tool/unflat-object.ts @@ -0,0 +1,121 @@ +type NestedStructure = object | unknown[]; + +/** + * Restores a flattened object to its original nested structure + * @param flatObj Flattened object (e.g., { 'a.b.c': 1, 'a.d[0]': 2 }) + * @returns Nested object structure (e.g., { a: { b: { c: 1 }, d: [2] } }) + */ +export function unflatObject>(flatObj: T): NestedStructure { + const result: NestedStructure = {}; + + for (const [flatKey, value] of Object.entries(flatObj)) { + const pathParts = parseKeyToPath(flatKey); + buildNestedStructure(result, pathParts, value); + } + + return result; +} + +/** + * Improved path parser with better array handling + * @example 'a.b[0].c' => ['a', 'b', '0', 'c'] + */ +function parseKeyToPath(flatKey: string): string[] { + const regex = /([\w-]+)|\[(\d+)\]/g; + const pathParts: string[] = []; + let match: RegExpExecArray | null; + + while ((match = regex.exec(flatKey)) !== null) { + const prop = match[1] || match[2]; + if (prop) { + pathParts.push(prop); + } + } + + return pathParts; +} + +/** + * Enhanced structure builder with array fixes + */ +function buildNestedStructure(currentObj: NestedStructure, pathParts: string[], value: unknown): void { + let currentLevel: any = currentObj; + + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i]; + const isArrayPart = isArrayIndex(part); + const isLast = i === pathParts.length - 1; + + try { + if (isArrayPart) { + validateArrayPath(currentLevel, part); + } else { + validateObjectPath(currentLevel, part); + } + } catch (e: any) { + throw new Error(`Structure conflict at path '${pathParts.slice(0, i + 1).join('.')}': ${e.message}`); + } + + if (isLast) { + assignValue(currentLevel, part, value); + } else { + currentLevel = prepareNextLevel(currentLevel, part, pathParts[i + 1]); + } + } +} + +// Utility functions =============================================== + +function isArrayIndex(key: string): boolean { + return /^\d+$/.test(key); +} + +function validateArrayPath(obj: any, index: string): void { + if (!Array.isArray(obj)) { + throw new Error(`Expected array but found ${typeof obj}`); + } + + const idx = Number(index); + if (idx > obj.length) { + obj.length = idx + 1; + } +} + +function validateObjectPath(obj: any, key: string): void { + if (Array.isArray(obj)) { + throw new Error(`Cannot create object property '${key}' on array`); + } + + if (typeof obj !== 'object' || obj === null) { + throw new Error(`Invalid structure for property '${key}'`); + } +} + +function prepareNextLevel(current: any, part: string, nextPart?: string): any { + const isNextArray = nextPart ? isArrayIndex(nextPart) : false; + + if (Array.isArray(current)) { + const index = Number(part); + if (!current[index]) { + current[index] = isNextArray ? [] : {}; + } + return current[index]; + } + + if (!current[part]) { + current[part] = isNextArray ? [] : {}; + } + return current[part]; +} + +function assignValue(target: any, key: string, value: unknown): void { + if (Array.isArray(target)) { + const index = Number(key); + if (index >= target.length) { + target.length = index + 1; + } + target[index] = value; + } else { + target[key] = value; + } +} diff --git a/packages/util/src/tool/uuid.ts b/packages/util/src/tool/uuid.ts new file mode 100644 index 0000000..37753c3 --- /dev/null +++ b/packages/util/src/tool/uuid.ts @@ -0,0 +1,16 @@ +import { generate32Base36Hash } from './hash'; + +export function createUUID(): string { + function _createStr() { + return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); + } + return `${_createStr()}${_createStr()}-${_createStr()}-${_createStr()}-${_createStr()}-${_createStr()}${_createStr()}${_createStr()}`; +} + +export function createAssetId(assetStr: string): string { + return `@assets/${generate32Base36Hash(assetStr)}`; +} + +export function isAssetId(id: any | string): boolean { + return /^@assets\/[0-9a-z-]{0,}$/.test(`${id}`); +} diff --git a/packages/util/src/lib/box.ts b/packages/util/src/view/box.ts similarity index 100% rename from packages/util/src/lib/box.ts rename to packages/util/src/view/box.ts diff --git a/packages/util/src/lib/canvas.ts b/packages/util/src/view/canvas.ts similarity index 100% rename from packages/util/src/lib/canvas.ts rename to packages/util/src/view/canvas.ts diff --git a/packages/util/src/lib/check.ts b/packages/util/src/view/check.ts similarity index 56% rename from packages/util/src/lib/check.ts rename to packages/util/src/view/check.ts index da0cdda..2255435 100644 --- a/packages/util/src/lib/check.ts +++ b/packages/util/src/view/check.ts @@ -14,13 +14,13 @@ function attrs(attrs: any): boolean { function box(detail: any = {}): boolean { const { borderColor, borderRadius, borderWidth } = detail; - if (detail.hasOwnProperty('borderColor') && !is.color(borderColor)) { + if (Object.prototype.hasOwnProperty.call(detail, 'borderColor') && !is.color(borderColor)) { return false; } - if (detail.hasOwnProperty('borderRadius') && !is.number(borderRadius)) { + if (Object.prototype.hasOwnProperty.call(detail, 'borderRadius') && !is.number(borderRadius)) { return false; } - if (detail.hasOwnProperty('borderWidth') && !is.number(borderWidth)) { + if (Object.prototype.hasOwnProperty.call(detail, 'borderWidth') && !is.number(borderWidth)) { return false; } return true; @@ -28,7 +28,7 @@ function box(detail: any = {}): boolean { function rectDesc(detail: any): boolean { const { background } = detail; - if (detail.hasOwnProperty('background') && !is.color(background)) { + if (Object.prototype.hasOwnProperty.call(detail, 'background') && !is.color(background)) { return false; } if (!box(detail)) { @@ -39,13 +39,13 @@ function rectDesc(detail: any): boolean { function circleDesc(detail: any): boolean { const { background, borderColor, borderWidth } = detail; - if (detail.hasOwnProperty('background') && !is.color(background)) { + if (Object.prototype.hasOwnProperty.call(detail, 'background') && !is.color(background)) { return false; } - if (detail.hasOwnProperty('borderColor') && !is.color(borderColor)) { + if (Object.prototype.hasOwnProperty.call(detail, 'borderColor') && !is.color(borderColor)) { return false; } - if (detail.hasOwnProperty('borderWidth') && !is.number(borderWidth)) { + if (Object.prototype.hasOwnProperty.call(detail, 'borderWidth') && !is.number(borderWidth)) { return false; } return true; @@ -76,7 +76,8 @@ function htmlDesc(detail: any): boolean { } function textDesc(detail: any): boolean { - const { text, color, fontSize, lineHeight, fontFamily, textAlign, fontWeight, background, strokeWidth, strokeColor } = detail; + const { text, color, fontSize, lineHeight, fontFamily, textAlign, fontWeight, background, strokeWidth, strokeColor } = + detail; if (!is.text(text)) { return false; } @@ -86,25 +87,25 @@ function textDesc(detail: any): boolean { if (!is.fontSize(fontSize)) { return false; } - if (detail.hasOwnProperty('background') && !is.color(background)) { + if (Object.prototype.hasOwnProperty.call(detail, 'background') && !is.color(background)) { return false; } - if (detail.hasOwnProperty('fontWeight') && !is.fontWeight(fontWeight)) { + if (Object.prototype.hasOwnProperty.call(detail, 'fontWeight') && !is.fontWeight(fontWeight)) { return false; } - if (detail.hasOwnProperty('lineHeight') && !is.lineHeight(lineHeight)) { + if (Object.prototype.hasOwnProperty.call(detail, 'lineHeight') && !is.lineHeight(lineHeight)) { return false; } - if (detail.hasOwnProperty('fontFamily') && !is.fontFamily(fontFamily)) { + if (Object.prototype.hasOwnProperty.call(detail, 'fontFamily') && !is.fontFamily(fontFamily)) { return false; } - if (detail.hasOwnProperty('textAlign') && !is.textAlign(textAlign)) { + if (Object.prototype.hasOwnProperty.call(detail, 'textAlign') && !is.textAlign(textAlign)) { return false; } - if (detail.hasOwnProperty('strokeWidth') && !is.strokeWidth(strokeWidth)) { + if (Object.prototype.hasOwnProperty.call(detail, 'strokeWidth') && !is.strokeWidth(strokeWidth)) { return false; } - if (detail.hasOwnProperty('strokeColor') && !is.color(strokeColor)) { + if (Object.prototype.hasOwnProperty.call(detail, 'strokeColor') && !is.color(strokeColor)) { return false; } diff --git a/packages/util/src/lib/config.ts b/packages/util/src/view/config.ts similarity index 100% rename from packages/util/src/lib/config.ts rename to packages/util/src/view/config.ts diff --git a/packages/util/src/lib/context2d.ts b/packages/util/src/view/context2d.ts similarity index 100% rename from packages/util/src/lib/context2d.ts rename to packages/util/src/view/context2d.ts diff --git a/packages/util/src/lib/controller.ts b/packages/util/src/view/controller.ts similarity index 86% rename from packages/util/src/lib/controller.ts rename to packages/util/src/view/controller.ts index f4a08d5..a493b0f 100644 --- a/packages/util/src/lib/controller.ts +++ b/packages/util/src/view/controller.ts @@ -1,5 +1,13 @@ -import type { Element, ElementSize, ElementSizeController, ViewRectVertexes, PointSize, ViewScaleInfo, LayoutSizeController } from '@idraw/types'; -import { createUUID } from './uuid'; +import type { + Element, + ElementSize, + ElementSizeController, + ViewRectVertexes, + PointSize, + ViewScaleInfo, + LayoutSizeController +} from '@idraw/types'; +import { createUUID } from '../tool/uuid'; import { getCenterFromTwoPoints } from './point'; import { calcElementVertexesInGroup, calcElementVertexes } from './vertex'; import { calcViewElementSize } from './view-calc'; @@ -85,17 +93,40 @@ export function calcElementSizeController( const topLeftSize = createControllerElementSizeFromCenter(topLeftCenter, { size: ctrlSize, angle: totalAngle }); const topRightSize = createControllerElementSizeFromCenter(topRightCenter, { size: ctrlSize, angle: totalAngle }); const bottomLeftSize = createControllerElementSizeFromCenter(bottomLeftCenter, { size: ctrlSize, angle: totalAngle }); - const bottomRightSize = createControllerElementSizeFromCenter(bottomRightCenter, { size: ctrlSize, angle: totalAngle }); + const bottomRightSize = createControllerElementSizeFromCenter(bottomRightCenter, { + size: ctrlSize, + angle: totalAngle + }); const topLeftVertexes = calcElementVertexes(topLeftSize); const topRightVertexes = calcElementVertexes(topRightSize); const bottomLeftVertexes = calcElementVertexes(bottomLeftSize); const bottomRightVertexes = calcElementVertexes(bottomRightSize); - const topVertexes: ViewRectVertexes = [topLeftVertexes[1], topRightVertexes[0], topRightVertexes[3], topLeftVertexes[2]]; - const rightVertexes: ViewRectVertexes = [topRightVertexes[3], topRightVertexes[2], bottomRightVertexes[1], bottomRightVertexes[0]]; - const bottomVertexes: ViewRectVertexes = [bottomLeftVertexes[1], bottomRightVertexes[0], bottomRightVertexes[3], bottomLeftVertexes[2]]; - const leftVertexes: ViewRectVertexes = [topLeftVertexes[3], topLeftVertexes[2], bottomLeftVertexes[1], bottomLeftVertexes[0]]; + const topVertexes: ViewRectVertexes = [ + topLeftVertexes[1], + topRightVertexes[0], + topRightVertexes[3], + topLeftVertexes[2] + ]; + const rightVertexes: ViewRectVertexes = [ + topRightVertexes[3], + topRightVertexes[2], + bottomRightVertexes[1], + bottomRightVertexes[0] + ]; + const bottomVertexes: ViewRectVertexes = [ + bottomLeftVertexes[1], + bottomRightVertexes[0], + bottomRightVertexes[3], + bottomLeftVertexes[2] + ]; + const leftVertexes: ViewRectVertexes = [ + topLeftVertexes[3], + topLeftVertexes[2], + bottomLeftVertexes[1], + bottomLeftVertexes[0] + ]; const topMiddleVertexes = calcElementVertexes(topMiddleSize); const rightMiddleVertexes = calcElementVertexes(rightMiddleSize); @@ -236,10 +267,30 @@ export function calcLayoutSizeController( const bottomLeftVertexes = calcElementVertexes(bottomLeftSize); const bottomRightVertexes = calcElementVertexes(bottomRightSize); - const topVertexes: ViewRectVertexes = [topLeftVertexes[1], topRightVertexes[0], topRightVertexes[3], topLeftVertexes[2]]; - const rightVertexes: ViewRectVertexes = [topRightVertexes[3], topRightVertexes[2], bottomRightVertexes[1], bottomRightVertexes[0]]; - const bottomVertexes: ViewRectVertexes = [bottomLeftVertexes[1], bottomRightVertexes[0], bottomRightVertexes[3], bottomLeftVertexes[2]]; - const leftVertexes: ViewRectVertexes = [topLeftVertexes[3], topLeftVertexes[2], bottomLeftVertexes[1], bottomLeftVertexes[0]]; + const topVertexes: ViewRectVertexes = [ + topLeftVertexes[1], + topRightVertexes[0], + topRightVertexes[3], + topLeftVertexes[2] + ]; + const rightVertexes: ViewRectVertexes = [ + topRightVertexes[3], + topRightVertexes[2], + bottomRightVertexes[1], + bottomRightVertexes[0] + ]; + const bottomVertexes: ViewRectVertexes = [ + bottomLeftVertexes[1], + bottomRightVertexes[0], + bottomRightVertexes[3], + bottomLeftVertexes[2] + ]; + const leftVertexes: ViewRectVertexes = [ + topLeftVertexes[3], + topLeftVertexes[2], + bottomLeftVertexes[1], + bottomLeftVertexes[0] + ]; const topMiddleVertexes = calcElementVertexes(topMiddleSize); const rightMiddleVertexes = calcElementVertexes(rightMiddleSize); diff --git a/packages/util/src/lib/data.ts b/packages/util/src/view/data.ts similarity index 98% rename from packages/util/src/lib/data.ts rename to packages/util/src/view/data.ts index e4dc667..889363d 100644 --- a/packages/util/src/lib/data.ts +++ b/packages/util/src/view/data.ts @@ -1,5 +1,5 @@ import type { Data, ElementAssets, Elements, ElementType, Element, LoadItemMap } from '@idraw/types'; -import { createAssetId, createUUID, isAssetId } from './uuid'; +import { createAssetId, createUUID, isAssetId } from '../tool/uuid'; export function deepClone(target: T): T { function _clone(t: T) { diff --git a/packages/util/src/lib/element.ts b/packages/util/src/view/element.ts similarity index 96% rename from packages/util/src/lib/element.ts rename to packages/util/src/view/element.ts index 41895d5..59ca536 100644 --- a/packages/util/src/lib/element.ts +++ b/packages/util/src/view/element.ts @@ -12,7 +12,7 @@ import type { ElementPosition } from '@idraw/types'; import { limitAngle, rotateElementVertexes } from './rotate'; -import { isAssetId, createAssetId } from './uuid'; +import { isAssetId, createAssetId } from '../tool/uuid'; function getGroupUUIDs(elements: Array>, index: string): string[] { const uuids: string[] = []; @@ -203,7 +203,11 @@ export function calcElementsViewInfo( ): { contextSize: ViewContextSize; } { - const contextSize = calcElementsContextSize(elements, { viewWidth: prevViewSize.width, viewHeight: prevViewSize.height, extend: options?.extend }); + const contextSize = calcElementsContextSize(elements, { + viewWidth: prevViewSize.width, + viewHeight: prevViewSize.height, + extend: options?.extend + }); if (options?.extend === true) { contextSize.contextWidth = Math.max(contextSize.contextWidth, prevViewSize.contextWidth); contextSize.contextHeight = Math.max(contextSize.contextHeight, prevViewSize.contextHeight); @@ -293,7 +297,10 @@ export function getGroupQueueFromList(uuid: string, elements: Element[], position: ElementPosition): Element<'group'>[] | null { +export function getGroupQueueByElementPosition( + elements: Element[], + position: ElementPosition +): Element<'group'>[] | null { const groupQueue: Element<'group'>[] = []; let currentElements: Element[] = elements; if (position.length > 1) { @@ -408,7 +415,7 @@ export function findElementFromListByPosition(position: ElementPosition, list: E for (let i = 0; i < position.length; i++) { const pos = position[i]; const item = tempList[pos]; - if (i < position.length - 1 && item.type === 'group') { + if (i < position.length - 1 && item?.type === 'group') { tempList = (item as Element<'group'>).detail.children; } else if (i === position.length - 1) { result = item; @@ -507,6 +514,10 @@ export function getElementPositionMapFromList( export function isSameElementSize(elem1: ElementSize, elem2: ElementSize) { return ( - elem1.x === elem2.x && elem1.y === elem2.y && elem1.h === elem2.h && elem1.w === elem2.w && limitAngle(elem1.angle || 0) === limitAngle(elem2.angle || 0) + elem1.x === elem2.x && + elem1.y === elem2.y && + elem1.h === elem2.h && + elem1.w === elem2.w && + limitAngle(elem1.angle || 0) === limitAngle(elem2.angle || 0) ); } diff --git a/packages/util/src/lib/flat.ts b/packages/util/src/view/flat.ts similarity index 98% rename from packages/util/src/lib/flat.ts rename to packages/util/src/view/flat.ts index cdb662b..0db6101 100644 --- a/packages/util/src/lib/flat.ts +++ b/packages/util/src/view/flat.ts @@ -9,7 +9,8 @@ function flatElementSize( } ): ElementSize { const { groupQueue } = opts; - let { x, y, w, h, angle = 0 } = elemSize; + let { x, y } = elemSize; + const { w, h, angle = 0 } = elemSize; let totalAngle = 0; groupQueue.forEach((group) => { x += group.x; diff --git a/packages/util/src/lib/group.ts b/packages/util/src/view/group.ts similarity index 98% rename from packages/util/src/lib/group.ts rename to packages/util/src/view/group.ts index c45adfe..ca7a297 100644 --- a/packages/util/src/lib/group.ts +++ b/packages/util/src/view/group.ts @@ -1,7 +1,7 @@ import type { Elements, Element, ElementPosition } from '@idraw/types'; import { findElementFromListByPosition, calcElementListSize } from './element'; import { deleteElementInListByPosition, insertElementToListByPosition } from './handle-element'; -import { createUUID } from './uuid'; +import { createUUID } from '../tool/uuid'; export function groupElementsByPosition(list: Elements, positions: ElementPosition[]): Elements { if (positions.length > 1) { diff --git a/packages/util/src/lib/handle-element.ts b/packages/util/src/view/handle-element.ts similarity index 80% rename from packages/util/src/lib/handle-element.ts rename to packages/util/src/view/handle-element.ts index f4f13cc..1534402 100644 --- a/packages/util/src/lib/handle-element.ts +++ b/packages/util/src/view/handle-element.ts @@ -1,6 +1,14 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -import type { RecursivePartial, Element, Elements, ElementPosition, ElementSize, ElementType, ViewScaleInfo, ViewSizeInfo } from '@idraw/types'; -import { createUUID } from './uuid'; +import type { + RecursivePartial, + Element, + Elements, + ElementPosition, + ElementSize, + ElementType, + ViewScaleInfo, + ViewSizeInfo +} from '@idraw/types'; +import { createUUID } from '../tool/uuid'; import { defaultText, getDefaultElementRectDetail, @@ -10,7 +18,8 @@ import { getDefaultElementImageDetail, getDefaultElementGroupDetail } from './config'; -import { istype } from './istype'; +import { toFlattenElement } from './modify-record'; +import { set, del } from '../tool/get-set-del'; import { findElementFromListByPosition, getElementPositionFromList } from './element'; import { deepResizeGroupElement } from './resize-element'; @@ -18,7 +27,10 @@ const defaultViewWidth = 200; const defaultViewHeight = 200; // const defaultDetail = getDefaultElementDetailConfig(); -function createElementSize(type: ElementType, opts?: { viewScaleInfo: ViewScaleInfo; viewSizeInfo: ViewSizeInfo }): ElementSize { +function createElementSize( + type: ElementType, + opts?: { viewScaleInfo: ViewScaleInfo; viewSizeInfo: ViewSizeInfo } +): ElementSize { let x = 0; let y = 0; let w = defaultViewWidth; @@ -87,9 +99,9 @@ export function createElement( detail = getDefaultElementGroupDetail(); } const elem: Element = { + uuid: createUUID(), ...elementSize, ...baseElem, - uuid: createUUID(), type, detail: { ...detail, @@ -170,7 +182,7 @@ export function moveElementPosition( return { elements, from, to }; } - // [1] -> [1, 2, 3] + // invalid [1] -> [1, 2, 3] if (from.length <= to.length) { for (let i = 0; i < from.length; i++) { if (to[i] === from[i]) { @@ -273,47 +285,47 @@ export function moveElementPosition( return { elements, from, to }; } -function mergeElement = Element>(originElem: T, updateContent: RecursivePartial): T { - const commonKeys = Object.keys(updateContent); - for (let i = 0; i < commonKeys.length; i++) { - const commonKey = commonKeys[i]; - if (['x', 'y', 'w', 'h', 'angle', 'name'].includes(commonKey)) { - // @ts-ignore - originElem[commonKey] = updateContent[commonKey]; - } else if (['detail', 'operations'].includes(commonKey)) { - // @ts-ignore - if (istype.json(updateContent[commonKey] as any)) { - if (!(originElem as unknown)?.hasOwnProperty(commonKey)) { - // @ts-ignore - originElem[commonKey] = {}; - } - // @ts-ignore - if (istype.json(originElem[commonKey])) { - // @ts-ignore - originElem[commonKey] = { ...originElem[commonKey], ...updateContent[commonKey] }; - } - // @ts-ignore - } else if (istype.array(updateContent[commonKey] as any)) { - if (!(originElem as unknown)?.hasOwnProperty(commonKey)) { - // @ts-ignore - originElem[commonKey] = []; - } - // @ts-ignore - if (istype.array(originElem[commonKey])) { - ((updateContent as any)?.[commonKey] as Array)?.forEach((item, i) => { - // @ts-ignore - originElem[commonKey][i] = item; - }); - // @ts-ignore - originElem[commonKey] = [...originElem[commonKey], ...updateContent[commonKey]]; - } +export function mergeElement = Element>( + originElem: T, + updateContent: RecursivePartial, + opts?: { + strict?: boolean; + } +): T { + const updatedFlatten = toFlattenElement(updateContent); + const ignoreKeys = ['uuid', 'type']; + + const updatedKeys = Object.keys(updatedFlatten); + updatedKeys.forEach((key) => { + if (!ignoreKeys.includes(key)) { + const value = updatedFlatten[key]; + del(originElem, key); + if (value !== undefined) { + set(originElem, key, value); } } + }); + + if (opts?.strict === true) { + const originFlatten = toFlattenElement(originElem); + const originKeys = Object.keys(originFlatten); + originKeys.forEach((key) => { + if (!ignoreKeys.includes(key)) { + if (!updatedKeys.includes(key)) { + del(originElem, key); + } + } + }); } + return originElem; } -export function updateElementInList(uuid: string, updateContent: RecursivePartial>, elements: Element[]): Element | null { +export function updateElementInList( + uuid: string, + updateContent: RecursivePartial>, + elements: Element[] +): Element | null { let targetElement: Element | null = null; for (let i = 0; i < elements.length; i++) { const elem = elements[i]; @@ -340,7 +352,8 @@ export function updateElementInList(uuid: string, updateContent: RecursivePartia export function updateElementInListByPosition( position: ElementPosition, updateContent: RecursivePartial>, - elements: Element[] + elements: Element[], + opts?: { strict?: boolean } ): Element | null { const elem: Element | null = findElementFromListByPosition(position, elements); if (elem) { @@ -352,7 +365,7 @@ export function updateElementInListByPosition( }); } } - mergeElement(elem, updateContent); + mergeElement(elem, updateContent, opts); } return elem; } diff --git a/packages/util/src/view/handle-global.ts b/packages/util/src/view/handle-global.ts new file mode 100644 index 0000000..30abe21 --- /dev/null +++ b/packages/util/src/view/handle-global.ts @@ -0,0 +1,39 @@ +import type { RecursivePartial, DataGlobal } from '@idraw/types'; +import { toFlattenGlobal } from './modify-record'; +import { set, del } from '../tool/get-set-del'; + +export function mergeGlobal( + originGlobal: DataGlobal, + updateContent: RecursivePartial, + opts?: { + strict?: boolean; + } +): DataGlobal { + const updatedFlatten = toFlattenGlobal(updateContent); + const ignoreKeys: string[] = []; // TODO + + const updatedKeys = Object.keys(updatedFlatten); + updatedKeys.forEach((key) => { + if (!ignoreKeys.includes(key)) { + const value = updatedFlatten[key]; + del(originGlobal, key); + if (value !== undefined) { + set(originGlobal, key, value); + } + } + }); + + if (opts?.strict === true) { + const originFlatten = toFlattenGlobal(originGlobal); + const originKeys = Object.keys(originFlatten); + originKeys.forEach((key) => { + if (!ignoreKeys.includes(key)) { + if (!updatedKeys.includes(key)) { + del(originGlobal, key); + } + } + }); + } + + return originGlobal; +} diff --git a/packages/util/src/view/handle-layout.ts b/packages/util/src/view/handle-layout.ts new file mode 100644 index 0000000..ff35833 --- /dev/null +++ b/packages/util/src/view/handle-layout.ts @@ -0,0 +1,39 @@ +import type { RecursivePartial, DataLayout } from '@idraw/types'; +import { toFlattenLayout } from './modify-record'; +import { set, del } from '../tool/get-set-del'; + +export function mergeLayout( + originLayout: DataLayout, + updateContent: RecursivePartial, + opts?: { + strict?: boolean; + } +): DataLayout { + const updatedFlatten = toFlattenLayout(updateContent); + const ignoreKeys: string[] = []; // TODO + + const updatedKeys = Object.keys(updatedFlatten); + updatedKeys.forEach((key) => { + if (!ignoreKeys.includes(key)) { + const value = updatedFlatten[key]; + del(originLayout, key); + if (value !== undefined) { + set(originLayout, key, value); + } + } + }); + + if (opts?.strict === true) { + const originFlatten = toFlattenLayout(originLayout); + const originKeys = Object.keys(originFlatten); + originKeys.forEach((key) => { + if (!ignoreKeys.includes(key)) { + if (!updatedKeys.includes(key)) { + del(originLayout, key); + } + } + }); + } + + return originLayout; +} diff --git a/packages/util/src/lib/is.ts b/packages/util/src/view/is.ts similarity index 98% rename from packages/util/src/lib/is.ts rename to packages/util/src/view/is.ts index 9521a3d..46a6791 100644 --- a/packages/util/src/lib/is.ts +++ b/packages/util/src/view/is.ts @@ -1,4 +1,4 @@ -import { isColorStr } from './color'; +import { isColorStr } from '../tool/color'; function positiveNum(value: any) { return typeof value === 'number' && value >= 0; diff --git a/packages/util/src/lib/load.ts b/packages/util/src/view/load.ts similarity index 100% rename from packages/util/src/lib/load.ts rename to packages/util/src/view/load.ts diff --git a/packages/util/src/lib/matrix.ts b/packages/util/src/view/matrix.ts similarity index 100% rename from packages/util/src/lib/matrix.ts rename to packages/util/src/view/matrix.ts diff --git a/packages/util/src/lib/middleware.ts b/packages/util/src/view/middleware.ts similarity index 100% rename from packages/util/src/lib/middleware.ts rename to packages/util/src/view/middleware.ts diff --git a/packages/util/src/view/modify-record.ts b/packages/util/src/view/modify-record.ts new file mode 100644 index 0000000..3da0dce --- /dev/null +++ b/packages/util/src/view/modify-record.ts @@ -0,0 +1,14 @@ +import type { FlattenElement, Element, RecursivePartial, DataLayout, DataGlobal } from '@idraw/types'; +import { flatObject } from '../tool/flat-object'; + +export function toFlattenElement(elem: Element | RecursivePartial): FlattenElement { + return flatObject(elem, { ignorePaths: ['detail.children'] }); +} + +export function toFlattenLayout(layout: DataLayout | RecursivePartial): FlattenElement { + return flatObject(layout); +} + +export function toFlattenGlobal(global: DataGlobal | RecursivePartial): FlattenElement { + return flatObject(global); +} diff --git a/packages/util/src/lib/parser.ts b/packages/util/src/view/parser.ts similarity index 100% rename from packages/util/src/lib/parser.ts rename to packages/util/src/view/parser.ts diff --git a/packages/util/src/lib/point-move-element.ts b/packages/util/src/view/point-move-element.ts similarity index 100% rename from packages/util/src/lib/point-move-element.ts rename to packages/util/src/view/point-move-element.ts diff --git a/packages/util/src/lib/point.ts b/packages/util/src/view/point.ts similarity index 100% rename from packages/util/src/lib/point.ts rename to packages/util/src/view/point.ts diff --git a/packages/util/src/view/position.ts b/packages/util/src/view/position.ts new file mode 100644 index 0000000..aaf7f9b --- /dev/null +++ b/packages/util/src/view/position.ts @@ -0,0 +1,106 @@ +import type { ElementPosition } from '@idraw/types'; + +export function calcResultMovePosition(opts: { from: ElementPosition; to: ElementPosition }): { + from: ElementPosition; + to: ElementPosition; +} | null { + const from = [...opts.from]; + const to = [...opts.to]; + + // [] -> [1,2,3] or [1, 2 ,3] -> [] + if (from.length === 0 || to.length === 0) { + return null; + } + + // invalid [1] -> [1, 2, 3] + if (from.length <= to.length) { + for (let i = 0; i < from.length; i++) { + if (to[i] === from[i]) { + if (i === from.length - 1) { + return null; + } + continue; + } + } + } + + let moveDirection: 'up-down' | 'down-up' | null = null; + + if (from.length >= 1 && to.length >= 1) { + // isEffectToIndex + // false [2, 4] -> [1, 2] + // false [3, 4, 5] -> [4, 5] + + // up -> down + // true [2] -> [4] + // true [2] -> [3, 4] + // true [2, 3] -> [2, 3, 4] + if (from.length <= to.length) { + if (from.length === 1) { + if (from[0] < to[0]) { + moveDirection = 'up-down'; + } + } else { + for (let i = 0; i < from.length; i++) { + if (from[i] === to[i]) { + if (from.length === from.length - 1) { + moveDirection = 'up-down'; + break; + } + } else { + break; + } + } + } + } + + // down -> up + // true [4] -> [2] + // true [3, 4, 5] -> [3, 3] + // true [3, 4, 5] -> [2] + if (from.length >= to.length) { + if (to.length === 1) { + if (to[0] < from[0]) { + // isEffectToIndex = true; + moveDirection = 'down-up'; + } + } else { + for (let i = 0; i < to.length; i++) { + if (i === to.length - 1 && to[i] < from[i]) { + // isEffectToIndex = true; + moveDirection = 'down-up'; + } + if (from[i] === to[i]) { + continue; + } else { + break; + } + } + } + } + } + + const startEffectIndex = from.length - 1; + const endEffectIndex = to.length - 1; + if (moveDirection === 'up-down' && startEffectIndex >= 0) { + to[startEffectIndex] -= 1; + } else if (moveDirection === 'down-up' && endEffectIndex >= 0) { + from[endEffectIndex] += 1; + } + + return { from, to }; +} + +export function calcRevertMovePosition(opts: { from: ElementPosition; to: ElementPosition }): { + from: ElementPosition; + to: ElementPosition; +} | null { + const result = calcResultMovePosition(opts); + if (!result) { + return result; + } + return { + from: [...result.to], + to: [...result.from] + }; +} diff --git a/packages/util/src/lib/rect.ts b/packages/util/src/view/rect.ts similarity index 100% rename from packages/util/src/lib/rect.ts rename to packages/util/src/view/rect.ts diff --git a/packages/util/src/lib/resize-element.ts b/packages/util/src/view/resize-element.ts similarity index 95% rename from packages/util/src/lib/resize-element.ts rename to packages/util/src/view/resize-element.ts index 961e8b8..310c074 100644 --- a/packages/util/src/lib/resize-element.ts +++ b/packages/util/src/view/resize-element.ts @@ -1,5 +1,5 @@ import type { Element, ElementSize } from '@idraw/types'; -import { formatNumber } from './number'; +import { formatNumber } from '../tool/number'; const doNum = (n: number) => { return formatNumber(n, { decimalPlaces: 4 }); @@ -97,7 +97,10 @@ function resizeElement(elem: Element, opts: ResizeOptions) { } } -export function deepResizeGroupElement(elem: Element<'group'>, size: Pick, 'w' | 'h'>): Element<'group'> { +export function deepResizeGroupElement( + elem: Element<'group'>, + size: Pick, 'w' | 'h'> +): Element<'group'> { const resizeW: number = size.w && size.w > 0 ? size.w : elem.w; const resizeH: number = size.h && size.h > 0 ? size.h : elem.h; const xRatio = resizeW / elem.w; diff --git a/packages/util/src/lib/rotate.ts b/packages/util/src/view/rotate.ts similarity index 100% rename from packages/util/src/lib/rotate.ts rename to packages/util/src/view/rotate.ts diff --git a/packages/util/src/lib/svg-path.ts b/packages/util/src/view/svg-path.ts similarity index 100% rename from packages/util/src/lib/svg-path.ts rename to packages/util/src/view/svg-path.ts diff --git a/packages/util/src/lib/text.ts b/packages/util/src/view/text.ts similarity index 100% rename from packages/util/src/lib/text.ts rename to packages/util/src/view/text.ts diff --git a/packages/util/src/lib/vertex.ts b/packages/util/src/view/vertex.ts similarity index 100% rename from packages/util/src/lib/vertex.ts rename to packages/util/src/view/vertex.ts diff --git a/packages/util/src/lib/view-box.ts b/packages/util/src/view/view-box.ts similarity index 100% rename from packages/util/src/lib/view-box.ts rename to packages/util/src/view/view-box.ts diff --git a/packages/util/src/lib/view-calc.ts b/packages/util/src/view/view-calc.ts similarity index 100% rename from packages/util/src/lib/view-calc.ts rename to packages/util/src/view/view-calc.ts diff --git a/packages/util/src/lib/view-content.ts b/packages/util/src/view/view-content.ts similarity index 98% rename from packages/util/src/lib/view-content.ts rename to packages/util/src/view/view-content.ts index 9e6673b..38974e3 100644 --- a/packages/util/src/lib/view-content.ts +++ b/packages/util/src/view/view-content.ts @@ -1,7 +1,7 @@ import type { Data, ViewSizeInfo, Element, ElementSize, ViewScaleInfo, PointSize } from '@idraw/types'; import { rotateElementVertexes } from './rotate'; import {} from './view-calc'; -import { formatNumber } from './number'; +import { formatNumber } from '../tool/number'; import { is } from './is'; interface ViewCenterContentResult { diff --git a/tsconfig.web.json b/tsconfig.web.json index abc8665..aa97f60 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -3,8 +3,8 @@ "jsx": "react", "declaration": true, "sourceMap": false, - "target": "ES6", - "module": "ES2015", + "target": "es2020", + "module": "esnext", "moduleResolution": "node", "allowJs": false, "strict": true, @@ -18,14 +18,13 @@ "@idraw/types": ["./packages/types/src/index.ts"], "@idraw/util": ["./packages/util/src/index.ts"], "@idraw/renderer": ["./packages/renderer/src/index.ts"], - "@idraw/board": ["./packages/board/src/index.ts"], - "@idraw/core": ["./packages/core/src/index.ts"] + "@idraw/core": ["./packages/core/src/index.ts"], + "idraw": ["./packages/idraw/src/index.ts"] } }, "include": [ "packages/*/src", - "packages/*/__tests__", - "packages/*/dev" + "packages/*/__tests__" ], "exclude": [ "packages/*/dist",