feat: add history and fix text-editor middleware

This commit is contained in:
chenshenhai 2025-05-10 15:20:55 +08:00
parent a901ab205c
commit 8a04ea0106
133 changed files with 4489 additions and 2904 deletions

View file

@ -20,15 +20,19 @@ module.exports = {
modulePaths: ['<rootDir>'],
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': '<rootDir>/packages/util/src/index.ts'
'@idraw/types': '<rootDir>/packages/types/src/index.ts',
'@idraw/util': '<rootDir>/packages/util/src/index.ts',
'@idraw/renderer': '<rootDir>/packages/renderer/src/index.ts',
'@idraw/core': '<rootDir>/packages/core/src/index.ts',
'^idraw$': '<rootDir>/packages/idraw/src/index.ts'
},
// "testRegex": "(/packages/idraw/__tests__/.*)\\.test.ts$",
setupFiles: ['jest-canvas-mock']
setupFiles: ['jest-canvas-mock', './jest.setup.js']
};

29
jest.setup.js Normal file
View file

@ -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;
}
};

View file

@ -1,6 +1,6 @@
{
"private": false,
"version": "0.4.0-beta.40",
"version": "0.4.0-beta.41",
"workspaces": [
"packages/*"
],

View file

@ -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<T extends BoardExtendEventMap = BoardExtendEventMap> {
#opts: BoardOptions;
#middlewareMap: WeakMap<BoardMiddleware, BoardMiddlewareMapItem> = new WeakMap();
#middlewares: BoardMiddleware[] = [];
#middlewareMap: Map<BoardMiddleware, BoardMiddlewareMapItem> = new Map();
// #middlewares: BoardMiddleware[] = [];
#activeMiddlewareObjs: BoardMiddlewareObject[] = [];
#watcher: BoardWatcher;
#renderer: Renderer;
@ -291,13 +290,12 @@ export class Board<T extends BoardExtendEventMap = BoardExtendEventMap> {
#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<T extends BoardExtendEventMap = BoardExtendEventMap> {
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<T extends BoardExtendEventMap = BoardExtendEventMap> {
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<T extends BoardExtendEventMap = BoardExtendEventMap> {
use<C extends any = any>(middleware: BoardMiddleware<any, any, any>, config?: Partial<C>) {
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<T extends BoardExtendEventMap = BoardExtendEventMap> {
config
);
obj.use?.();
this.#middlewares.push(middleware);
this.#activeMiddlewareObjs.push(obj);
this.#middlewareMap.set(middleware, {
status: 'enable',
@ -391,11 +375,13 @@ export class Board<T extends BoardExtendEventMap = BoardExtendEventMap> {
}
disuse(middleware: BoardMiddleware<any, any>) {
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();
}
}

View file

@ -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<E extends CoreEventMap = CoreEventMap> {
container.style.position = 'relative';
}
use<C extends any = any>(middleware: BoardMiddleware<any, any, any>, config?: C) {
use<C extends any = any>(middleware: Middleware<any, any, any>, config?: C) {
this.#board.use<C>(middleware, config);
}
disuse(middleware: BoardMiddleware<any, any, any>) {
disuse(middleware: Middleware<any, any, any>) {
this.#board.disuse(middleware);
}
resetMiddlewareConfig<C extends any = any>(middleware: BoardMiddleware<any, any, any>, config?: Partial<C>) {
resetMiddlewareConfig<C extends any = any>(middleware: Middleware<any, any, any>, config?: Partial<C>) {
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<E extends CoreEventMap = CoreEventMap> {
offBoardWatcherEvents() {
this.#board.offWatcherEvents();
}
createElement<T extends ElementType = ElementType>(
type: T,
element: RecursivePartial<Element<T>>,
opts?: {
viewCenter?: boolean;
}
): Element<T> {
const { viewScaleInfo, viewSizeInfo } = this.getViewInfo();
return createElement<T>(
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<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>
): 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<RecursivePartial<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>>
): 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<DataLayout> | 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<DataGlobal> | 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;
}
}

View file

@ -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<DraggerSharedStorage, CoreEventMap> = (opts) => {
export const MiddlewareDragger: Middleware<DraggerSharedStorage, CoreEventMap> = (opts) => {
const { eventHub, sharer, viewer } = opts;
let isDragging = false;

View file

@ -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';

View file

@ -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 };

View file

@ -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<ElementSize> | 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);

View file

@ -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);

View file

@ -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<DeepPointerSharedStorage, CoreEventMap> = (opts) => {
export const MiddlewarePointer: Middleware<DeepPointerSharedStorage, CoreEventMap> = (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<DeepPointerSharedStorage, CoreEv
return { left, top, width, height };
};
const contextMenuPointer = document.createElement('div');
contextMenuPointer.setAttribute('id', id);
contextMenuPointer.style.position = 'fixed';
contextMenuPointer.style.top = '0';
contextMenuPointer.style.bottom = 'unset';
contextMenuPointer.style.left = '0';
contextMenuPointer.style.right = 'unset';
// // TODO
// contextMenuPointer.style.width = '10px';
// contextMenuPointer.style.height = '10px';
// contextMenuPointer.style.background = 'red';
container.appendChild(contextMenuPointer);
let contextMenuPointer = document.createElement('div');
return {
name: '@middleware/pointer',
use() {
// TODO
contextMenuPointer.setAttribute('id', id);
contextMenuPointer.style.position = 'fixed';
contextMenuPointer.style.top = '0';
contextMenuPointer.style.bottom = 'unset';
contextMenuPointer.style.left = '0';
contextMenuPointer.style.right = 'unset';
container.appendChild(contextMenuPointer);
},
disuse() {
// TODO
},
pointStart(e) {
// TODO
},
pointEnd() {
// TODO
container.removeChild(contextMenuPointer);
contextMenuPointer.remove();
contextMenuPointer = null as any;
},
contextMenu(e) {
const { point } = e;

View file

@ -12,7 +12,7 @@ const scaleColor = '#000000';
const textColor = '#00000080';
const gridColor = '#AAAAAA20';
const gridPrimaryColor = '#AAAAAA40';
const selectedAreaColor = '#196097';
const selectedAreaColor = '#19609780';
export const defaultStyle: MiddlewareRulerStyle = {
background,

View file

@ -1,11 +1,22 @@
import type { BoardMiddleware, CoreEventMap, MiddlewareRulerConfig } from '@idraw/types';
import type { Middleware, CoreEventMap, MiddlewareRulerConfig } from '@idraw/types';
import { getViewScaleInfoFromSnapshot, getViewSizeInfoFromSnapshot } from '@idraw/util';
import { drawRulerBackground, drawXRuler, drawYRuler, calcXRulerScaleList, calcYRulerScaleList, drawGrid, drawScrollerSelectedArea } from './util';
import {
drawRulerBackground,
drawXRuler,
drawYRuler,
calcXRulerScaleList,
calcYRulerScaleList,
drawGrid,
drawScrollerSelectedArea
} from './util';
import type { DeepRulerSharedStorage } from './types';
import { defaultStyle } from './config';
import { coreEventKeys } from '../../config';
export const MiddlewareRuler: BoardMiddleware<DeepRulerSharedStorage, CoreEventMap, MiddlewareRulerConfig> = (opts, config) => {
export const MiddlewareRuler: Middleware<DeepRulerSharedStorage, CoreEventMap, MiddlewareRulerConfig> = (
opts,
config
) => {
const { boardContent, viewer, eventHub, calculator } = opts;
const { overlayContext, underlayContext } = boardContent;
let innerConfig = {
@ -45,7 +56,8 @@ export const MiddlewareRuler: BoardMiddleware<DeepRulerSharedStorage, CoreEventM
},
beforeDrawFrame: ({ snapshot }) => {
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<DeepRulerSharedStorage, CoreEventM
const viewScaleInfo = getViewScaleInfoFromSnapshot(snapshot);
const viewSizeInfo = getViewSizeInfoFromSnapshot(snapshot);
drawScrollerSelectedArea(overlayContext, { snapshot, calculator, style });
drawRulerBackground(overlayContext, { viewScaleInfo, viewSizeInfo, style });
drawScrollerSelectedArea(overlayContext, { snapshot, calculator, style });
const { list: xList, rulerUnit } = calcXRulerScaleList({ viewScaleInfo, viewSizeInfo });
drawXRuler(overlayContext, { scaleList: xList, style });

View file

@ -51,7 +51,10 @@ function limitRulerUnit(unit: number): number {
return unit;
}
function calcRulerScaleList(opts: { axis: 'X' | 'Y'; scale: number; viewLength: number; viewOffset: number }): { list: RulerScale[]; rulerUnit: number } {
function calcRulerScaleList(opts: { axis: 'X' | 'Y'; scale: number; viewLength: number; viewOffset: number }): {
list: RulerScale[];
rulerUnit: number;
} {
const { scale, viewLength, viewOffset } = opts;
const list: RulerScale[] = [];
let rulerUnit = 10;
@ -87,7 +90,10 @@ function calcRulerScaleList(opts: { axis: 'X' | 'Y'; scale: number; viewLength:
return { list, rulerUnit };
}
export function calcXRulerScaleList(opts: { viewScaleInfo: ViewScaleInfo; viewSizeInfo: ViewSizeInfo }): { list: RulerScale[]; rulerUnit: number } {
export function calcXRulerScaleList(opts: { viewScaleInfo: ViewScaleInfo; viewSizeInfo: ViewSizeInfo }): {
list: RulerScale[];
rulerUnit: number;
} {
const { viewScaleInfo, viewSizeInfo } = opts;
const { scale, offsetLeft } = viewScaleInfo;
const { width } = viewSizeInfo;
@ -99,7 +105,10 @@ export function calcXRulerScaleList(opts: { viewScaleInfo: ViewScaleInfo; viewSi
});
}
export function calcYRulerScaleList(opts: { viewScaleInfo: ViewScaleInfo; viewSizeInfo: ViewSizeInfo }): { list: RulerScale[]; rulerUnit: number } {
export function calcYRulerScaleList(opts: { viewScaleInfo: ViewScaleInfo; viewSizeInfo: ViewSizeInfo }): {
list: RulerScale[];
rulerUnit: number;
} {
const { viewScaleInfo, viewSizeInfo } = opts;
const { scale, offsetTop } = viewScaleInfo;
const { height } = viewSizeInfo;
@ -222,7 +231,7 @@ export function drawRulerBackground(
ctx.closePath();
ctx.fillStyle = background;
ctx.fill();
ctx.fill('nonzero');
ctx.lineWidth = lineSize;
ctx.setLineDash([]);
ctx.strokeStyle = borderColor;
@ -277,7 +286,11 @@ export function drawGrid(
export function drawScrollerSelectedArea(
ctx: ViewContext2D,
opts: { snapshot: BoardViewerFrameSnapshot<DeepRulerSharedStorage>; calculator: ViewCalculator; style: MiddlewareRulerStyle }
opts: {
snapshot: BoardViewerFrameSnapshot<DeepRulerSharedStorage>;
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');
}
}

View file

@ -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<Record<string, any>, CoreEventMap> = (opts) => {
export const MiddlewareScaler: Middleware<Record<string, any>, CoreEventMap> = (opts) => {
const { viewer, sharer, eventHub } = opts;
const maxScale = 50;
const minScale = 0.05;

View file

@ -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<DeepScrollerSharedStorage, any, MiddlewareScrollerConfig> = (opts, config) => {
export const MiddlewareScroller: Middleware<DeepScrollerSharedStorage, any, MiddlewareScrollerConfig> = (
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<DeepScrollerSharedStorage, any,
}
},
beforeDrawFrame({ snapshot }) {
const { thumbBackground, thumbBorderColor, hoverThumbBackground, hoverThumbBorderColor, activeThumbBackground, activeThumbBorderColor } = innerConfig;
const {
thumbBackground,
thumbBorderColor,
hoverThumbBackground,
hoverThumbBorderColor,
activeThumbBackground,
activeThumbBorderColor
} = innerConfig;
const style = {
thumbBackground,

View file

@ -201,7 +201,7 @@ function drawScrollerThumb(
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.fillStyle = background;
ctx.fill();
ctx.fill('nonzero');
ctx.beginPath();
ctx.lineWidth = 1;

View file

@ -19,10 +19,15 @@ export function drawVertexes(
ctx.lineTo(vertexes[0].x, vertexes[0].y);
ctx.closePath();
ctx.stroke();
ctx.fill();
ctx.fill('nonzero');
}
export function drawLine(ctx: ViewContext2D, start: PointSize, end: PointSize, opts: { borderColor: string; borderWidth: number; lineDash: number[] }) {
export function drawLine(
ctx: ViewContext2D,
start: PointSize,
end: PointSize,
opts: { borderColor: string; borderWidth: number; lineDash: number[] }
) {
const { borderColor, borderWidth, lineDash } = opts;
ctx.setLineDash([]);
ctx.lineWidth = borderWidth;
@ -66,7 +71,7 @@ export function drawCircleController(
ctx.fillStyle = background;
ctx.circle(center.x, center.y, a, b, 0, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
ctx.fill('nonzero');
}
// ctx.setLineDash([]);
@ -82,10 +87,14 @@ export function drawCircleController(
// ctx.lineTo(vertexes[0].x, vertexes[0].y);
// ctx.closePath();
// ctx.stroke();
// ctx.fill();
// ctx.fill('nonzero');
}
export function drawCrossVertexes(ctx: ViewContext2D, vertexes: ViewRectVertexes, opts: { borderColor: string; borderWidth: number; lineDash: number[] }) {
export function drawCrossVertexes(
ctx: ViewContext2D,
vertexes: ViewRectVertexes,
opts: { borderColor: string; borderWidth: number; lineDash: number[] }
) {
const { borderColor, borderWidth, lineDash } = opts;
ctx.setLineDash([]);
ctx.lineWidth = borderWidth;
@ -104,7 +113,11 @@ export function drawCrossVertexes(ctx: ViewContext2D, vertexes: ViewRectVertexes
ctx.stroke();
}
export function drawCrossByCenter(ctx: ViewContext2D, center: PointSize, opts: { size: number; borderColor: string; borderWidth: number; lineDash: number[] }) {
export function drawCrossByCenter(
ctx: ViewContext2D,
center: PointSize,
opts: { size: number; borderColor: string; borderWidth: number; lineDash: number[] }
) {
const { size, borderColor, borderWidth, lineDash } = opts;
const minX = center.x - size / 2;
const maxX = center.x + size / 2;

View file

@ -22,7 +22,7 @@ function drawDebugControllerVertexes(opts: {
ctx.lineTo(v3.x, v3.y);
ctx.lineTo(v0.x, v0.y);
ctx.closePath();
ctx.fill();
ctx.fill('nonzero');
return false;
}

View file

@ -99,7 +99,12 @@ export function drawSelectedElementControllersVertexes(
const { devicePixelRatio = 1 } = viewSizeInfo;
const { activeColor } = style;
const { elementWrapper, topLeft, topRight, bottomLeft, bottomRight, rotate } = controller;
const wrapperOpts = { borderColor: activeColor, borderWidth: selectWrapperBorderWidth, background: 'transparent', lineDash: [] };
const wrapperOpts = {
borderColor: activeColor,
borderWidth: selectWrapperBorderWidth,
background: 'transparent',
lineDash: []
};
const ctrlOpts = { ...wrapperOpts, borderWidth: resizeControllerBorderWidth, background: '#FFFFFF' };
drawVertexes(ctx, calcViewVertexes(elementWrapper, opts), wrapperOpts);
@ -115,7 +120,11 @@ export function drawSelectedElementControllersVertexes(
drawVertexes(ctx, calcViewVertexes(bottomRight.vertexes, opts), ctrlOpts);
if (element?.operations?.rotatable !== false) {
drawCircleController(ctx, calcViewPointSize(rotate.center, opts), { ...ctrlOpts, size: rotate.size, borderWidth: 0 });
drawCircleController(ctx, calcViewPointSize(rotate.center, opts), {
...ctrlOpts,
size: rotate.size,
borderWidth: 0
});
const rotateCenter = calcViewPointSize(rotate.center, opts);
ctx.drawImage(
rotateControllerPattern.canvas,
@ -139,7 +148,11 @@ export function drawSelectedElementControllersVertexes(
// });
}
export function drawElementListShadows(ctx: ViewContext2D, elements: Element<ElementType>[], opts?: Omit<RendererDrawElementOptions, 'loader'>) {
export function drawElementListShadows(
ctx: ViewContext2D,
elements: Element<ElementType>[],
opts?: Omit<RendererDrawElementOptions, 'loader'>
) {
elements.forEach((elem) => {
let { x, y, w, h } = elem;
const { angle = 0 } = elem;
@ -164,12 +177,15 @@ export function drawElementListShadows(ctx: ViewContext2D, elements: Element<Ele
}
ctx.closePath();
ctx.stroke();
ctx.fill();
ctx.fill('nonzero');
}
});
}
export function drawArea(ctx: ViewContext2D, opts: { start: PointSize; end: PointSize; style: MiddlewareSelectorStyle }) {
export function drawArea(
ctx: ViewContext2D,
opts: { start: PointSize; end: PointSize; style: MiddlewareSelectorStyle }
) {
const { start, end, style } = opts;
const { activeColor, activeAreaColor } = style;
ctx.setLineDash([]);
@ -183,7 +199,7 @@ export function drawArea(ctx: ViewContext2D, opts: { start: PointSize; end: Poin
ctx.lineTo(start.x, end.y);
ctx.closePath();
ctx.stroke();
ctx.fill();
ctx.fill('nonzero');
}
export function drawListArea(ctx: ViewContext2D, opts: { areaSize: AreaSize; style: MiddlewareSelectorStyle }) {
@ -201,7 +217,7 @@ export function drawListArea(ctx: ViewContext2D, opts: { areaSize: AreaSize; sty
ctx.lineTo(x, y + h);
ctx.closePath();
ctx.stroke();
ctx.fill();
ctx.fill('nonzero');
}
export function drawGroupQueueVertexesWrappers(
@ -217,7 +233,12 @@ export function drawGroupQueueVertexesWrappers(
const { activeColor } = style;
for (let i = 0; i < vertexesList.length; i++) {
const vertexes = vertexesList[i];
const wrapperOpts = { borderColor: activeColor, borderWidth: selectWrapperBorderWidth, background: 'transparent', lineDash: [4, 4] };
const wrapperOpts = {
borderColor: activeColor,
borderWidth: selectWrapperBorderWidth,
background: 'transparent',
lineDash: [4, 4]
};
drawVertexes(ctx, calcViewVertexes(vertexes, opts), wrapperOpts);
}
}

View file

@ -14,7 +14,8 @@ import {
deepResizeGroupElement,
getElementSize,
calcPointMoveElementInGroup,
isSameElementSize
isSameElementSize,
toFlattenElement
} from '@idraw/util';
import type {
Data,
@ -24,13 +25,14 @@ import type {
ViewSizeInfo,
ElementSizeController,
MiddlewareSelectorConfig,
ElementSize
ElementSize,
ModifyRecord
} from '@idraw/types';
import type {
Point,
PointSize,
PointWatcherEvent,
BoardMiddleware,
Middleware,
Element,
ActionType,
ResizeType,
@ -93,7 +95,7 @@ import { MIDDLEWARE_INTERNAL_EVENT_SHOW_INFO_ANGLE } from '../info';
export { keySelectedElementList, keyHoverElement, keyActionType, keyResizeType, keyGroupQueue };
export type { DeepSelectorSharedStorage, ActionType };
export const MiddlewareSelector: BoardMiddleware<
export const MiddlewareSelector: Middleware<
DeepSelectorSharedStorage,
CoreEventMap & {
[MIDDLEWARE_INTERNAL_EVENT_SHOW_INFO_ANGLE]: { show: boolean };
@ -107,6 +109,7 @@ export const MiddlewareSelector: BoardMiddleware<
const { viewer, sharer, boardContent, calculator, eventHub } = opts;
const { overlayContext } = boardContent;
let prevPoint: Point | null = null;
let pointStartElementSizeList: Array<Partial<ElementSize> & { 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;
}
}

View file

@ -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 & {

View file

@ -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<typeof coreEventKeys.TEXT_EDIT, TextEditEvent> & Record<typeof coreEventKeys.TEXT_CHANGE, TextChangeEvent>;
type ExtendEventMap = Record<typeof coreEventKeys.TEXT_EDIT, TextEditEvent> &
Record<typeof coreEventKeys.TEXT_CHANGE, TextChangeEvent>;
const defaultElementDetail = getDefaultElementDetailConfig();
export const MiddlewareTextEditor: BoardMiddleware<ExtendEventMap, CoreEventMap & ExtendEventMap> = (opts) => {
const { eventHub, boardContent, viewer, sharer } = opts;
export const MiddlewareTextEditor: Middleware<ExtendEventMap, CoreEventMap & ExtendEventMap> = (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<ExtendEventMap, CoreEventMap
resetCanvasWrapper();
resetTextArea(e);
mask.style.display = 'block';
originText = '';
if (activeElem?.uuid) {
sharer.setActiveOverrideElemenentMap({
[activeElem.uuid]: {
operations: { invisible: true }
}
});
originText = activeElem.detail.text || '';
viewer.drawFrame();
}
};
@ -201,13 +205,15 @@ export const MiddlewareTextEditor: BoardMiddleware<ExtendEventMap, CoreEventMap
// canvasWrapper.style.background = '#000000';
};
mask.addEventListener('click', () => {
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<ExtendEventMap, CoreEventMap
},
position: [...(activePosition || [])]
});
calculator.modifyText(activeElem);
viewer.drawFrame();
}
});
textarea.addEventListener('blur', () => {
};
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<ExtendEventMap, CoreEventMap
},
position: [...activePosition]
});
const data = sharer.getActiveStorage('data') || { elements: [] };
const updateContent = {
detail: {
text: activeElem.detail.text
}
};
updateElementInList(activeElem.uuid, updateContent, data.elements);
eventHub.trigger(coreEventKeys.CHANGE, {
selectedElements: [
{
...activeElem,
detail: {
...activeElem.detail,
...updateContent.detail
}
}
],
data,
type: 'modifyElement',
modifyRecord: {
type: 'modifyElement',
time: Date.now(),
content: {
method: 'modifyElement',
uuid: activeElem.uuid as string,
before: {
'detail.text': originText
},
after: {
'detail.text': activeElem.detail.text
}
}
}
});
calculator.modifyText(activeElem);
viewer.drawFrame();
}
hideTextArea();
});
textarea.addEventListener('keydown', (e) => {
};
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<ExtendEventMap, CoreEventMap
},
disuse() {
eventHub.off(coreEventKeys.TEXT_EDIT, textEditCallback);
mask.removeEventListener('click', maskClickEvent);
textarea.removeEventListener('click', textareaClickEvent);
textarea.removeEventListener('input', textareaInputEvent);
textarea.removeEventListener('blur', textareaBlurEvent);
textarea.removeEventListener('keydown', textareaKeyDownEvent);
textarea.removeEventListener('keypress', textareaKeyPressEvent);
textarea.removeEventListener('keyup', textareaKeyUpEvent);
textarea.removeEventListener('wheel', textareaWheelEvent);
canvasWrapper.removeChild(textarea);
mask.removeChild(canvasWrapper);
container.removeChild(mask);
textarea.remove();
canvasWrapper.remove();
mask = null as any;
textarea = null as any;
canvasWrapper = null as any;
mask = null as any;
activeElem = null;
activePosition = null as any;
originText = null as any;
}
};
};

View file

@ -0,0 +1,34 @@
import type { RecursivePartial, FlattenElement, Element, ModifyRecord } from '@idraw/types';
import { toFlattenElement, get } from '@idraw/util';
export function getModifyElementRecord(opts: {
modifiedElement: RecursivePartial<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>;
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;
}

File diff suppressed because it is too large Load diff

View file

@ -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([]);
});
});

View file

@ -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([]);
});
});

View file

@ -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<string, any> = {};
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<Omit<Element, 'uuid'>>;
idraw.modifyElement({
uuid: targetElement.uuid,
...deepClone(modifiedInfo2)
} as RecursivePartial<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>);
const expectedData2 = deepClone(expectedData1);
const flattenModifiedInfo2 = toFlattenElement(modifiedInfo2);
const beforeInfo2: Record<string, any> = {};
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([]);
});
});

View file

@ -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<string, any> | 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<DataGlobal>;
idraw.modifyGlobal({ ...modifiedInfo2 });
const expectedData2 = deepClone(expectedData1);
const flattenModifiedInfo2 = toFlattenGlobal(modifiedInfo2);
const beforeInfo2: Record<string, any> = {};
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([]);
});
});

View file

@ -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<string, any> | 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<DataLayout>;
idraw.modifyLayout({ ...modifiedInfo2 });
const expectedData2 = deepClone(expectedData1);
const flattenModifiedInfo2 = toFlattenLayout(modifiedInfo2);
const beforeInfo2: Record<string, any> = {};
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([]);
});
});

View file

@ -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]);
});
});

View file

@ -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<string, any> = toFlattenElement(targetElement);
const afterInfo1: Record<string, any> = 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<string, any> = toFlattenElement(updatedElement1);
const afterInfo2: Record<string, any> = 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([]);
});
});

View file

@ -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<string, any> = {};
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<Omit<Element, 'uuid'>>;
idraw.modifyElement({
uuid: targetElement.uuid,
...deepClone(modifiedInfo2)
} as RecursivePartial<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>);
const expectedData2 = deepClone(expectedData1);
const flattenModifiedInfo2 = toFlattenElement(modifiedInfo2);
const beforeInfo2: Record<string, any> = {};
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([]);
});
});

View file

@ -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<void> {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, time);
});
}
describe('idraw', () => {
beforeEach(() => {
requestAnimationFrameMock.reset();
});
test('context', async () => {
document.body.innerHTML = `
<div id="mount"></div>
`;
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 = `
<div id="mount"></div>
`;
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();
});
});

View file

@ -25,9 +25,12 @@ export type ExportImageFileResult = {
export async function exportImageFileBlobURL(opts: ExportImageFileOptions): Promise<ExportImageFileResult> {
const { data, width, height, devicePixelRatio, viewScaleInfo, viewSizeInfo, loadItemMap } = opts;
let viewContext: ViewContext2D | null = createOffscreenContext2D({ width, height, devicePixelRatio });
let 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 {

View file

@ -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<IDrawEvent>;
@ -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<IDrawFeature | string, keyof Omit<IDrawStorage, 'mode'>> = {
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<C extends any = any>(middleware: Middleware<any, any, any>, config?: C) {
this.#core.use<C>(middleware, config);
}
disuse(middleware: Middleware<any, any, any>) {
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<T extends ElementType>(
type: T,
opts?: {
element?: RecursivePartial<Element<T>>;
viewCenter?: boolean;
}
element: RecursivePartial<Element<T>>,
opts?: { viewCenter?: boolean }
): Element<T> {
const { viewScaleInfo, viewSizeInfo } = this.#core.getViewInfo();
return createElement<T>(
type,
opts?.element || {},
opts?.viewCenter === true
? {
viewScaleInfo,
viewSizeInfo
}
: undefined
);
return createElement<T>({ 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<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>) {
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<DataLayout>) {
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<DataLayout> | null) {
return modifyLayout({ core: this.#core }, layout);
}
modifyGlobal(global: RecursivePartial<DataGlobal> | null) {
return modifyGlobal({ core: this.#core }, global);
}
async getImageBlobURL(opts?: ExportImageFileBaseOptions): Promise<ExportImageFileResult> {
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;
}
}

View file

@ -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';

View file

@ -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<T extends ElementType = ElementType>(
depOptions: {
core: Core<IDrawEvent>;
},
type: T,
element: RecursivePartial<Element<T>>,
opts?: {
viewCenter?: boolean;
}
): Element<T> {
const { core } = depOptions;
return core.createElement<T>(type, element, opts);
}
export function updateElement(
depOptions: {
core: Core<IDrawEvent>;
},
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<IDrawEvent>;
},
element: RecursivePartial<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>
) {
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<IDrawEvent>;
},
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<IDrawEvent> }, 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<IDrawEvent> }, 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 });
}

View file

@ -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<IDrawEvent>; store: Store<IDrawStorage> },
feat: IDrawFeature,
status: boolean
) {
const { core, store } = depOptions;
if (['ruler', 'scroll', 'scale', 'info'].includes(feat)) {
const map: Record<IDrawFeature | string, keyof Omit<IDrawStorage, 'mode'>> = {
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
});
}
}

View file

@ -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<IDrawEvent> }, global: RecursivePartial<DataGlobal> | null) {
const { core } = depOptions;
const modifyRecord = core.modifyGlobal(global);
const data = core.getData() as Data;
core.trigger(coreEventKeys.CHANGE, { data, type: 'modifyGlobal', modifyRecord });
}

