Merge pull request #360 from idrawjs/dev-v0.4

feat: add features of undo and redo
This commit is contained in:
Deepsea 2025-05-31 14:06:11 +08:00 committed by GitHub
commit e952f6c190
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 385 additions and 62 deletions

View file

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

View file

@ -34,7 +34,7 @@ import {
} from '@idraw/util';
import { Board, Sharer, Calculator } from './board';
import { createBoardContent, validateElements } from '@idraw/util';
import { Cursor } from './lib/cursor';
import { Cursor } from './cursor/cursor';
import { getModifyElementRecord } from './record';
export { coreEventKeys } from './config';
@ -43,15 +43,15 @@ export type { CoreEventKeys } from './config';
export { Board, Sharer, Calculator };
// export { MiddlewareSelector } from './middleware/selector';
export { MiddlewareSelector } from './middleware/selector';
export { MiddlewareScroller } from './middleware/scroller';
export { MiddlewareScaler } from './middleware/scaler';
export { MiddlewareRuler } from './middleware/ruler';
export { MiddlewareTextEditor } from './middleware/text-editor';
export { MiddlewareDragger } from './middleware/dragger';
export { MiddlewareInfo } from './middleware/info';
export { MiddlewareLayoutSelector } from './middleware/layout-selector';
export { MiddlewarePointer } from './middleware/pointer';
export { MiddlewareSelector } from './middlewares/selector';
export { MiddlewareScroller } from './middlewares/scroller';
export { MiddlewareScaler } from './middlewares/scaler';
export { MiddlewareRuler } from './middlewares/ruler';
export { MiddlewareTextEditor } from './middlewares/text-editor';
export { MiddlewareDragger } from './middlewares/dragger';
export { MiddlewareInfo } from './middlewares/info';
export { MiddlewareLayoutSelector } from './middlewares/layout-selector';
export { MiddlewarePointer } from './middlewares/pointer';
export class Core<E extends CoreEventMap = CoreEventMap> {
#board: Board<E>;

View file

@ -391,7 +391,7 @@ export const MiddlewareLayoutSelector: Middleware<
};
}
eventHub.trigger(coreEventKeys.CHANGE, {
type: 'dragLayout',
type: 'resizeLayout',
data,
modifyRecord
});

View file