View file

@ -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<IDrawEvent> },
opts?: ExportImageFileBaseOptions
): Promise<ExportImageFileResult> {
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()
});
}

View file

@ -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<IDrawEvent> }, layout: RecursivePartial<DataLayout> | null) {
const { core } = depOptions;
const modifyRecord = core.modifyLayout(layout);
const data = core.getData() as Data;
core.trigger(coreEventKeys.CHANGE, { data, type: 'modifyLayout', modifyRecord });
}

View file

@ -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<IDrawEvent>; store: Store<IDrawStorage> },
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;
}

View file

@ -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<DataLayout>);
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<DataGlobal>);
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<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>
>
) 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<DataLayout>);
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<DataGlobal>);
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<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>
>
) as ModifyRecord;
}
redoRecord = { ...redoRecord, type: 'redo' } as ModifyRecord<'redo'>;
doRecords.push(redoRecord);
}
};
const MiddlewareHistory: Middleware<any, IDrawEvent> = (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;
};

View file

@ -3,8 +3,9 @@ import { istype } from '@idraw/util';
export const defaultMode: IDrawMode = 'select';
export const defaultSettings: Required<Pick<IDrawSettings, 'mode'>> = {
mode: defaultMode
export const defaultSettings: Required<Pick<IDrawSettings, 'mode' | 'history'>> = {
mode: defaultMode,
history: false
};
export const defaultOptions: Required<Pick<IDrawOptions, 'devicePixelRatio'>> = {

View file

@ -19,7 +19,8 @@ function isValidMode(mode: string | IDrawMode) {
}
export function runMiddlewares(core: Core<IDrawEvent>, store: Store<IDrawStorage>) {
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);

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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)) {

View file

@ -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;

View file

@ -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);

View file

@ -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) {

View file

@ -70,6 +70,14 @@ export class Renderer extends EventEmitter<RendererEventMap> implements BoardRen
// ...opts
// });
// }
if (opts.forceDrawAll === true) {
this.#calculator.resetVirtualFlatItemMap(data, {
viewScaleInfo: opts.viewScaleInfo,
viewSizeInfo: opts.viewSizeInfo
});
}
const drawOpts = {
loader,
calculator,

View file

@ -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

View file

@ -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';

View file

@ -143,22 +143,6 @@ export interface BoardViewer extends UtilEventEmitter<BoardViewerEventMap> {
scroll(opts: { moveX?: number; moveY?: number; ignoreUpdateVisibleStatus?: boolean }): ViewScaleInfo;
resize(viewSize: Partial<ViewSizeInfo>, 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<RendererEventMap> {

View file

@ -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<Array<number>>;
// }
export interface CoreEventChange {
export interface CoreEventChange<T extends ModifyType = ModifyType> {
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<T>;
}
export interface CoreEventScale {
scale: number;

View file

@ -19,7 +19,7 @@ export type DataLayout = Pick<ElementSize, 'x' | 'y' | 'w' | 'h'> & {
};
};
export interface DataGlobalDetail {
export interface DataGlobal {
background?: string;
}
@ -28,7 +28,7 @@ export type Data<E extends Record<string, any> = Record<string, any>> = {
elements: Element<ElementType, E>[];
assets?: ElementAssets;
layout?: DataLayout;
global?: DataGlobalDetail;
global?: DataGlobal;
};
export type Matrix = [

View file

@ -178,19 +178,19 @@ export interface ElementOperations {
lastModified?: number;
}
export interface ElementGlobalDetail {
export interface ElementGlobal {
background?: string;
}
export interface Element<T extends ElementType = ElementType, E extends Record<string, any> = Record<string, any>>
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<ElementType>[];

View file

@ -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<MiddlewareScrollerStyle>;
layoutSelector?: Partial<MiddlewareLayoutSelectorStyle>;
};
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;

View file

@ -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 | symbol, any> = any,
E extends BoardExtendEventMap = BoardExtendEventMap,
C extends any = undefined
> = BoardMiddleware<S, E, C>;
export type MiddlewareObject = BoardMiddlewareObject;

View file

@ -0,0 +1,22 @@
import type { Element, ElementPosition } from './element';
import type { RecursivePartial } from './util';
type ModifyInfoType = 'updateElement' | 'addElement' | 'deleteElement' | 'moveElement';
type ModifiedElement = Omit<RecursivePartial<Element>, '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<T extends ModifyInfoType = ModifyInfoType> {
type: T;
content: ModifyInfoContentMap[T];
}

View file

@ -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<RecursivePartial<Element>, '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<string, string | number | undefined | null>;
/**
* 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<string, string | number | undefined | null>;
/**
* FlattenGlobal
For example:
{
"background": "#FFFFFF",
}
*/
export type FlattenGlobal = Partial<DataGlobal>;
export type ModifiedElementOperations = Partial<ElementOperations>;
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<T extends ModifyType = ModifyType> {
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<ModifyContentMap['updateElement']>;
content: ModifyContentMap['updateElement'];
};
modifyElement: {
type: 'modifyElement';
time: number;
content: ModifyContentMap['modifyElement'];
};
addElement: {
type: 'addElement';
time: number;
} & Required<ModifyContentMap['addElement']>;
content: ModifyContentMap['addElement'];
};
deleteElement: {
type: 'deleteElement';
time: number;
} & Required<ModifyContentMap['deleteElement']>;
content: ModifyContentMap['deleteElement'];
};
moveElement: {
type: 'moveElement';
time: number;
afterModifiedFrom: ElementPosition;
afterModifiedTo: ElementPosition;
} & Required<ModifyContentMap['moveElement']>;
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<T extends ModifyType = ModifyType> = ModifyRecordMap[T];

View file

@ -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];

View file

@ -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);
});
});