@ -120,6 +120,9 @@ export const MiddlewareSelector: Middleware<
devicePixelRatio: sharer.getActiveViewSizeInfo().devicePixelRatio
});
let startResizeGroupRecord: ModifyRecord<'resizeElements'> | null = null;
let endResizeGroupRecord: ModifyRecord<'resizeElements'> | null = null;
sharer.setSharedStorage(keyActionType, null);
sharer.setSharedStorage(keyEnableSnapToGrid, true);
@ -213,6 +216,8 @@ export const MiddlewareSelector: Middleware<
};
const clear = () => {
startResizeGroupRecord = null;
endResizeGroupRecord = null;
sharer.setSharedStorage(keyActionType, null);
sharer.setSharedStorage(keyResizeType, null);
sharer.setSharedStorage(keyAreaStart, null);
@ -419,6 +424,8 @@ export const MiddlewareSelector: Middleware<
prevPoint = e.point;
moveOriginalStartPoint = e.point;
startResizeGroupRecord = null;
endResizeGroupRecord = null;
sharer.setSharedStorage(keyActionType, null);
sharer.setSharedStorage(keyResizeType, null);
sharer.setSharedStorage(keyAreaStart, null);
@ -674,7 +681,7 @@ export const MiddlewareSelector: Middleware<
const gridW = calculator.toGridNum(resizedElemSize.w, calcOpts);
const gridH = calculator.toGridNum(resizedElemSize.h, calcOpts);
if (elems[0].type === 'group') {
resizeEffectGroupElement(
endResizeGroupRecord = resizeEffectGroupElement(
elems[0] as Element<'group'>,
{
x: gridX,
@ -684,6 +691,9 @@ export const MiddlewareSelector: Middleware<
},
{ resizeEffect: elems[0].operations?.resizeEffect }
);
if (!startResizeGroupRecord) {
startResizeGroupRecord = endResizeGroupRecord;
}
elems[0].x = gridX;
elems[0].y = gridY;
} else {
@ -791,16 +801,16 @@ export const MiddlewareSelector: Middleware<
}
if (data && (['drag', 'drag-list', 'drag-list-end', 'resize'] as ActionType[]).includes(actionType)) {
let type: any = 'dragElement';
let type: any = 'resizeElement';
if (type === 'resize') {
type = 'resizeElement';
}
if (hasChangedData) {
const startSize = pointStartElementSizeList[0] as ElementSize & { uuid: string };
let modifyRecord: ModifyRecord | undefined = undefined;
let modifyRecord: ModifyRecord | null | undefined = null;
if (selectedElements.length === 1) {
modifyRecord = {
type: 'dragElement',
type: 'resizeElement',
time: 0,
content: {
method: 'modifyElement',
@ -809,9 +819,18 @@ export const MiddlewareSelector: Middleware<
after: toFlattenElement(getElementSize(selectedElements[0]))
}
};
if (selectedElements[0].type === 'group' && startResizeGroupRecord && endResizeGroupRecord) {
modifyRecord = {
...endResizeGroupRecord,
content: {
...endResizeGroupRecord.content,
before: startResizeGroupRecord.content.before
}
};
}
} else if (selectedElements.length > 1) {
modifyRecord = {
type: 'dragElements',
type: 'resizeElements',
time: 0,
content: {
method: 'modifyElements',

View file

@ -238,15 +238,15 @@ export class iDraw {
return this.#core.forceRender();
}
// getHistoryHandler() {
// return this.#historyHandler;
// }
getHistoryHandler() {
return this.#historyHandler;
}
// redo() {
// this.#historyHandler?.redo();
// }
redo() {
this.#historyHandler?.redo();
}
// undo() {
// this.#historyHandler?.undo();
// }
undo() {
this.#historyHandler?.undo();
}
}

View file

@ -18,9 +18,9 @@ const supportRecordTypes = [
'deleteElement',
'moveElement',
'addElement',
'dragElement',
'resizeElement',
'dragLayout',
'resizeElements',
'resizeLayout',
'modifyLayout',
'modifyGlobal'
];
@ -90,7 +90,7 @@ export const useHistory = (opts: { core: Core; limit?: number }) => {
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<
record.content.before.map((item) => unflatObject(item)) as unknown as Array<
RecursivePartial<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>
>
) as ModifyRecord;
@ -153,7 +153,7 @@ export const useHistory = (opts: { core: Core; limit?: number }) => {
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<
record.content.before.map((item) => unflatObject(item)) as unknown as Array<
RecursivePartial<Omit<Element, 'uuid'>> & Pick<Element, 'uuid'>
>
) as ModifyRecord;

View file

@ -1,7 +1,7 @@
import type { Element, RendererDrawElementOptions, ViewContext2D } from '@idraw/types';
import { rotateElement, calcViewElementSize, enhanceFontFamliy } from '@idraw/util';
import { is, isColorStr, getDefaultElementDetailConfig } from '@idraw/util';
import { drawBox, drawBoxShadow } from './box';
import { drawBox, drawBoxShadow, getOpacity } from './box';
const detailConfig = getDefaultElementDetailConfig();
@ -35,6 +35,9 @@ export function drawText(ctx: ViewContext2D, elem: Element<'text'>, opts: Render
return;
}
const { parentOpacity } = opts;
const opacity = getOpacity(elem) * parentOpacity;
ctx.globalAlpha = opacity;
ctx.fillStyle = elem.detail.color || detailConfig.color;
ctx.textBaseline = 'top';
ctx.$setFont({
@ -64,6 +67,8 @@ export function drawText(ctx: ViewContext2D, elem: Element<'text'>, opts: Render
});
}
}
ctx.globalAlpha = parentOpacity;
}
});
}

View file

@ -1,5 +1,12 @@
import type { ElementBaseDetail, ElementTextDetail, ElementGroupDetail } from './element';
export type DefaultElementDetailConfig = Required<Omit<ElementBaseDetail, 'clipPath' | 'background'>> &
Required<Pick<ElementTextDetail, 'color' | 'textAlign' | 'verticalAlign' | 'fontSize' | 'fontFamily' | 'fontWeight' | 'minInlineSize' | 'wordBreak'>> &
export type DefaultElementDetailConfig = Required<
Omit<ElementBaseDetail, 'clipPath' | 'clipPathStrokeWidth' | 'clipPathStrokeColor' | 'background'>
> &
Required<
Pick<
ElementTextDetail,
'color' | 'textAlign' | 'verticalAlign' | 'fontSize' | 'fontFamily' | 'fontWeight' | 'minInlineSize' | 'wordBreak'
>
> &
Required<Pick<ElementGroupDetail, 'overflow'>>;

View file

@ -39,7 +39,7 @@ export interface CoreEventChange<T extends ModifyType = ModifyType> {
type: T | 'setData' | 'other' | string;
selectedElements?: Element[] | null;
hoverElement?: Element | null;
modifyRecord?: ModifyRecord<T>;
modifyRecord?: ModifyRecord<T> | null;
}
export interface CoreEventScale {
scale: number;

View file

@ -7,10 +7,10 @@ export type ModifyMethod =
| 'deleteElement'
| 'moveElement'
| 'addElement'
| 'dragElement'
| 'dragElements'
| 'resizeElement'
| 'resizeElements'
| 'modifyElements'
| 'dragLayout'
| 'resizeLayout'
| 'modifyLayout'
| 'modifyGlobal';
@ -92,19 +92,19 @@ export interface ModifyContentMap {
from: ElementPosition;
to: ElementPosition;
};
dragElement: {
resizeElement: {
method: 'modifyElement';
uuid: string;
before: FlattenElement | null;
after: FlattenElement | null;
};
dragElements: {
resizeElements: {
method: 'modifyElements';
before: (FlattenLayout & { uuid: string })[];
after: (FlattenLayout & { uuid: string })[];
};
dragLayout: {
method: 'modifyElement';
resizeLayout: {
method: 'modifyLayout';
before: FlattenLayout;
after: FlattenLayout;
};
@ -151,13 +151,13 @@ export interface ModifyRecordMap {
time: number;
content: ModifyContentMap['moveElement'];
};
dragElement: {
type: 'dragElement';
resizeElement: {
type: 'resizeElement';
time: number;
content: ModifyContentMap['modifyElement'];
};
dragElements: {
type: 'dragElements';
resizeElements: {
type: 'resizeElements';
time: number;
content: ModifyContentMap['modifyElements'];
};
@ -166,10 +166,10 @@ export interface ModifyRecordMap {
time: number;
content: ModifyContentMap['modifyElements'];
};
dragLayout: {
type: 'dragLayout';
resizeLayout: {
type: 'resizeLayout';
time: number;
content: ModifyContentMap['dragLayout'];
content: ModifyContentMap['resizeLayout'];
};
modifyLayout: {
type: 'modifyLayout';

View file

@ -0,0 +1,292 @@
import { createElement, resizeEffectGroupElement } from '@idraw/util';
import type { Element } from '@idraw/types';
const createGroupByRatio = (opts?: { xRatio?: number; yRatio?: number }) => {
const { xRatio = 1, yRatio = 1 } = opts || {};
const minRatio = Math.min(xRatio, yRatio);
const maxRatio = Math.max(xRatio, yRatio);
const midRatio = (minRatio + maxRatio) / 2;
const group: Element<'group'> = createElement('group', {
uuid: 'test-001',
x: 10,
y: 10,
w: 2000 * xRatio,
h: 2000 * yRatio,
detail: {
children: [
createElement('rect', { uuid: 'test-002', x: 20 * xRatio, y: 20 * yRatio, w: 20 * xRatio, h: 20 * yRatio }),
createElement('circle', { uuid: 'test-003', x: 40 * xRatio, y: 40 * yRatio, w: 40 * xRatio, h: 40 * yRatio }),
createElement('text', {
uuid: 'test-004',
x: 60 * xRatio,
y: 60 * yRatio,
w: 60 * xRatio,
h: 60 * yRatio,
detail: {
fontSize: 16 * midRatio,
// lineHeight: 32 * midRatio,
text: 'Text in Group'
}
}),
createElement('image', {
uuid: 'test-005',
x: 80 * xRatio,
y: 80 * yRatio,
w: 80 * xRatio,
h: 80 * yRatio,
detail: { src: 'https://example.com/002.png' }
}),
createElement('group', {
uuid: 'test-100',
x: 500 * xRatio,
y: 500 * yRatio,
w: 1000 * xRatio,
h: 1000 * yRatio,
detail: {
children: [
createElement('rect', {
uuid: 'test-101',
x: 20 * xRatio,
y: 20 * yRatio,
w: 20 * xRatio,
h: 20 * yRatio
}),
createElement('circle', {
uuid: 'test-102',
x: 40 * xRatio,
y: 40 * yRatio,
w: 40 * xRatio,
h: 40 * yRatio
}),
createElement('text', {
uuid: 'test-103',
x: 60 * xRatio,
y: 60 * yRatio,
w: 60 * xRatio,
h: 60 * yRatio,
detail: {
fontSize: 16 * midRatio,
text: 'Text in Group'
}
}),
createElement('image', {
uuid: 'test-104',
x: 80 * xRatio,
y: 80 * yRatio,
w: 80 * xRatio,
h: 80 * yRatio,
detail: { src: 'https://example.com/002.png' }
})
]
}
})
]
},
operations: {
resizeEffect: 'deepResize'
}
});
return group;
};
const createGroupByFixed = (opts: { moveX: number; moveY: number; moveW: number; moveH: number }) => {
const { moveX, moveY, moveW, moveH } = opts || {};
const group: Element<'group'> = createElement('group', {
uuid: 'test-001',
x: 10 + moveX,
y: 10 + moveY,
w: 2000 + moveW,
h: 2000 + moveH,
detail: {
children: [
createElement('rect', { uuid: 'test-002', x: 20 - moveX, y: 20 - moveY, w: 20, h: 20 }),
createElement('circle', { uuid: 'test-003', x: 40 - moveX, y: 40 - moveY, w: 40, h: 40 }),
createElement('text', {
uuid: 'test-004',
x: 60 - moveX,
y: 60 - moveY,
w: 60,
h: 60,
detail: {
fontSize: 16,
text: 'Text in Group'
}
}),
createElement('image', {
uuid: 'test-005',
x: 80 - moveX,
y: 80 - moveY,
w: 80,
h: 80,
detail: { src: 'https://example.com/002.png' }
}),
createElement('group', {
uuid: 'test-100',
x: 500 - moveX,
y: 500 - moveY,
w: 1000,
h: 1000,
detail: {
children: [
createElement('rect', {
uuid: 'test-101',
x: 20,
y: 20,
w: 20,
h: 20
}),
createElement('circle', {
uuid: 'test-102',
x: 40,
y: 40,
w: 40,
h: 40
}),
createElement('text', {
uuid: 'test-103',
x: 60,
y: 60,
w: 60,
h: 60,
detail: {
fontSize: 16,
text: 'Text in Group'
}
}),
createElement('image', {
uuid: 'test-104',
x: 80,
y: 80,
w: 80,
h: 80,
detail: { src: 'https://example.com/002.png' }
})
]
}
})
]
},
operations: {
resizeEffect: 'deepResize'
}
});
return group;
};
describe('resizeEffectGroupElement', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2025-01-01'));
});
test('deepSize', () => {
const group = createGroupByRatio();
const xRatio = 2;
const yRatio = 3;
const record = resizeEffectGroupElement(
group,
{
w: group.w * xRatio,
h: group.h * yRatio
},
{
resizeEffect: 'deepResize'
}
);
expect(group).toStrictEqual(
createGroupByRatio({
xRatio,
yRatio
})
);
expect(record).toStrictEqual({
type: 'resizeElements',
time: 1735689600000,
content: {
method: 'modifyElements',
before: [
{ uuid: 'test-001', x: 10, y: 10, w: 2000, h: 2000 },
{ x: 20, y: 20, w: 20, h: 20, uuid: 'test-002' },
{ x: 40, y: 40, w: 40, h: 40, uuid: 'test-003' },
{ x: 60, y: 60, w: 60, h: 60, uuid: 'test-004', 'detail.fontSize': 16 },
{ x: 80, y: 80, w: 80, h: 80, uuid: 'test-005' },
{ x: 500, y: 500, w: 1000, h: 1000, uuid: 'test-100' },
{ x: 20, y: 20, w: 20, h: 20, uuid: 'test-101' },
{ x: 40, y: 40, w: 40, h: 40, uuid: 'test-102' },
{ x: 60, y: 60, w: 60, h: 60, uuid: 'test-103', 'detail.fontSize': 16 },
{ x: 80, y: 80, w: 80, h: 80, uuid: 'test-104' }
],
after: [
{ uuid: 'test-001', x: 10, y: 10, w: 4000, h: 6000 },
{ x: 40, y: 60, w: 40, h: 60, uuid: 'test-002' },
{ x: 80, y: 120, w: 80, h: 120, uuid: 'test-003' },
{ x: 120, y: 180, w: 120, h: 180, uuid: 'test-004', 'detail.fontSize': 40 },
{ x: 160, y: 240, w: 160, h: 240, uuid: 'test-005' },
{ x: 1000, y: 1500, w: 2000, h: 3000, uuid: 'test-100' },
{ x: 40, y: 60, w: 40, h: 60, uuid: 'test-101' },
{ x: 80, y: 120, w: 80, h: 120, uuid: 'test-102' },
{ x: 120, y: 180, w: 120, h: 180, uuid: 'test-103', 'detail.fontSize': 40 },
{ x: 160, y: 240, w: 160, h: 240, uuid: 'test-104' }
]
}
});
});
test('fixed', () => {
const group = createGroupByRatio();
const moveX = 99;
const moveY = 88;
const moveW = 77;
const moveH = 66;
const record = resizeEffectGroupElement(
group,
{
x: group.x + moveX,
y: group.y + moveY,
w: group.w + moveW,
h: group.h + moveH
},
{
resizeEffect: 'fixed'
}
);
expect(group).toStrictEqual(
createGroupByFixed({
moveX,
moveY,
moveW,
moveH
})
);
expect(record).toStrictEqual({
type: 'resizeElements',
time: 1735689600000,
content: {
method: 'modifyElements',
before: [
{ uuid: 'test-001', x: 10, y: 10, w: 2000, h: 2000 },
{ uuid: 'test-002', x: 20, y: 20 },
{ uuid: 'test-003', x: 40, y: 40 },
{ uuid: 'test-004', x: 60, y: 60 },
{ uuid: 'test-005', x: 80, y: 80 },
{ uuid: 'test-100', x: 500, y: 500 }
],
after: [
{ uuid: 'test-001', x: 109, y: 98, w: 2077, h: 2066 },
{ uuid: 'test-002', x: -79, y: -68 },
{ uuid: 'test-003', x: -59, y: -48 },
{ uuid: 'test-004', x: -39, y: -28 },
{ uuid: 'test-005', x: -19, y: -8 },
{ uuid: 'test-100', x: 401, y: 412 }
]
}
});
});
});

View file

@ -61,7 +61,7 @@ export function getDefaultElementTextDetail(elementSize: ElementSize): ElementTe
color: detailConfig.color,
fontFamily: detailConfig.fontFamily,
fontWeight: detailConfig.fontWeight,
lineHeight: elementSize.w / defaultText.length,
// lineHeight: elementSize.w / defaultText.length,
fontSize: elementSize.w / defaultText.length,
textAlign: 'center',
verticalAlign: 'middle'

View file

@ -105,6 +105,10 @@ function resizeElementBaseDetailByRatio(elem: Element, opts: DeepResizeRatioOpti
function resizeElementBaseByRatio(elem: Element, opts: DeepResizeRatioOptions): ModifyRecord<'modifyElement'> {
const { xRatio, yRatio } = opts;
const { uuid, x, y, w, h } = elem;
elem.x = doNum(x * xRatio);
elem.y = doNum(y * yRatio);
elem.w = doNum(w * xRatio);
elem.h = doNum(h * yRatio);
const record: ModifyRecord<'modifyElement'> = {
type: 'modifyElement',
time: Date.now(),
@ -112,13 +116,10 @@ function resizeElementBaseByRatio(elem: Element, opts: DeepResizeRatioOptions):
method: 'modifyElement',
uuid: uuid,
before: { x, y, w, h },
after: { x, y, w, h }
after: { x: elem.x, y: elem.y, w: elem.w, h: elem.h }
}
};
elem.x = doNum(x * xRatio);
elem.y = doNum(y * yRatio);
elem.w = doNum(w * xRatio);
elem.h = doNum(h * yRatio);
const detailRecord = resizeElementBaseDetailByRatio(elem, opts);
record.content.before = {
...record.content.before,
@ -170,7 +171,7 @@ function resizeTextElementDetailByRatio(
function deepResizeElementByRatio(
elem: Element,
opts: DeepResizeRatioOptions,
record?: ModifyRecord<'modifyElements'>
record?: ModifyRecord<'resizeElements'>
) {
const { type, uuid } = elem;
@ -210,7 +211,7 @@ function deepResizeElementByRatio(
function fixedResizeGroupElementChildren(
elem: Element<'group'>,
opts: FixedResizeOptions,
record?: ModifyRecord<'modifyElements'>
record?: ModifyRecord<'resizeElements'>
) {
if (!(elem.type === 'group' && Array.isArray(elem.detail.children))) {
return;
@ -250,12 +251,9 @@ export function resizeEffectGroupElement(
opts?: {
resizeEffect?: ElementOperations['resizeEffect'];
}
): ModifyRecord<'modifyElements'> | null {
if (!istype.number(size.x) && !istype.number(size.y)) {
return null;
}
const record: ModifyRecord<'modifyElements'> = {
type: 'modifyElements',
): ModifyRecord<'resizeElements'> | null {
const record: ModifyRecord<'resizeElements'> = {
type: 'resizeElements',
time: Date.now(),
content: {
method: 'modifyElements',
@ -279,6 +277,8 @@ export function resizeEffectGroupElement(
const afterGroupElem: FlattenLayout & { uuid: string } = { uuid, x: resizeX, y: resizeY, w: resizeW, h: resizeH };
if (opts?.resizeEffect === 'deepResize') {
record.content.before.push(beforeGroupElem);
record.content.after.push(afterGroupElem);
const xRatio = resizeW / elem.w;
const yRatio = resizeH / elem.h;
if (xRatio === yRatio && xRatio === 1) {