View file

@ -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', () => {

View file

@ -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
}

View file

@ -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();
});
});

View file

@ -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<string, any> = { a: 1 };
obj.self = obj;
expect(() => flatObject(obj)).toThrow();
});
});

View file

@ -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');
});
});

View file

@ -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;

View file

@ -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 () => {

View file

@ -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([]);
}
});
});

View file

@ -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'
}
});
});
});

View file

@ -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]));
});
});

View file

@ -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 () => {

View file

@ -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] });
});
});

View file

@ -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({});
});
});

View file

@ -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);
});
});

View file

@ -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';

View file

@ -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<T extends ModifyType>(opts: ModifyOptions<T>, modifiedContent: ModifyContentMap[T]): ModifyRecord<T> {
const content = opts.content as ModifyContentMap[T];
const modifyRecord: ModifyRecord<T> = {
...deepClone<ModifyContentMap[T]>(content),
// ...deepClone<ModifyContentMap[T]>(modifiedContent),
type: opts.type,
time: Date.now()
} as ModifyRecord<T>;
const record = modifyRecord as ModifyRecord<T>;
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<T extends ModifyType>(data: Data, opts: ModifyOptions<T>): 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;
}
}

View file

@ -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<T extends ModifyType = ModifyType>(data: Data, options: ModifyOptions<T>): { 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<number | string> = [];
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;
}

View file

@ -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}`);
}

View file

@ -0,0 +1,59 @@
type FlattenedObject = Record<string, any>;
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<T extends Record<string, any>>(
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<unknown> {
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<T extends Record<string, any>>(obj: T, opts?: FlatObjectOptions): FlattenedObject {
// Defensive check for non-object input
if (typeof obj !== 'object' || obj === null) {
return { '': obj };
}
return flattenObject(obj, '', {}, opts);
}

View file

@ -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<T = any, D = any>(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<T = any>(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<T = any>(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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<string | symbol, any> = Record<string | symbol, any>,

View file

@ -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<T extends Record<string, unknown>>(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;
}
}

Some files were not shown because too many files have changed in this diff Show more