diff --git a/README.md b/README.md
index 0103aa1e..cf8f1603 100644
--- a/README.md
+++ b/README.md
@@ -226,7 +226,7 @@ For **detailed type definitions**, refer to the [data.d.ts](src/display/data-sch
### `update(options)`
-Updates the properties of objects rendered on the canvas. By default, only the changed properties are applied, but you can precisely control the update behavior using the `refresh` or `arrayMerge` options.
+Updates the properties of objects rendered on the canvas. By default, only the changed properties are applied, but you can precisely control the update behavior using the `refresh` or `mergeStrategy` options.
#### **`Options`**
- `path` (optional, string) - Selector for the object to which the event will be applied, following [jsonpath](https://github.com/JSONPath-Plus/JSONPath) syntax.
@@ -234,9 +234,9 @@ Updates the properties of objects rendered on the canvas. By default, only the c
- `changes` (optional, object) - New properties to apply (e.g., color, text visibility). If the `refresh` option is set to `true`, this can be omitted.
- `history` (optional, boolean \| string) - Determines whether to record changes made by this `update` method in the `undoRedoManager`. If a string that matches the historyId of a previously saved record is provided, the two records will be merged into a single undo/redo step.
- `relativeTransform` (optional, boolean) - Determines whether to use relative values for `position`, `rotation`, and `angle`. If `true`, the provided values will be added to the object's values.
-- `arrayMerge` (optional, string) - Determines how to merge array properties. The default is `'merge'`.
- - `'merge'` (default): Merges the target and source arrays.
- - `'replace'`: Completely replaces the target array with the source array. Useful for forcing a specific state.
+- `mergeStrategy` (optional, string) - Determines how to apply the `changes` object to the existing properties. The default is `'merge'`.
+ - `'merge'` (default): Deep merges the `changes` object into the existing properties. Individual properties within objects are updated.
+ - `'replace'`: Replaces the top-level properties specified in `changes` entirely. This is useful for undo operations or for completely resetting a complex property like `style` or `components` to a specific state.
- `refresh` (optional, boolean) - If set to `true`, all property handlers are forcibly re-executed and the object is "refreshed" even if the values in `changes` are the same as before. This is useful when child objects need to be recalculated due to changes in the parent. Default is `false`.
diff --git a/README_KR.md b/README_KR.md
index 4ac15c13..07122590 100644
--- a/README_KR.md
+++ b/README_KR.md
@@ -225,7 +225,7 @@ draw method가 요구하는 **데이터 구조**입니다.
### `update(options)`
-캔버스에 렌더링된 객체의 속성을 업데이트합니다. 기본적으로 변경된 속성만 반영하지만, refresh 또는 arrayMerge 옵션을 통해 업데이트 동작을 정밀하게 제어할 수 있습니다.
+캔버스에 렌더링된 객체의 속성을 업데이트합니다. 기본적으로 변경된 속성만 반영하지만, refresh 또는 mergeStrategy 옵션을 통해 업데이트 동작을 정밀하게 제어할 수 있습니다.
#### **`Options`**
- `path` (optional, string) - [jsonpath](https://github.com/JSONPath-Plus/JSONPath) 문법에 따른 selector로, 이벤트가 적용될 객체를 선택합니다.
@@ -233,9 +233,9 @@ draw method가 요구하는 **데이터 구조**입니다.
- `changes` (optional, object) - 적용할 새로운 속성 (예: 색상, 텍스트 가시성). `refresh` 옵션을 `true`로 설정할 경우 생략할 수 있습니다.
- `history` (optional, boolean \| string) - 해당 `update` 메소드에 의한 변경 사항을 `undoRedoManager`에 기록할 것인지 결정합니다. 이전에 저장된 기록의 historyId와 일치하는 문자열이 제공되면, 두 기록이 하나의 실행 취소/재실행 단계로 병합됩니다.
- `relativeTransform` (optional, boolean) - `position`, `rotation`, `angle` 값에 대해서 상대값을 이용할 지 결정합니다. 만약, `true` 라면 전달된 값을 객체의 값에 더합니다.
-- `arrayMerge` (optional, string) - 배열 속성을 병합하는 방식을 결정합니다. 기본값은 `'merge'` 입니다.
- - `'merge'` (기본값): 대상 배열과 소스 배열을 병합합니다.
- - `'replace'`: 대상 배열을 소스 배열로 완전히 교체하여, 특정 상태로 강제할 때 유용합니다.
+- `mergeStrategy` (optional, string) - `changes` 객체를 기존 속성에 적용하는 방식을 결정합니다. 기본값은 `'merge'` 입니다.
+ - `'merge'` (기본값): `changes` 객체를 기존 속성에 깊게 병합(deep merge)합니다. 객체 내의 개별 속성이 업데이트됩니다.
+ - `'replace'`: `changes`에 지정된 최상위 속성을 통째로 교체합니다. `undo`를 실행하거나 `style`, `components`와 같은 복잡한 속성을 특정 상태로 완전히 리셋할 때 유용합니다.
- `refresh` (optional, boolean) - `true`로 설정하면, `changes`의 속성 값이 이전과 동일하더라도 모든 속성 핸들러를 강제로 다시 실행하여 객체를 "새로고침"합니다. 부모의 상태 변화에 따라 자식 객체를 다시 계산해야 할 때 유용합니다. 기본값은 `false` 입니다.
```js
diff --git a/src/command/commands/base.js b/src/command/commands/base.js
index 410267f3..437a96f5 100644
--- a/src/command/commands/base.js
+++ b/src/command/commands/base.js
@@ -1,8 +1,3 @@
-/**
- * @fileoverview Base command class for undo/redo operations.
- */
-import { isSame } from '../../utils/diff/isSame';
-
/**
* Base Command class.
* Represents an abstract command with execute, undo, and state change checking methods.
@@ -33,14 +28,4 @@ export class Command {
undo() {
throw new Error('The undo() method must be implemented.');
}
-
- /**
- * Checks if the command's state has changed.
- * @returns {boolean} True if the state has changed, false otherwise.
- */
- isStateChanged() {
- return this.config && this.prevConfig
- ? !isSame(this.config, this.prevConfig)
- : true;
- }
}
diff --git a/src/command/commands/bundle.js b/src/command/commands/bundle.js
index 85f0a608..ab558283 100644
--- a/src/command/commands/bundle.js
+++ b/src/command/commands/bundle.js
@@ -39,7 +39,6 @@ export class BundleCommand extends Command {
* @param {Command} command - The command to add and execute.
*/
addCommand(command) {
- command.execute();
this.commands.push(command);
}
}
diff --git a/src/command/commands/update.js b/src/command/commands/update.js
new file mode 100644
index 00000000..bf6975a3
--- /dev/null
+++ b/src/command/commands/update.js
@@ -0,0 +1,62 @@
+import { Command } from './base';
+
+export class UpdateCommand extends Command {
+ constructor(element, changes, options) {
+ super(options.historyId);
+ this.element = element;
+ this.changes = changes;
+ this.options = options;
+ this.previousProps = this._createPreviousState(this.changes);
+ }
+
+ execute() {
+ this.element.update(this.changes, {
+ ...this.options,
+ historyId: false,
+ });
+ }
+
+ undo() {
+ this.element.update(this.previousProps, {
+ ...this.options,
+ mergeStrategy: 'replace',
+ historyId: false,
+ });
+ }
+
+ _createPreviousState(changes) {
+ const slice = {};
+ if (!changes) {
+ return slice;
+ }
+ const currentProps = this.element.props;
+
+ for (const key of Object.keys(changes)) {
+ if (
+ key === 'attrs' &&
+ changes.attrs !== null &&
+ typeof changes.attrs === 'object'
+ ) {
+ const prevAttrs = {};
+ for (const attrKey of Object.keys(changes.attrs)) {
+ prevAttrs[attrKey] = this._deepClone(this.element[attrKey]);
+ }
+ slice.attrs = prevAttrs;
+ } else {
+ slice[key] = this._deepClone(currentProps[key]);
+ }
+ }
+ return slice;
+ }
+
+ _deepClone(value) {
+ if (value && typeof value.clone === 'function') {
+ return value.clone();
+ }
+ try {
+ return structuredClone(value);
+ } catch (_) {
+ return value;
+ }
+ }
+}
diff --git a/src/command/undo-redo-manager.js b/src/command/undo-redo-manager.js
index 50f12a22..ea71449d 100644
--- a/src/command/undo-redo-manager.js
+++ b/src/command/undo-redo-manager.js
@@ -19,9 +19,6 @@ export class UndoRedoManager {
}
execute(command, options = {}) {
- if (!command.isStateChanged()) {
- return;
- }
command.execute();
this._commands = this._commands.slice(0, this._index + 1);
diff --git a/src/command/undo-redo-manager.test.js b/src/command/undo-redo-manager.test.js
new file mode 100644
index 00000000..9c73e2a3
--- /dev/null
+++ b/src/command/undo-redo-manager.test.js
@@ -0,0 +1,172 @@
+import { describe, expect, it, vi } from 'vitest';
+import { Command } from './commands/base';
+import { BundleCommand } from './commands/bundle';
+import { UndoRedoManager } from './undo-redo-manager';
+
+/**
+ * A mock command for testing purposes.
+ * It tracks how many times execute and undo have been called.
+ */
+class MockCommand extends Command {
+ constructor(id = null) {
+ super(id);
+ this.executeCount = 0;
+ this.undoCount = 0;
+ }
+
+ execute() {
+ this.executeCount++;
+ }
+
+ undo() {
+ this.undoCount++;
+ }
+}
+
+describe('UndoRedoManager', () => {
+ it('should correctly execute a command and add it to the history', () => {
+ const manager = new UndoRedoManager();
+ const command = new MockCommand();
+
+ manager.execute(command);
+
+ expect(command.executeCount).toBe(1);
+ expect(manager.commands).toHaveLength(1);
+ expect(manager.canUndo()).toBe(true);
+ expect(manager.canRedo()).toBe(false);
+ });
+
+ it('should correctly undo and redo a command', () => {
+ const manager = new UndoRedoManager();
+ const command = new MockCommand();
+
+ manager.execute(command);
+
+ // Undo
+ manager.undo();
+ expect(command.undoCount).toBe(1);
+ expect(manager.canUndo()).toBe(false);
+ expect(manager.canRedo()).toBe(true);
+
+ // Redo
+ manager.redo();
+ expect(command.executeCount).toBe(2); // Initial execute + redo
+ expect(manager.canUndo()).toBe(true);
+ expect(manager.canRedo()).toBe(false);
+ });
+
+ it('should clear the redo stack after a new command is executed', () => {
+ const manager = new UndoRedoManager();
+ const command1 = new MockCommand();
+ const command2 = new MockCommand();
+
+ manager.execute(command1);
+ manager.undo();
+
+ expect(manager.canRedo()).toBe(true);
+
+ manager.execute(command2);
+
+ expect(command2.executeCount).toBe(1);
+ expect(manager.commands).toHaveLength(1);
+ expect(manager.commands[0]).toBe(command2);
+ expect(manager.canRedo()).toBe(false);
+ });
+
+ it('should respect the maxCommands limit', () => {
+ const manager = new UndoRedoManager(3); // Max 3 commands
+ const commands = [
+ new MockCommand(),
+ new MockCommand(),
+ new MockCommand(),
+ new MockCommand(),
+ ];
+
+ manager.execute(commands[0]);
+ manager.execute(commands[1]);
+ manager.execute(commands[2]);
+ manager.execute(commands[3]);
+
+ expect(manager.commands).toHaveLength(3);
+ expect(manager.commands[0]).toBe(commands[1]); // The first command should be discarded
+ expect(manager.canUndo()).toBe(true);
+ });
+
+ it('should clear the entire command history', () => {
+ const manager = new UndoRedoManager();
+ manager.execute(new MockCommand());
+ manager.execute(new MockCommand());
+
+ expect(manager.commands).toHaveLength(2);
+
+ manager.clear();
+
+ expect(manager.commands).toHaveLength(0);
+ expect(manager.canUndo()).toBe(false);
+ expect(manager.canRedo()).toBe(false);
+ });
+
+ it('should bundle commands with the same historyId', () => {
+ const manager = new UndoRedoManager();
+ const command1 = new MockCommand('bundle-1');
+ const command2 = new MockCommand('bundle-1');
+ const command3 = new MockCommand('bundle-2');
+
+ manager.execute(command1, { historyId: 'bundle-1' });
+ manager.execute(command2, { historyId: 'bundle-1' });
+ manager.execute(command3, { historyId: 'bundle-2' });
+
+ expect(manager.commands).toHaveLength(2); // Two bundles
+ expect(manager.commands[0]).toBeInstanceOf(BundleCommand);
+ expect(manager.commands[0].commands).toHaveLength(2);
+ expect(manager.commands[0].commands[0]).toBe(command1);
+ expect(manager.commands[0].commands[1]).toBe(command2);
+ expect(manager.commands[1]).toBeInstanceOf(BundleCommand);
+ });
+
+ it('should correctly undo/redo a bundle of commands', () => {
+ const manager = new UndoRedoManager();
+ const command1 = new MockCommand('bundle-1');
+ const command2 = new MockCommand('bundle-1');
+
+ manager.execute(command1, { historyId: 'bundle-1' });
+ manager.execute(command2, { historyId: 'bundle-1' });
+
+ expect(command1.executeCount).toBe(1);
+ expect(command2.executeCount).toBe(1);
+
+ // Undo the whole bundle
+ manager.undo();
+ expect(command1.undoCount).toBe(1);
+ expect(command2.undoCount).toBe(1);
+
+ // Redo the whole bundle
+ manager.redo();
+ expect(command1.executeCount).toBe(2);
+ expect(command2.executeCount).toBe(2);
+ });
+
+ it('should correctly notify subscribers on state changes', () => {
+ const manager = new UndoRedoManager();
+ const listener = vi.fn();
+ const unsubscribe = manager.subscribe(listener);
+
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ manager.execute(new MockCommand());
+ expect(listener).toHaveBeenCalledTimes(2);
+
+ manager.undo();
+ expect(listener).toHaveBeenCalledTimes(3);
+
+ manager.redo();
+ expect(listener).toHaveBeenCalledTimes(4);
+
+ manager.clear();
+ expect(listener).toHaveBeenCalledTimes(5);
+
+ unsubscribe();
+ manager.execute(new MockCommand());
+ expect(listener).toHaveBeenCalledTimes(5);
+ });
+});
diff --git a/src/display/components/Text.js b/src/display/components/Text.js
index 3d559eb2..d03061f8 100644
--- a/src/display/components/Text.js
+++ b/src/display/components/Text.js
@@ -5,6 +5,7 @@ import { Placementable } from '../mixins/Placementable';
import { Showable } from '../mixins/Showable';
import { Textable } from '../mixins/Textable';
import { Textstyleable } from '../mixins/Textstyleable';
+import { Tintable } from '../mixins/Tintable';
import { mixins } from '../mixins/utils';
const EXTRA_KEYS = {
@@ -17,17 +18,13 @@ const ComposedText = mixins(
Showable,
Textable,
Textstyleable,
+ Tintable,
Placementable,
);
export class Text extends ComposedText {
constructor(context) {
- super({
- type: 'text',
- context,
- text: '',
- style: { fontFamily: 'FiraCode regular', fill: 'black' },
- });
+ super({ type: 'text', context, text: '' });
this.constructor.registerHandler(
EXTRA_KEYS.PLACEMENT,
diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js
index 9964a575..3db46e12 100644
--- a/src/display/data-schema/component-schema.js
+++ b/src/display/data-schema/component-schema.js
@@ -1,12 +1,12 @@
import { z } from 'zod';
import {
Base,
+ Color,
Margin,
Placement,
PxOrPercentSize,
TextStyle,
TextureStyle,
- Tint,
} from './primitive-schema';
/**
@@ -24,7 +24,7 @@ export const backgroundSchema = Base.extend({
width: { value: 100, unit: '%' },
height: { value: 100, unit: '%' },
})),
- tint: Tint.optional(),
+ tint: Color,
}).strict();
/**
@@ -38,7 +38,7 @@ export const barSchema = Base.extend({
size: PxOrPercentSize,
placement: Placement.default('bottom'),
margin: Margin.default(0),
- tint: Tint.optional(),
+ tint: Color,
animation: z.boolean().default(true),
animationDuration: z.number().default(200),
}).strict();
@@ -54,7 +54,7 @@ export const iconSchema = Base.extend({
size: PxOrPercentSize,
placement: Placement.default('center'),
margin: Margin.default(0),
- tint: Tint.optional(),
+ tint: Color,
}).strict();
/**
@@ -66,9 +66,9 @@ export const textSchema = Base.extend({
type: z.literal('text'),
placement: Placement.default('center'),
margin: Margin.default(0),
- tint: Tint.optional(),
+ tint: Color,
text: z.string().default(''),
- style: TextStyle.optional(),
+ style: TextStyle,
split: z.number().int().default(0),
}).strict();
diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts
index baf502c5..eeaab5ef 100644
--- a/src/display/data-schema/data.d.ts
+++ b/src/display/data-schema/data.d.ts
@@ -8,11 +8,11 @@
*/
import type {
- Color,
HslColor,
HslaColor,
HsvColor,
HsvaColor,
+ Color as PixiColor,
RgbColor,
RgbaColor,
} from './color';
@@ -103,6 +103,7 @@ export interface Grid {
item: {
components?: Component[];
size: Size;
+ padding?: Margin; // Default: 0
};
attrs?: Record;
}
@@ -152,6 +153,7 @@ export interface Item {
show?: boolean; // Default: true
components?: Component[];
size: Size;
+ padding?: Margin; // Default: 0
attrs?: Record;
}
@@ -216,7 +218,7 @@ export interface Background {
label?: string;
show?: boolean; // Default: true
source: TextureStyle | string;
- tint?: Tint;
+ tint?: Color;
attrs?: Record;
}
@@ -244,7 +246,7 @@ export interface Bar {
size: PxOrPercentSize;
placement?: Placement; // Default: 'bottom'
margin?: Margin; // Default: 0
- tint?: Tint;
+ tint?: Color;
animation?: boolean; // Default: true
animationDuration?: number; // Default: 200
attrs?: Record;
@@ -272,7 +274,7 @@ export interface Icon {
size: PxOrPercentSize;
placement?: Placement; // Default: 'center'
margin?: Margin; // Default: 0
- tint?: Tint;
+ tint?: Color;
attrs?: Record;
}
@@ -298,7 +300,7 @@ export interface Text {
text?: string; // Default: ''
placement?: Placement; // Default: 'center'
margin?: Margin; // Default: 0
- tint?: Tint;
+ tint?: Color;
style?: TextStyle;
split?: number; // Default: 0
attrs?: Record;
@@ -434,10 +436,10 @@ export type Margin =
*/
export interface TextureStyle {
type: 'rect';
- fill?: string;
- borderWidth?: number;
- borderColor?: string;
- radius?: number;
+ fill?: string; // Default: 'transparent'
+ borderWidth?: number; // Default: 0
+ borderColor?: string; // Default: 'black'
+ radius?: number; // Default: 0
}
/**
@@ -453,7 +455,19 @@ export interface TextureStyle {
* cap: 'square'
* };
*/
-export type RelationsStyle = Record;
+export interface RelationsStyle {
+ /**
+ * The color of the line. Can be any valid PixiJS ColorSource.
+ * @default 'black'
+ */
+ color?: Color;
+
+ /**
+ * Allows any other properties compatible with PIXI.Graphics' stroke style,
+ * such as `width`, `cap`, `join`, etc.
+ */
+ [key: string]: unknown;
+}
/**
* Defines the text style for a Text component.
@@ -485,6 +499,24 @@ export interface TextStyle {
*/
fontSize?: number | 'auto' | string;
+ /**
+ * The font family.
+ * @default 'FiraCode'
+ */
+ fontFamily?: unknown;
+
+ /**
+ * The font weight.
+ * @default 400
+ */
+ fontWeight?: unknown;
+
+ /**
+ * The fill color.
+ * @default 'black'
+ */
+ fill?: unknown;
+
/**
* Configuration for the 'auto' font size mode.
* This is only active when `fontSize` is 'auto'.
@@ -509,23 +541,23 @@ export interface TextStyle {
*
* @example
* // As a theme key (string)
- * const tintThemeKey: Tint = 'primary.default';
+ * const tintThemeKey: Color = 'primary.default';
*
* @example
* // As a hex string
- * const tintHexString: Tint = '#ff0000';
+ * const tintHexString: Color = '#ff0000';
*
* @example
* // As a hex number
- * const tintHexNumber: Tint = 0xff0000;
+ * const tintHexNumber: Color = 0xff0000;
*
* @example
* // As an RGB object
- * const tintRgbObject: Tint = { r: 255, g: 0, b: 0 };
+ * const tintRgbObject: Color = { r: 255, g: 0, b: 0 };
*
* @see {@link https://pixijs.download/release/docs/color.ColorSource.html}
*/
-export type Tint =
+export type Color =
| string
| number
| number[]
@@ -538,4 +570,4 @@ export type Tint =
| HsvaColor
| RgbColor
| RgbaColor
- | Color;
+ | PixiColor;
diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js
index aa357cf6..28c8afba 100644
--- a/src/display/data-schema/element-schema.js
+++ b/src/display/data-schema/element-schema.js
@@ -23,7 +23,7 @@ export const gridSchema = Base.extend({
cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))),
gap: Gap,
item: z.object({
- components: componentArraySchema,
+ components: componentArraySchema.default([]),
size: Size,
padding: Margin.default(0),
}),
@@ -37,7 +37,7 @@ export const gridSchema = Base.extend({
*/
export const itemSchema = Base.extend({
type: z.literal('item'),
- components: componentArraySchema,
+ components: componentArraySchema.default([]),
size: Size,
padding: Margin.default(0),
}).strict();
@@ -51,7 +51,7 @@ export const itemSchema = Base.extend({
export const relationsSchema = Base.extend({
type: z.literal('relations'),
links: z.array(z.object({ source: z.string(), target: z.string() })),
- style: RelationsStyle.optional(),
+ style: RelationsStyle,
}).strict();
export const elementTypes = z.discriminatedUnion('type', [
diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js
index 6d4958d6..0f637f0d 100644
--- a/src/display/data-schema/primitive-schema.js
+++ b/src/display/data-schema/primitive-schema.js
@@ -1,16 +1,41 @@
import { z } from 'zod';
import { uid } from '../../utils/uuid';
-import { ZERO_MARGIN } from '../mixins/constants';
import {
- Color,
+ DEFAULT_AUTO_FONT_RANGE,
+ DEFAULT_PATHSTYLE,
+ DEFAULT_TEXTSTYLE,
+} from '../mixins/constants';
+import {
HslColor,
HslaColor,
HsvColor,
HsvaColor,
+ Color as PixiColor,
RgbColor,
RgbaColor,
} from './color-schema';
+/**
+ * @see {@link https://pixijs.download/release/docs/color.ColorSource.html}
+ */
+export const Color = z
+ .union([
+ z.string(),
+ z.number(),
+ z.array(z.number()),
+ z.instanceof(Float32Array),
+ z.instanceof(Uint8Array),
+ z.instanceof(Uint8ClampedArray),
+ HslColor,
+ HslaColor,
+ HsvColor,
+ HsvaColor,
+ RgbColor,
+ RgbaColor,
+ PixiColor,
+ ])
+ .default(0xffffff);
+
export const Base = z
.object({
show: z.boolean().default(true),
@@ -128,18 +153,19 @@ export const PxOrPercentSize = z.union([
}),
]);
-export const Placement = z.enum([
- 'left',
- 'left-top',
- 'left-bottom',
- 'top',
- 'right',
- 'right-top',
- 'right-bottom',
- 'bottom',
- 'center',
- 'none',
-]);
+export const Placement = z
+ .enum([
+ 'left',
+ 'left-top',
+ 'left-bottom',
+ 'top',
+ 'right',
+ 'right-top',
+ 'right-bottom',
+ 'bottom',
+ 'center',
+ ])
+ .default('left-top');
export const Gap = z.preprocess(
(val) => (typeof val === 'number' ? { x: val, y: val } : val),
@@ -169,25 +195,29 @@ export const Margin = z.preprocess(
bottom: z.number().default(0),
left: z.number().default(0),
})
- .default(ZERO_MARGIN),
+ .default({}),
);
export const TextureStyle = z
.object({
type: z.enum(['rect']),
- fill: z.string(),
- borderWidth: z.number(),
- borderColor: z.string(),
- radius: z.number(),
+ fill: z.string().default('transparent'),
+ borderWidth: z.number().default(0),
+ borderColor: z.string().default('black'),
+ radius: z.number().default(0),
})
.partial();
/**
* @see {@link https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html}
*/
-export const RelationsStyle = z.record(z.string(), z.unknown());
+export const RelationsStyle = z
+ .object({
+ color: Color.default(DEFAULT_PATHSTYLE.color),
+ })
+ .passthrough()
+ .default({});
-export const DEFAULT_AUTO_FONT_RANGE = { min: 1, max: 100 };
/**
* @see {@link https://pixijs.download/release/docs/text.TextStyleOptions.html}
*/
@@ -202,25 +232,10 @@ export const TextStyle = z
.refine((data) => data.min <= data.max, {
message: 'autoFont.min must not be greater than autoFont.max',
})
- .optional(),
+ .default({}),
+ fontFamily: z.any().default(DEFAULT_TEXTSTYLE.fontFamily),
+ fontWeight: z.any().default(DEFAULT_TEXTSTYLE.fontWeight),
+ fill: z.any().default(DEFAULT_TEXTSTYLE.fill),
})
- .passthrough();
-
-/**
- * @see {@link https://pixijs.download/release/docs/color.ColorSource.html}
- */
-export const Tint = z.union([
- z.string(),
- z.number(),
- z.array(z.number()),
- z.instanceof(Float32Array),
- z.instanceof(Uint8Array),
- z.instanceof(Uint8ClampedArray),
- HslColor,
- HslaColor,
- HsvColor,
- HsvaColor,
- RgbColor,
- RgbaColor,
- Color,
-]);
+ .passthrough()
+ .default({});
diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js
index 3ec81e25..8f517c11 100644
--- a/src/display/data-schema/primitive-schema.test.js
+++ b/src/display/data-schema/primitive-schema.test.js
@@ -3,6 +3,7 @@ import { uid } from '../../utils/uuid';
import { deepPartial } from '../../utils/zod-deep-strict-partial';
import {
Base,
+ Color,
Gap,
Margin,
Placement,
@@ -11,7 +12,6 @@ import {
Size,
TextStyle,
TextureStyle,
- Tint,
pxOrPercentSchema,
} from './primitive-schema';
@@ -19,7 +19,7 @@ vi.mock('../../utils/uuid');
vi.mocked(uid).mockReturnValue('mock-uid-123');
describe('Primitive Schema Tests', () => {
- describe('Tint Schema', () => {
+ describe('Color Schema', () => {
const validColorSourceCases = [
// CSS Color Names (pass as string)
{ case: 'CSS color name', value: 'red' },
@@ -57,8 +57,8 @@ describe('Primitive Schema Tests', () => {
it.each(validColorSourceCases)(
'should correctly parse various color source types: $case',
({ value }) => {
- expect(() => Tint.parse(value)).not.toThrow();
- const parsed = Tint.parse(value);
+ expect(() => Color.parse(value)).not.toThrow();
+ const parsed = Color.parse(value);
expect(parsed).toEqual(value);
},
);
@@ -204,7 +204,6 @@ describe('Primitive Schema Tests', () => {
'right-bottom',
'bottom',
'center',
- 'none',
])('should accept valid placement value: %s', (placement) => {
expect(() => Placement.parse(placement)).not.toThrow();
});
@@ -344,19 +343,29 @@ describe('Primitive Schema Tests', () => {
describe('RelationsStyle Schema', () => {
it('should add default color if not provided', () => {
const data = { lineWidth: 2 };
- expect(RelationsStyle.parse(data)).toEqual({ lineWidth: 2 });
+ expect(RelationsStyle.parse(data)).toEqual({
+ lineWidth: 2,
+ color: 'black',
+ });
});
});
describe('TextStyle Schema', () => {
it('should apply default styles for a partial object', () => {
const data = { fontSize: 16 };
- expect(TextStyle.parse(data)).toEqual({ fontSize: 16 });
+ expect(TextStyle.parse(data)).toEqual({
+ autoFont: { min: 1, max: 100 },
+ fill: 'black',
+ fontFamily: 'FiraCode',
+ fontSize: 16,
+ fontWeight: 400,
+ });
});
it('should not override provided styles', () => {
const data = { fontFamily: 'Arial', fill: 'red', fontWeight: 'bold' };
expect(TextStyle.parse(data)).toEqual({
+ autoFont: { min: 1, max: 100 },
fontFamily: 'Arial',
fontWeight: 'bold',
fill: 'red',
diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js
index 9014188e..06e18217 100644
--- a/src/display/elements/Relations.js
+++ b/src/display/elements/Relations.js
@@ -25,7 +25,6 @@ export class Relations extends ComposedRelations {
initPath() {
const path = new Graphics();
- path.setStrokeStyle({ color: 'black' });
Object.assign(path, { type: 'path', links: [] });
this.addChild(path);
return path;
@@ -33,10 +32,10 @@ export class Relations extends ComposedRelations {
_afterRender() {
super._afterRender();
- this._onUpdate();
+ this._refreshLink();
}
- _onUpdate() {
+ _refreshLink() {
if (this._renderDirty) {
try {
this.renderLink();
@@ -53,8 +52,8 @@ export class Relations extends ComposedRelations {
let lastPoint = null;
for (const link of links) {
- const sourceObject = this.linkedObjects[link.source];
- const targetObject = this.linkedObjects[link.target];
+ const sourceObject = this.linkedObjects?.[link.source];
+ const targetObject = this.linkedObjects?.[link.target];
if (
!sourceObject ||
diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js
index 18b8a731..8d53010c 100644
--- a/src/display/mixins/Base.js
+++ b/src/display/mixins/Base.js
@@ -1,7 +1,8 @@
import { Matrix } from 'pixi.js';
import { isValidationError } from 'zod-validation-error';
+import { UpdateCommand } from '../../command/commands/update';
import { deepMerge } from '../../utils/deepmerge/deepmerge';
-import { diffJson } from '../../utils/diff/diff-json';
+import { diffReplace } from '../../utils/diff/diff-replace';
import { validate } from '../../utils/validator';
import { deepPartial } from '../../utils/zod-deep-strict-partial';
import { Type } from './Type';
@@ -66,21 +67,32 @@ export const Base = (superClass) => {
}
update(changes, schema, options = {}) {
- const { arrayMerge = 'merge', refresh = false } = options;
+ const { mergeStrategy = 'merge', refresh = false } = options;
const effectiveChanges = refresh && !changes ? {} : changes;
const validatedChanges = validate(effectiveChanges, deepPartial(schema));
if (isValidationError(validatedChanges)) throw validatedChanges;
- const prevProps = JSON.parse(JSON.stringify(this.props));
- this.props = deepMerge(prevProps, validatedChanges, { arrayMerge });
+ const nextProps =
+ mergeStrategy === 'replace'
+ ? validate({ ...this.props, ...validatedChanges }, schema)
+ : deepMerge(this.props, validatedChanges);
+ if (isValidationError(nextProps)) throw nextProps;
+ const actualChanges = diffReplace(this.props, nextProps) ?? {};
+
+ if (options?.historyId && Object.keys(actualChanges).length > 0) {
+ const command = new UpdateCommand(this, changes, options);
+ this.context.undoRedoManager.execute(command, options);
+ return;
+ }
+ this.props = nextProps;
const keysToProcess = refresh
- ? Object.keys(this.props)
- : Object.keys(diffJson(prevProps, this.props) ?? {});
+ ? Object.keys(nextProps)
+ : Object.keys(actualChanges);
const { id, label, attrs } = validatedChanges;
if (id || label || attrs) {
- this._applyRaw({ id, label, ...attrs }, arrayMerge);
+ this._applyRaw({ id, label, ...attrs }, mergeStrategy);
}
const tasks = new Map();
@@ -108,21 +120,22 @@ export const Base = (superClass) => {
fullPayload[key] = this.props[key];
}
});
- handler.call(this, fullPayload, { arrayMerge, refresh });
+ handler.call(this, fullPayload, { mergeStrategy, refresh });
});
if (this.parent?._onChildUpdate) {
- this.parent._onChildUpdate(
- this.id,
- diffJson(prevProps, this.props),
- arrayMerge,
- );
+ this.parent._onChildUpdate(this.id, actualChanges, mergeStrategy);
}
}
- _applyRaw(attrs, arrayMerge) {
+ _applyRaw(attrs, mergeStrategy) {
for (const [key, value] of Object.entries(attrs)) {
- if (value === undefined) continue;
+ if (value === undefined) {
+ if (key !== 'id') {
+ delete this[key];
+ }
+ continue;
+ }
if (key === 'x' || key === 'y') {
const x = key === 'x' ? value : (attrs?.x ?? this.x);
@@ -134,13 +147,13 @@ export const Base = (superClass) => {
key === 'height' ? value : (attrs?.height ?? this.height);
this.setSize(width, height);
} else {
- this._updateProperty(key, value, arrayMerge);
+ this._updateProperty(key, value, mergeStrategy);
}
}
}
- _updateProperty(key, value, arrayMerge) {
- deepMerge(this, { [key]: value }, { arrayMerge });
+ _updateProperty(key, value, mergeStrategy) {
+ deepMerge(this, { [key]: value }, { mergeStrategy });
}
};
};
diff --git a/src/display/mixins/Cellsable.js b/src/display/mixins/Cellsable.js
index 6a7f98e6..3ab44dc4 100644
--- a/src/display/mixins/Cellsable.js
+++ b/src/display/mixins/Cellsable.js
@@ -20,6 +20,7 @@ export const Cellsable = (superClass) => {
if (!currentItemIds.has(id)) {
const item = newElement('item', this.context);
item.update({
+ type: 'item',
id,
...itemProps,
attrs: {
diff --git a/src/display/mixins/Childrenable.js b/src/display/mixins/Childrenable.js
index d32be3e6..ddf4cda6 100644
--- a/src/display/mixins/Childrenable.js
+++ b/src/display/mixins/Childrenable.js
@@ -19,7 +19,7 @@ export const Childrenable = (superClass) => {
mapDataSchema,
);
- if (options.arrayMerge === 'replace') {
+ if (options.mergeStrategy === 'replace') {
elements.forEach((element) => {
this.removeChild(element);
element.destroy({ children: true });
@@ -42,7 +42,7 @@ export const Childrenable = (superClass) => {
}
}
- _onChildUpdate(childId, changes, arrayMerge) {
+ _onChildUpdate(childId, changes, mergeStrategy) {
if (!this.props.children) return;
const childIndex = this.props.children.findIndex((c) => c.id === childId);
@@ -50,7 +50,7 @@ export const Childrenable = (superClass) => {
const updatedChildProps = deepMerge(
this.props.children[childIndex],
changes,
- { arrayMerge },
+ { mergeStrategy },
);
this.props.children[childIndex] = updatedChildProps;
}
diff --git a/src/display/mixins/Componentsable.js b/src/display/mixins/Componentsable.js
index e92f6dad..ed43fb70 100644
--- a/src/display/mixins/Componentsable.js
+++ b/src/display/mixins/Componentsable.js
@@ -19,7 +19,7 @@ export const Componentsable = (superClass) => {
componentArraySchema,
);
- if (options.arrayMerge === 'replace') {
+ if (options.mergeStrategy === 'replace') {
components.forEach((component) => {
this.removeChild(component);
component.destroy({ children: true });
@@ -38,11 +38,14 @@ export const Componentsable = (superClass) => {
component = newComponent(componentChange.type, this.context);
this.addChild(component);
}
- component.update(componentChange, options);
+ component.update(
+ { type: componentChange.type, ...componentChange },
+ options,
+ );
}
}
- _onChildUpdate(childId, changes, arrayMerge) {
+ _onChildUpdate(childId, changes, mergeStrategy) {
if (!this.props.components) return;
const childIndex = this.props.components.findIndex(
@@ -52,7 +55,7 @@ export const Componentsable = (superClass) => {
const updatedChildProps = deepMerge(
this.props.components[childIndex],
changes,
- { arrayMerge },
+ { mergeStrategy },
);
this.props.components[childIndex] = updatedChildProps;
}
diff --git a/src/display/mixins/Relationstyleable.js b/src/display/mixins/Relationstyleable.js
index fdd84e8d..4d97e8ba 100644
--- a/src/display/mixins/Relationstyleable.js
+++ b/src/display/mixins/Relationstyleable.js
@@ -6,7 +6,7 @@ const KEYS = ['style'];
export const Relationstyleable = (superClass) => {
const MixedClass = class extends superClass {
- _applyRelationstyle(relevantChanges) {
+ _applyRelationstyle(relevantChanges, options) {
const { style } = relevantChanges;
const path = selector(this, '$.children[?(@.type==="path")]')[0];
if (!path) return;
@@ -14,7 +14,12 @@ export const Relationstyleable = (superClass) => {
if ('color' in style) {
style.color = getColor(this.context.theme, style.color);
}
- path.setStrokeStyle({ ...path.strokeStyle, ...style });
+
+ const newStrokeStyle =
+ options.mergeStrategy === 'replace'
+ ? style
+ : { ...path.strokeStyle, ...style };
+ path.setStrokeStyle(newStrokeStyle);
this._renderDirty = true;
}
};
diff --git a/src/display/mixins/Textstyleable.js b/src/display/mixins/Textstyleable.js
index c3db5ce8..20558b9f 100644
--- a/src/display/mixins/Textstyleable.js
+++ b/src/display/mixins/Textstyleable.js
@@ -1,18 +1,27 @@
+import { TextStyle } from 'pixi.js';
import { getColor } from '../../utils/get';
-import { DEFAULT_AUTO_FONT_RANGE } from '../data-schema/primitive-schema';
-import { FONT_WEIGHT, UPDATE_STAGES } from './constants';
+import {
+ DEFAULT_AUTO_FONT_RANGE,
+ FONT_WEIGHT,
+ UPDATE_STAGES,
+} from './constants';
const KEYS = ['text', 'split', 'style', 'margin'];
export const Textstyleable = (superClass) => {
const MixedClass = class extends superClass {
- _applyTextstyle(relevantChanges) {
+ _applyTextstyle(relevantChanges, options) {
const { style, margin } = relevantChanges;
- const { theme } = this.context.theme;
+ const { theme } = this.context;
+
+ if (options.mergeStrategy === 'replace') {
+ this.style = new TextStyle();
+ }
for (const key in style) {
if (key === 'fontFamily' || key === 'fontWeight') {
- this.style.fontFamily = `${style.fontFamily ?? this.style.fontFamily.split(' ')[0]} ${FONT_WEIGHT[style.fontWeight ?? this.style.fontWeight]}`;
+ this.style.fontWeight = this._getFontWeight(style.fontWeight);
+ this.style.fontFamily = this._getFontFamily(style.fontFamily);
} else if (key === 'fill') {
this.style[key] = getColor(theme, style.fill);
} else if (key === 'fontSize' && style[key] === 'auto') {
@@ -23,6 +32,14 @@ export const Textstyleable = (superClass) => {
}
}
}
+
+ _getFontFamily(value) {
+ return `${value ?? this.style.fontFamily.split(' ')[0]} ${FONT_WEIGHT.STRING[this.style.fontWeight]}`;
+ }
+
+ _getFontWeight(value) {
+ return FONT_WEIGHT.NUMBER[value ?? this.style.fontWeight];
+ }
};
MixedClass.registerHandler(
KEYS,
diff --git a/src/display/mixins/constants.js b/src/display/mixins/constants.js
index 93eba2e7..50105693 100644
--- a/src/display/mixins/constants.js
+++ b/src/display/mixins/constants.js
@@ -8,24 +8,46 @@ export const UPDATE_STAGES = Object.freeze({
});
export const FONT_WEIGHT = {
- 100: 'thin',
- 200: 'extralight',
- 300: 'light',
- 400: 'regular',
- 500: 'medium',
- 600: 'semibold',
- 700: 'bold',
- 800: 'extrabold',
- 900: 'black',
- thin: 'thin',
- extralight: 'extralight',
- light: 'light',
- regular: 'regular',
- medium: 'medium',
- semibold: 'semibold',
- bold: 'bold',
- extrabold: 'extrabold',
- black: 'black',
+ STRING: {
+ 100: 'thin',
+ 200: 'extralight',
+ 300: 'light',
+ 400: 'regular',
+ 500: 'medium',
+ 600: 'semibold',
+ 700: 'bold',
+ 800: 'extrabold',
+ 900: 'black',
+ thin: 'thin',
+ extralight: 'extralight',
+ light: 'light',
+ regular: 'regular',
+ medium: 'medium',
+ semibold: 'semibold',
+ bold: 'bold',
+ extrabold: 'extrabold',
+ black: 'black',
+ },
+ NUMBER: {
+ 100: '100',
+ 200: '200',
+ 300: '300',
+ 400: '400',
+ 500: '500',
+ 600: '600',
+ 700: '700',
+ 800: '800',
+ 900: '900',
+ thin: '100',
+ extralight: '200',
+ light: '300',
+ regular: '400',
+ medium: '500',
+ semibold: '600',
+ bold: '700',
+ extrabold: '800',
+ black: '900',
+ },
};
export const ZERO_MARGIN = Object.freeze({
@@ -34,3 +56,13 @@ export const ZERO_MARGIN = Object.freeze({
bottom: 0,
left: 0,
});
+
+export const DEFAULT_AUTO_FONT_RANGE = { min: 1, max: 100 };
+
+export const DEFAULT_TEXTSTYLE = {
+ fontFamily: 'FiraCode',
+ fill: 'black',
+ fontWeight: 400,
+};
+
+export const DEFAULT_PATHSTYLE = { color: 'black' };
diff --git a/src/display/mixins/utils.js b/src/display/mixins/utils.js
index 1f1ea903..0c52198e 100644
--- a/src/display/mixins/utils.js
+++ b/src/display/mixins/utils.js
@@ -8,7 +8,7 @@ export const tweensOf = (object) => gsap.getTweensOf(object);
export const killTweensOf = (object) => gsap.killTweensOf(object);
-const parseCalcExpression = (expression, parentDimension) => {
+export const parseCalcExpression = (expression, parentDimension) => {
const innerExpression = expression.substring(5, expression.length - 1);
const sanitizedExpression = innerExpression.replace(/\s-\s/g, ' + -');
const terms = sanitizedExpression.split(/\s\+\s/);
diff --git a/src/display/mixins/utils.test.js b/src/display/mixins/utils.test.js
new file mode 100644
index 00000000..173d3360
--- /dev/null
+++ b/src/display/mixins/utils.test.js
@@ -0,0 +1,196 @@
+import { describe, expect, it } from 'vitest';
+import { calcSize, parseCalcExpression } from './utils';
+
+describe('parseCalcExpression', () => {
+ const parentDimension = 200;
+
+ const testCases = [
+ {
+ name: 'a simple percentage value',
+ expression: 'calc(50%)',
+ expected: 100,
+ },
+ { name: 'a simple pixel value', expression: 'calc(75px)', expected: 75 },
+ {
+ name: 'subtraction of pixels from a percentage',
+ expression: 'calc(100% - 50px)',
+ expected: 150,
+ },
+ {
+ name: 'addition of pixels to a percentage',
+ expression: 'calc(25% + 25px)',
+ expected: 75,
+ },
+ {
+ name: 'multiple terms with mixed units',
+ expression: 'calc(50% - 20px + 10% + 5px)',
+ expected: 105,
+ },
+ {
+ name: 'floating point numbers in percentages and pixels',
+ expression: 'calc(12.5% + 15.5px)',
+ expected: 40.5,
+ },
+ {
+ name: 'negative values within the expression',
+ expression: 'calc(50% + -30px)',
+ expected: 70,
+ },
+ {
+ name: 'an expression resulting in zero',
+ expression: 'calc(50% - 100px)',
+ expected: 0,
+ },
+ {
+ name: 'an expression with only pixel values',
+ expression: 'calc(100px - 25px + 10px)',
+ expected: 85,
+ },
+ {
+ name: 'an expression with only percentage values',
+ expression: 'calc(100% - 25% + 10%)',
+ expected: 170,
+ },
+ ];
+
+ it.each(testCases)(
+ 'should correctly parse $name',
+ ({ expression, expected }) => {
+ expect(parseCalcExpression(expression, parentDimension)).toBe(expected);
+ },
+ );
+});
+
+describe('calcSize', () => {
+ const mockParent = {
+ props: {
+ size: { width: 400, height: 200 },
+ padding: { top: 10, right: 20, bottom: 30, left: 40 },
+ },
+ };
+
+ const testCases = [
+ {
+ name: 'with simple pixel values',
+ props: {
+ source: {},
+ size: {
+ width: { value: 100, unit: 'px' },
+ height: { value: 50, unit: 'px' },
+ },
+ },
+ respectsPadding: true,
+ expected: { width: 100, height: 50, borderWidth: 0 },
+ },
+ {
+ name: 'with percentage values based on parent content area',
+ props: {
+ source: {},
+ size: {
+ width: { value: 50, unit: '%' },
+ height: { value: 25, unit: '%' },
+ },
+ },
+ respectsPadding: true,
+ // Expected: width = 340 * 0.5 = 170, height = 160 * 0.25 = 40
+ expected: { width: 170, height: 40, borderWidth: 0 },
+ },
+ {
+ name: 'with borderWidth, which is added to the final size',
+ props: {
+ source: { borderWidth: 5 },
+ size: {
+ width: { value: 100, unit: 'px' },
+ height: { value: 50, unit: 'px' },
+ },
+ },
+ respectsPadding: true,
+ expected: { width: 105, height: 55, borderWidth: 5 },
+ },
+ {
+ name: 'with calc() expressions for width and height',
+ props: {
+ source: { borderWidth: 2 },
+ size: { width: 'calc(100% - 40px)', height: 'calc(50% + 10px)' },
+ },
+ respectsPadding: true,
+ // Expected: width = (340 - 40) + 2 = 302, height = (160 * 0.5 + 10) + 2 = 92
+ expected: { width: 302, height: 92, borderWidth: 2 },
+ },
+ {
+ name: "when respectsPadding is false, using parent's full dimensions",
+ props: {
+ source: {},
+ size: {
+ width: { value: 50, unit: '%' },
+ height: { value: 50, unit: '%' },
+ },
+ },
+ respectsPadding: false,
+ // Expected: width = 400 * 0.5 = 200, height = 200 * 0.5 = 100
+ expected: { width: 200, height: 100, borderWidth: 0 },
+ },
+ {
+ name: 'gracefully when the component has no parent',
+ props: {
+ source: {},
+ size: {
+ width: { value: 50, unit: '%' },
+ height: { value: 100, unit: 'px' },
+ },
+ },
+ respectsPadding: true,
+ parent: null,
+ // Expected: width = 0 (since % of null is 0), height = 100
+ expected: { width: 0, height: 100, borderWidth: 0 },
+ },
+ {
+ name: 'with pixel values and borderWidth',
+ props: {
+ source: { borderWidth: 5 },
+ size: {
+ width: { value: 100, unit: 'px' },
+ height: { value: 50, unit: 'px' },
+ },
+ },
+ respectsPadding: true,
+ // Expected: width = 100 + 5, height = 50 + 5
+ expected: { width: 105, height: 55, borderWidth: 5 },
+ },
+ {
+ name: 'with percentage values and borderWidth',
+ props: {
+ source: { borderWidth: 10 },
+ size: {
+ width: { value: 50, unit: '%' },
+ height: { value: 25, unit: '%' },
+ },
+ },
+ respectsPadding: true,
+ // Expected: width = (340 * 0.5) + 10 = 180, height = (160 * 0.25) + 10 = 50
+ expected: { width: 180, height: 50, borderWidth: 10 },
+ },
+ {
+ name: 'with calc() expressions and borderWidth',
+ props: {
+ source: { borderWidth: 2 },
+ size: { width: 'calc(100% - 40px)', height: 'calc(50% + 10px)' },
+ },
+ respectsPadding: true,
+ // Expected: width = (340 - 40) + 2 = 302, height = (160 * 0.5 + 10) + 2 = 92
+ expected: { width: 302, height: 92, borderWidth: 2 },
+ },
+ ];
+
+ it.each(testCases)(
+ 'should calculate size correctly $name',
+ ({ props, respectsPadding, parent = mockParent, expected }) => {
+ const mockComponent = {
+ constructor: { respectsPadding },
+ parent,
+ };
+ const result = calcSize(mockComponent, props);
+ expect(result).toEqual(expected);
+ },
+ );
+});
diff --git a/src/display/update.js b/src/display/update.js
index fec59f9c..9d9cae48 100644
--- a/src/display/update.js
+++ b/src/display/update.js
@@ -10,7 +10,7 @@ const updateSchema = z.object({
changes: z.record(z.unknown()).nullable().default(null),
history: z.union([z.boolean(), z.string()]).default(false),
relativeTransform: z.boolean().default(false),
- arrayMerge: z.enum(['merge', 'replace']).default('merge'),
+ mergeStrategy: z.enum(['merge', 'replace']).default('merge'),
refresh: z.boolean().default(false),
});
@@ -34,7 +34,7 @@ export const update = (viewport, opts) => {
}
element.update(changes, {
historyId,
- arrayMerge: config.arrayMerge,
+ mergeStrategy: config.mergeStrategy,
refresh: config.refresh,
});
}
diff --git a/src/init.js b/src/init.js
index c1c9538f..6a1ebf17 100644
--- a/src/init.js
+++ b/src/init.js
@@ -5,6 +5,7 @@ import * as PIXI from 'pixi.js';
import { firaCode } from './assets/fonts';
import { icons } from './assets/icons';
import { Type } from './display/mixins/Type';
+import { FONT_WEIGHT } from './display/mixins/constants';
import { deepMerge } from './utils/deepmerge/deepmerge';
import { plugin } from './utils/event/viewport';
import { uid } from './utils/uuid';
@@ -45,7 +46,10 @@ const DEFAULT_INIT_OPTIONS = {
items: Object.entries(firaCode).map(([key, font]) => ({
alias: `firaCode-${key}`,
src: font,
- data: { family: `FiraCode ${key}` },
+ data: {
+ family: `FiraCode ${key}`,
+ weights: [FONT_WEIGHT.NUMBER[key]],
+ },
})),
},
],
diff --git a/src/patchmap.js b/src/patchmap.js
index a6409cac..6b063276 100644
--- a/src/patchmap.js
+++ b/src/patchmap.js
@@ -1,5 +1,5 @@
import gsap from 'gsap';
-import { Application, Graphics } from 'pixi.js';
+import { Application, Graphics, UPDATE_PRIORITY } from 'pixi.js';
import { isValidationError } from 'zod-validation-error';
import { UndoRedoManager } from './command/undo-redo-manager';
import { draw } from './display/draw';
@@ -149,7 +149,13 @@ class Patchmap {
// Force a refresh of all relation elements after the initial draw. This ensures
// that all link targets exist in the scene graph before the relations
// attempt to draw their links.
- this.update({ path: '$..children[?(@.type=="relations")]', refresh: true });
+ this.app.ticker.addOnce(
+ () => {
+ this.update({ path: '$..[?(@.type=="relations")]', refresh: true });
+ },
+ undefined,
+ UPDATE_PRIORITY.UTILITY,
+ );
this.app.start();
return validatedData;
diff --git a/src/tests/render/components/Background.test.js b/src/tests/render/components/Background.test.js
index 6278a173..a9e659e4 100644
--- a/src/tests/render/components/Background.test.js
+++ b/src/tests/render/components/Background.test.js
@@ -99,7 +99,7 @@ describe('Background Component In Item', () => {
});
});
- it('should replace the entire component array when arrayMerge is "replace"', () => {
+ it('should replace the entire component array when mergeStrategy is "replace"', () => {
const patchmap = getPatchmap();
patchmap.draw([baseItemData]);
@@ -113,7 +113,7 @@ describe('Background Component In Item', () => {
changes: {
components: [newBackground],
},
- arrayMerge: 'replace',
+ mergeStrategy: 'replace',
});
const item = patchmap.selector('$..[?(@.id=="item-with-background")]')[0];
diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js
index 587a804c..bc01990a 100644
--- a/src/tests/render/patchmap.test.js
+++ b/src/tests/render/patchmap.test.js
@@ -144,7 +144,7 @@ describe('patchmap test', () => {
expect(background.props.source.fill).toBe('blue');
});
- it('should replace an array completely when arrayMerge is "replace"', () => {
+ it('should replace an array completely when mergeStrategy is "replace"', () => {
const initialGridItemCount = patchmap.selector(
'$..[?(@.id=="grid-1")]',
)[0].children.length;
@@ -154,7 +154,7 @@ describe('patchmap test', () => {
changes: {
cells: [[1, 1, 1, 1]],
},
- arrayMerge: 'replace',
+ mergeStrategy: 'replace',
});
const gridItems = patchmap.selector('$..[?(@.id=="grid-1")]')[0].children;
expect(gridItems.length).toBe(4);
diff --git a/src/tests/undo-redo/Background.test.js b/src/tests/undo-redo/Background.test.js
new file mode 100644
index 00000000..7aa5d646
--- /dev/null
+++ b/src/tests/undo-redo/Background.test.js
@@ -0,0 +1,216 @@
+import { describe, expect, it } from 'vitest';
+import { deepMerge } from '../../utils/deepmerge/deepmerge';
+import { setupPatchmapTests } from '../render/patchmap.setup';
+
+describe('Undo/Redo: Background Component', () => {
+ const { getPatchmap } = setupPatchmapTests();
+
+ const baseItemData = {
+ type: 'item',
+ id: 'item-with-background',
+ size: { width: 100, height: 100 },
+ components: [
+ {
+ type: 'background',
+ id: 'background-1',
+ source: {
+ type: 'rect',
+ fill: 'white',
+ borderColor: 'black',
+ borderWidth: 2,
+ radius: 4,
+ },
+ tint: 'gray.default', // 0xd9d9d9
+ },
+ ],
+ };
+
+ const getBackground = (patchmap) => {
+ return patchmap.selector('$..[?(@.id=="background-1")]')[0];
+ };
+
+ it('should correctly undo/redo a single property change (tint)', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const background = getBackground(patchmap);
+ const originalTint = background.tint;
+ expect(originalTint).toBe(0xd9d9d9);
+
+ patchmap.update({
+ path: '$..[?(@.id=="background-1")]',
+ changes: { tint: 'primary.accent' }, // #EF4444
+ history: true,
+ });
+ const updatedTint = getBackground(patchmap).tint;
+ expect(updatedTint).toBe(0xef4444);
+
+ patchmap.undoRedoManager.undo();
+ expect(getBackground(patchmap).tint).toBe(originalTint);
+
+ patchmap.undoRedoManager.redo();
+ expect(getBackground(patchmap).tint).toBe(updatedTint);
+ });
+
+ it('should correctly undo/redo a nested property change (source.fill)', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const background = getBackground(patchmap);
+ const originalFill = background.props.source.fill;
+ expect(originalFill).toBe('white');
+
+ patchmap.update({
+ path: '$..[?(@.id=="background-1")]',
+ changes: { source: { fill: 'red' } },
+ history: true,
+ });
+ const updatedFill = background.props.source.fill;
+ expect(updatedFill).toBe('red');
+
+ patchmap.undoRedoManager.undo();
+ expect(getBackground(patchmap).props.source.fill).toBe(originalFill);
+
+ patchmap.undoRedoManager.redo();
+ expect(getBackground(patchmap).props.source.fill).toBe(updatedFill);
+ });
+
+ it('should correctly undo/redo multiple property changes simultaneously', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const background = getBackground(patchmap);
+ const originalState = {
+ tint: background.tint,
+ borderWidth: background.props.source.borderWidth,
+ fill: background.props.source.fill,
+ };
+
+ const changes = {
+ tint: 'primary.dark', // #083967
+ source: {
+ borderWidth: 5,
+ fill: 'blue',
+ },
+ };
+ patchmap.update({
+ path: '$..[?(@.id=="background-1")]',
+ changes,
+ history: true,
+ });
+
+ const updatedState = {
+ tint: background.tint,
+ borderWidth: background.props.source.borderWidth,
+ fill: background.props.source.fill,
+ };
+ expect(updatedState.tint).toBe(0x083967);
+ expect(updatedState.borderWidth).toBe(5);
+ expect(updatedState.fill).toBe('blue');
+
+ patchmap.undoRedoManager.undo();
+ const undoneState = {
+ tint: getBackground(patchmap).tint,
+ borderWidth: getBackground(patchmap).props.source.borderWidth,
+ fill: getBackground(patchmap).props.source.fill,
+ };
+ expect(undoneState).toEqual(originalState);
+
+ patchmap.undoRedoManager.redo();
+ const redoneState = {
+ tint: getBackground(patchmap).tint,
+ borderWidth: getBackground(patchmap).props.source.borderWidth,
+ fill: getBackground(patchmap).props.source.fill,
+ };
+ expect(redoneState).toEqual(updatedState);
+ });
+
+ it('should correctly undo/redo replacing the entire source object', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const background = getBackground(patchmap);
+ const originalSource = JSON.parse(JSON.stringify(background.props.source));
+
+ const newSource = { type: 'rect', fill: 'green', radius: 10 };
+ const expectedMergedSource = deepMerge(originalSource, newSource);
+
+ patchmap.update({
+ path: '$..[?(@.id=="background-1")]',
+ changes: { source: newSource },
+ history: true,
+ });
+ expect(background.props.source).toEqual(expectedMergedSource);
+
+ patchmap.undoRedoManager.undo();
+ expect(background.props.source).toEqual(originalSource);
+
+ patchmap.undoRedoManager.redo();
+ expect(background.props.source).toEqual(expectedMergedSource);
+ });
+
+ it('should correctly undo/redo pixi attributes (alpha, angle) via attrs', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const background = getBackground(patchmap);
+ const originalAlpha = background.alpha;
+ const originalAngle = background.angle;
+
+ expect(originalAlpha).toBe(1);
+ expect(originalAngle).toBe(0);
+
+ const changes = { attrs: { alpha: 0.5, angle: 45 } };
+ patchmap.update({
+ path: '$..[?(@.id=="background-1")]',
+ changes,
+ history: true,
+ });
+
+ const updatedBackground = getBackground(patchmap);
+ expect(updatedBackground.alpha).toBe(0.5);
+ expect(updatedBackground.angle).toBe(45);
+
+ // Undo
+ patchmap.undoRedoManager.undo();
+ const undoneBackground = getBackground(patchmap);
+ expect(undoneBackground.alpha).toBe(originalAlpha);
+ expect(undoneBackground.angle).toBe(originalAngle);
+
+ // Redo
+ patchmap.undoRedoManager.redo();
+ const redoneBackground = getBackground(patchmap);
+ expect(redoneBackground.alpha).toBe(0.5);
+ expect(redoneBackground.angle).toBe(45);
+ });
+
+ it('should correctly undo/redo custom metadata via attrs', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const background = getBackground(patchmap);
+ expect(background?.customMeta).toBeUndefined();
+
+ const customData = { version: 1, author: 'test' };
+ const changes = { attrs: { customMeta: customData } };
+
+ patchmap.update({
+ path: '$..[?(@.id=="background-1")]',
+ changes,
+ history: true,
+ });
+
+ const updatedBackground = getBackground(patchmap);
+ expect(updatedBackground.customMeta).toEqual(customData);
+
+ // Undo
+ patchmap.undoRedoManager.undo();
+ const undoneBackground = getBackground(patchmap);
+ expect(undoneBackground?.customMeta).toBeUndefined();
+
+ // Redo
+ patchmap.undoRedoManager.redo();
+ const redoneBackground = getBackground(patchmap);
+ expect(redoneBackground.customMeta).toEqual(customData);
+ });
+});
diff --git a/src/tests/undo-redo/Bar.test.js b/src/tests/undo-redo/Bar.test.js
new file mode 100644
index 00000000..14295ce9
--- /dev/null
+++ b/src/tests/undo-redo/Bar.test.js
@@ -0,0 +1,305 @@
+import gsap from 'gsap';
+import { describe, expect, it } from 'vitest';
+import { setupPatchmapTests } from '../render/patchmap.setup';
+
+describe('Undo/Redo: Bar Component', () => {
+ const { getPatchmap } = setupPatchmapTests();
+
+ const baseItemData = {
+ type: 'item',
+ id: 'item-with-bar',
+ size: { width: 200, height: 100 },
+ components: [
+ {
+ type: 'bar',
+ id: 'bar-1',
+ source: { type: 'rect', fill: 'blue', radius: 2 },
+ size: { width: '50%', height: 20 }, // width: 100, height: 20
+ placement: 'bottom',
+ margin: 5,
+ tint: 0xffffff, // white
+ animation: true,
+ animationDuration: 200,
+ },
+ ],
+ };
+
+ const baseItemDataWithoutOptionals = {
+ type: 'item',
+ id: 'item-with-bar',
+ size: { width: 200, height: 100 },
+ components: [
+ {
+ type: 'bar',
+ id: 'bar-1',
+ source: { type: 'rect', fill: 'blue' },
+ size: { width: '50%', height: 20 },
+ },
+ ],
+ };
+
+ const getBar = (patchmap) => {
+ return patchmap.selector('$..[?(@.id=="bar-1")]')[0];
+ };
+
+ it('should correctly undo/redo a size change and verify rendered dimensions', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+ gsap.exportRoot().totalProgress(1);
+
+ const bar = getBar(patchmap);
+ const originalSizeProps = JSON.parse(JSON.stringify(bar.props.size));
+ const originalDimensions = { width: bar.width, height: bar.height };
+ expect(originalDimensions).toEqual({ width: 100, height: 20 });
+
+ const newSize = { width: '80%', height: 30 };
+ patchmap.update({
+ path: '$..[?(@.id=="bar-1")]',
+ changes: { size: newSize },
+ history: true,
+ });
+ gsap.exportRoot().totalProgress(1);
+
+ const updatedBar = getBar(patchmap);
+ expect(updatedBar.props.size).toEqual({
+ width: { value: 80, unit: '%' },
+ height: { value: 30, unit: 'px' },
+ });
+ expect(updatedBar.width).toBe(160); // 200 * 80%
+ expect(updatedBar.height).toBe(30);
+
+ patchmap.undoRedoManager.undo();
+ gsap.exportRoot().totalProgress(1);
+ const undoneBar = getBar(patchmap);
+ expect(undoneBar.props.size).toEqual(originalSizeProps);
+ expect(undoneBar.width).toBe(originalDimensions.width);
+ expect(undoneBar.height).toBe(originalDimensions.height);
+
+ patchmap.undoRedoManager.redo();
+ gsap.exportRoot().totalProgress(1);
+ const redoneBar = getBar(patchmap);
+ expect(redoneBar.props.size).toEqual({
+ width: { value: 80, unit: '%' },
+ height: { value: 30, unit: 'px' },
+ });
+ expect(redoneBar.width).toBe(160);
+ expect(redoneBar.height).toBe(30);
+ });
+
+ it('should correctly undo/redo a placement change and verify rendered position', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+ gsap.exportRoot().totalProgress(1);
+
+ const bar = getBar(patchmap);
+ const originalPosition = { x: bar.x, y: bar.y };
+ expect(originalPosition).toEqual({ x: 50, y: 75 });
+
+ patchmap.update({
+ path: '$..[?(@.id=="bar-1")]',
+ changes: { placement: 'left-top' },
+ history: true,
+ });
+
+ const updatedBar = getBar(patchmap);
+ expect(updatedBar.props.placement).toBe('left-top');
+ expect(updatedBar.x).toBe(5);
+ expect(updatedBar.y).toBe(5);
+
+ patchmap.undoRedoManager.undo();
+ const undoneBar = getBar(patchmap);
+ expect(undoneBar.props.placement).toBe('bottom');
+ expect(undoneBar.x).toBe(originalPosition.x);
+ expect(undoneBar.y).toBe(originalPosition.y);
+
+ patchmap.undoRedoManager.redo();
+ const redoneBar = getBar(patchmap);
+ expect(redoneBar.props.placement).toBe('left-top');
+ expect(redoneBar.x).toBe(5);
+ expect(redoneBar.y).toBe(5);
+ });
+
+ it('should correctly undo/redo a margin change and verify rendered position', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+ gsap.exportRoot().totalProgress(1);
+
+ const bar = getBar(patchmap);
+ const originalPosition = { x: bar.x, y: bar.y };
+ expect(originalPosition).toEqual({ x: 50, y: 75 });
+
+ patchmap.update({
+ path: '$..[?(@.id=="bar-1")]',
+ changes: { margin: 20 },
+ history: true,
+ });
+
+ const updatedBar = getBar(patchmap);
+ expect(updatedBar.props.margin).toEqual({
+ top: 20,
+ right: 20,
+ bottom: 20,
+ left: 20,
+ });
+ expect(updatedBar.x).toBe(50);
+ expect(updatedBar.y).toBe(60);
+
+ patchmap.undoRedoManager.undo();
+ const undoneBar = getBar(patchmap);
+ expect(undoneBar.props.margin).toEqual({
+ top: 5,
+ right: 5,
+ bottom: 5,
+ left: 5,
+ });
+ expect(undoneBar.x).toBe(originalPosition.x);
+ expect(undoneBar.y).toBe(originalPosition.y);
+
+ patchmap.undoRedoManager.redo();
+ const redoneBar = getBar(patchmap);
+ expect(redoneBar.props.margin).toEqual({
+ top: 20,
+ right: 20,
+ bottom: 20,
+ left: 20,
+ });
+ expect(redoneBar.x).toBe(50);
+ expect(redoneBar.y).toBe(60);
+ });
+
+ it('should correctly undo/redo a single property change (tint)', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const bar = getBar(patchmap);
+ const originalTint = bar.tint;
+ expect(originalTint).toBe(0xffffff);
+
+ patchmap.update({
+ path: '$..[?(@.id=="bar-1")]',
+ changes: { tint: 0xff0000 },
+ history: true,
+ });
+ const updatedTint = getBar(patchmap).tint;
+ expect(updatedTint).toBe(0xff0000);
+
+ patchmap.undoRedoManager.undo();
+ expect(getBar(patchmap).tint).toBe(originalTint);
+
+ patchmap.undoRedoManager.redo();
+ expect(getBar(patchmap).tint).toBe(updatedTint);
+ });
+
+ it('should correctly undo/redo a nested property change (source.fill)', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const bar = getBar(patchmap);
+ const originalFill = bar.props.source.fill;
+ expect(originalFill).toBe('blue');
+
+ patchmap.update({
+ path: '$..[?(@.id=="bar-1")]',
+ changes: { source: { fill: 'green' } },
+ history: true,
+ });
+ const updatedFill = getBar(patchmap).props.source.fill;
+ expect(updatedFill).toBe('green');
+
+ patchmap.undoRedoManager.undo();
+ expect(getBar(patchmap).props.source.fill).toBe(originalFill);
+
+ patchmap.undoRedoManager.redo();
+ expect(getBar(patchmap).props.source.fill).toBe(updatedFill);
+ });
+
+ describe('when optional properties are initially undefined', () => {
+ it('should correctly add a new property (margin) and undo to its default state', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemDataWithoutOptionals]);
+ gsap.exportRoot().totalProgress(1);
+
+ const bar = getBar(patchmap);
+ const originalMargin = bar.props.margin;
+ const originalPosition = { x: bar.x, y: bar.y };
+
+ expect(originalMargin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
+ expect(originalPosition).toEqual({ x: 50, y: 80 });
+
+ patchmap.update({
+ path: '$..[?(@.id=="bar-1")]',
+ changes: { margin: 15 },
+ history: true,
+ });
+ gsap.exportRoot().totalProgress(1);
+
+ const updatedBar = getBar(patchmap);
+ expect(updatedBar.props.margin).toEqual({
+ top: 15,
+ right: 15,
+ bottom: 15,
+ left: 15,
+ });
+ expect(updatedBar.x).toBe(50);
+ expect(updatedBar.y).toBe(65);
+
+ patchmap.undoRedoManager.undo();
+ gsap.exportRoot().totalProgress(1);
+ const undoneBar = getBar(patchmap);
+ expect(undoneBar.props.margin).toEqual(originalMargin);
+ expect(undoneBar.x).toBe(originalPosition.x);
+ expect(undoneBar.y).toBe(originalPosition.y);
+
+ patchmap.undoRedoManager.redo();
+ gsap.exportRoot().totalProgress(1);
+ const redoneBar = getBar(patchmap);
+ expect(redoneBar.props.margin).toEqual({
+ top: 15,
+ right: 15,
+ bottom: 15,
+ left: 15,
+ });
+ expect(redoneBar.x).toBe(50);
+ expect(redoneBar.y).toBe(65);
+ });
+
+ it('should correctly add a new property (placement) and undo to its default state', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemDataWithoutOptionals]);
+ gsap.exportRoot().totalProgress(1);
+
+ const bar = getBar(patchmap);
+ const originalPlacement = bar.props.placement;
+ const originalPosition = { x: bar.x, y: bar.y };
+
+ expect(originalPlacement).toBe('bottom');
+ expect(originalPosition).toEqual({ x: 50, y: 80 });
+
+ patchmap.update({
+ path: '$..[?(@.id=="bar-1")]',
+ changes: { placement: 'center' },
+ history: true,
+ });
+ gsap.exportRoot().totalProgress(1);
+
+ const updatedBar = getBar(patchmap);
+ expect(updatedBar.props.placement).toBe('center');
+ expect(updatedBar.x).toBe(50);
+ expect(updatedBar.y).toBe(40);
+
+ patchmap.undoRedoManager.undo();
+ gsap.exportRoot().totalProgress(1);
+ const undoneBar = getBar(patchmap);
+ expect(undoneBar.props.placement).toBe(originalPlacement);
+ expect(undoneBar.x).toBe(originalPosition.x);
+ expect(undoneBar.y).toBe(originalPosition.y);
+
+ patchmap.undoRedoManager.redo();
+ gsap.exportRoot().totalProgress(1);
+ const redoneBar = getBar(patchmap);
+ expect(redoneBar.props.placement).toBe('center');
+ expect(redoneBar.x).toBe(50);
+ expect(redoneBar.y).toBe(40);
+ });
+ });
+});
diff --git a/src/tests/undo-redo/Grid.test.js b/src/tests/undo-redo/Grid.test.js
new file mode 100644
index 00000000..db06833a
--- /dev/null
+++ b/src/tests/undo-redo/Grid.test.js
@@ -0,0 +1,225 @@
+import { describe, expect, it } from 'vitest';
+import { setupPatchmapTests } from '../render/patchmap.setup';
+
+describe('Undo/Redo: Grid Element', () => {
+ const { getPatchmap } = setupPatchmapTests();
+
+ const baseGridData = [
+ {
+ type: 'grid',
+ id: 'grid-1',
+ cells: [
+ [1, 0, 1],
+ [1, 1, 0],
+ ],
+ gap: 10,
+ item: {
+ size: { width: 50, height: 50 },
+ components: [
+ {
+ type: 'background',
+ id: 'bg-component',
+ label: 'bg-component',
+ source: { type: 'rect', fill: 'white' },
+ tint: 'primary.default',
+ },
+ ],
+ },
+ attrs: { x: 100, y: 100 },
+ },
+ ];
+
+ const getGrid = (patchmap) => {
+ return patchmap.selector('$..[?(@.id=="grid-1")]')[0];
+ };
+
+ const getGridItems = (patchmap) => {
+ return getGrid(patchmap).children;
+ };
+
+ it('should undo/redo changes to the "cells" property, altering the number of items', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(baseGridData);
+
+ expect(getGridItems(patchmap).length).toBe(4);
+
+ const newCells = [
+ [1, 1, 1],
+ [1, 1, 1],
+ ];
+ patchmap.update({
+ path: '$..[?(@.id=="grid-1")]',
+ changes: { cells: newCells },
+ history: true,
+ });
+ expect(getGridItems(patchmap).length).toBe(6);
+
+ patchmap.undoRedoManager.undo();
+ expect(getGridItems(patchmap).length).toBe(4);
+
+ patchmap.undoRedoManager.redo();
+ expect(getGridItems(patchmap).length).toBe(6);
+ });
+
+ it('should undo/redo changes to the "gap" property, altering item positions', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(baseGridData);
+
+ const item1_1 = getGrid(patchmap).children.find(
+ (item) => item.id === 'grid-1.1.1',
+ );
+ const originalPosition = { x: item1_1.x, y: item1_1.y };
+ // size (50) + gap (10) = 60. So position is (60, 60).
+ expect(originalPosition).toEqual({ x: 60, y: 60 });
+
+ patchmap.update({
+ path: '$..[?(@.id=="grid-1")]',
+ changes: { gap: 20 },
+ history: true,
+ });
+ const updatedPosition = { x: item1_1.x, y: item1_1.y };
+ // size (50) + gap (20) = 70. So position is (70, 70).
+ expect(updatedPosition).toEqual({ x: 70, y: 70 });
+
+ patchmap.undoRedoManager.undo();
+ expect({ x: item1_1.x, y: item1_1.y }).toEqual(originalPosition);
+
+ patchmap.undoRedoManager.redo();
+ expect({ x: item1_1.x, y: item1_1.y }).toEqual(updatedPosition);
+ });
+
+ it('should undo/redo changes to the "item.size" property, altering item size and positions', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(baseGridData);
+
+ const items = getGridItems(patchmap);
+ const item1_1 = items.find((c) => c.id === 'grid-1.1.1');
+ const originalSize = { width: items[0].width, height: items[0].height };
+ const originalPosition = { x: item1_1.x, y: item1_1.y };
+ expect(originalSize).toEqual({ width: 50, height: 50 });
+ expect(originalPosition).toEqual({ x: 60, y: 60 });
+
+ patchmap.update({
+ path: '$..[?(@.id=="grid-1")]',
+ changes: { item: { size: { width: 60, height: 40 } } },
+ history: true,
+ });
+ const updatedSize = { width: items[0].width, height: items[0].height };
+ const updatedPosition = { x: item1_1.x, y: item1_1.y };
+ expect(updatedSize).toEqual({ width: 60, height: 40 });
+ // new pos: x = 60 + 10 = 70; y = 40 + 10 = 50
+ expect(updatedPosition).toEqual({ x: 70, y: 50 });
+
+ patchmap.undoRedoManager.undo();
+ expect({ width: items[0].width, height: items[0].height }).toEqual(
+ originalSize,
+ );
+ expect({ x: item1_1.x, y: item1_1.y }).toEqual(originalPosition);
+
+ patchmap.undoRedoManager.redo();
+ expect({ width: items[0].width, height: items[0].height }).toEqual(
+ updatedSize,
+ );
+ expect({ x: item1_1.x, y: item1_1.y }).toEqual(updatedPosition);
+ });
+
+ it('should undo/redo changes to "item.components", altering all items style', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(baseGridData);
+
+ const getItemBackground = (item) => item.getChildByLabel('bg-component');
+
+ const originalTint = getItemBackground(getGridItems(patchmap)[0]).tint;
+ expect(originalTint).toBe(0x0c73bf); // primary.default
+
+ const newComponents = [
+ {
+ type: 'background',
+ id: 'bg-component',
+ source: { type: 'rect', fill: 'red' },
+ tint: 'primary.accent', // #EF4444
+ },
+ ];
+
+ patchmap.update({
+ path: '$..[?(@.id=="grid-1")]',
+ changes: { item: { components: newComponents } },
+ history: true,
+ });
+
+ const updatedTint = getItemBackground(getGridItems(patchmap)[0]).tint;
+ expect(updatedTint).toBe(0xef4444);
+
+ patchmap.undoRedoManager.undo();
+ expect(getItemBackground(getGridItems(patchmap)[0]).tint).toBe(
+ originalTint,
+ );
+
+ patchmap.undoRedoManager.redo();
+ expect(getItemBackground(getGridItems(patchmap)[0]).tint).toBe(updatedTint);
+ });
+
+ it('should handle simultaneous changes to multiple properties correctly', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(baseGridData);
+
+ const originalState = {
+ itemCount: getGridItems(patchmap).length,
+ itemSize: { ...getGrid(patchmap).props.item.size },
+ gap: getGrid(patchmap).props.gap,
+ tint: getGridItems(patchmap)[0].getChildByLabel('bg-component').tint,
+ };
+
+ patchmap.update({
+ path: '$..[?(@.id=="grid-1")]',
+ changes: {
+ cells: [
+ [1, 1],
+ [1, 1],
+ ], // Change structure
+ gap: 20, // Change layout
+ item: {
+ // Change item properties
+ size: { width: 40, height: 40 },
+ components: [
+ {
+ type: 'background',
+ id: 'bg-component',
+ source: { type: 'rect', fill: 'blue' },
+ tint: 'primary.accent',
+ },
+ ],
+ },
+ },
+ history: true,
+ });
+
+ // Verify updated state
+ const updatedGrid = getGrid(patchmap);
+ const updatedItems = updatedGrid.children;
+ expect(updatedItems.length).toBe(4);
+ expect(updatedGrid.props.gap).toStrictEqual({ x: 20, y: 20 });
+ expect(updatedGrid.props.item.size.width).toBe(40);
+ expect(updatedItems[0].getChildByLabel('bg-component').tint).toBe(0xef4444);
+
+ // --- Undo ---
+ patchmap.undoRedoManager.undo();
+ const undoneGrid = getGrid(patchmap);
+ const undoneItems = undoneGrid.children;
+ expect(undoneItems.length).toBe(originalState.itemCount);
+ expect(undoneGrid.props.gap).toStrictEqual(originalState.gap);
+ expect(undoneGrid.props.item.size.width).toBe(originalState.itemSize.width);
+ expect(undoneItems[0].getChildByLabel('bg-component').tint).toBe(
+ originalState.tint,
+ );
+
+ // --- Redo ---
+ patchmap.undoRedoManager.redo();
+ const redoneGrid = getGrid(patchmap);
+ const redoneItems = redoneGrid.children;
+ expect(redoneItems.length).toBe(4);
+ expect(redoneGrid.props.gap).toStrictEqual({ x: 20, y: 20 });
+ expect(redoneGrid.props.item.size.width).toBe(40);
+ expect(redoneItems[0].getChildByLabel('bg-component').tint).toBe(0xef4444);
+ });
+});
diff --git a/src/tests/undo-redo/Group.test.js b/src/tests/undo-redo/Group.test.js
new file mode 100644
index 00000000..f1ec03c0
--- /dev/null
+++ b/src/tests/undo-redo/Group.test.js
@@ -0,0 +1,156 @@
+import { describe, expect, it } from 'vitest';
+import { setupPatchmapTests } from '../render/patchmap.setup';
+
+describe('Undo/Redo: Group Element', () => {
+ const { getPatchmap } = setupPatchmapTests();
+
+ // Base data structure with a Group containing other Elements.
+ const initialMapData = [
+ {
+ type: 'group',
+ id: 'group-1',
+ children: [
+ { type: 'item', id: 'item-1', size: 50, label: 'Initial Item 1' },
+ { type: 'item', id: 'item-2', size: 60, label: 'Initial Item 2' },
+ ],
+ attrs: { x: 100, y: 100 },
+ },
+ ];
+
+ const getGroup = (patchmap) => {
+ return patchmap.selector('$..[?(@.id=="group-1")]')[0];
+ };
+
+ const getItem = (patchmap, id) => {
+ return patchmap.selector(`$..[?(@.id=="${id}")]`)[0];
+ };
+
+ it('should undo/redo adding a new child Element to the group', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(initialMapData);
+
+ const group = getGroup(patchmap);
+ expect(group.children.length).toBe(2);
+
+ const newItem = { type: 'item', id: 'item-3', size: 70 };
+ patchmap.update({
+ path: '$..[?(@.id=="group-1")]',
+ changes: { children: [newItem] },
+ history: true,
+ });
+
+ const groupAfterUpdate = getGroup(patchmap);
+ expect(groupAfterUpdate.children.length).toBe(3);
+ expect(getItem(patchmap, 'item-3')).toBeDefined();
+
+ // --- Undo ---
+ patchmap.undoRedoManager.undo();
+ const groupAfterUndo = getGroup(patchmap);
+ expect(groupAfterUndo.children.length).toBe(2);
+ expect(getItem(patchmap, 'item-3')).toBeUndefined();
+
+ // --- Redo ---
+ patchmap.undoRedoManager.redo();
+ const groupAfterRedo = getGroup(patchmap);
+ expect(groupAfterRedo.children.length).toBe(3);
+ expect(getItem(patchmap, 'item-3')).toBeDefined();
+ });
+
+ it('should undo/redo removing a child Element from the group', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(initialMapData);
+
+ expect(getGroup(patchmap).children.length).toBe(2);
+
+ // Remove item-2
+ const updatedChildren = [
+ { type: 'item', id: 'item-1', size: 50, label: 'Initial Item 1' },
+ ];
+
+ patchmap.update({
+ path: '$..[?(@.id=="group-1")]',
+ changes: { children: updatedChildren },
+ history: true,
+ mergeStrategy: 'replace', // Use replace to correctly handle array removal
+ });
+
+ expect(getGroup(patchmap).children.length).toBe(1);
+ expect(getItem(patchmap, 'item-2')).toBeUndefined();
+
+ // --- Undo ---
+ patchmap.undoRedoManager.undo();
+ expect(getGroup(patchmap).children.length).toBe(2);
+ expect(getItem(patchmap, 'item-2')).toBeDefined();
+
+ // --- Redo ---
+ patchmap.undoRedoManager.redo();
+ expect(getGroup(patchmap).children.length).toBe(1);
+ expect(getItem(patchmap, 'item-2')).toBeUndefined();
+ });
+
+ it('should undo/redo a property change on a nested child Element', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(initialMapData);
+
+ const item1 = getItem(patchmap, 'item-1');
+ const originalLabel = item1.label;
+ expect(originalLabel).toBe('Initial Item 1');
+
+ patchmap.update({
+ path: '$..[?(@.id=="item-1")]',
+ changes: { label: 'Updated Label' },
+ history: true,
+ });
+
+ expect(getItem(patchmap, 'item-1').label).toBe('Updated Label');
+ // Ensure the group's internal props reflect this change for history
+ expect(getGroup(patchmap).props.children[0].label).toBe('Updated Label');
+
+ // --- Undo ---
+ patchmap.undoRedoManager.undo();
+ expect(getItem(patchmap, 'item-1').label).toBe(originalLabel);
+ expect(getGroup(patchmap).props.children[0].label).toBe(originalLabel);
+
+ // --- Redo ---
+ patchmap.undoRedoManager.redo();
+ expect(getItem(patchmap, 'item-1').label).toBe('Updated Label');
+ expect(getGroup(patchmap).props.children[0].label).toBe('Updated Label');
+ });
+
+ it('should correctly handle undo/redo in a deeply nested group structure', () => {
+ const patchmap = getPatchmap();
+ const deepData = [
+ {
+ type: 'group',
+ id: 'group-1',
+ children: [
+ {
+ type: 'group',
+ id: 'group-2',
+ children: [
+ { type: 'item', id: 'item-deep', size: 30, label: 'Deep Item' },
+ ],
+ },
+ ],
+ },
+ ];
+ patchmap.draw(deepData);
+
+ const deepItem = getItem(patchmap, 'item-deep');
+ expect(deepItem.label).toBe('Deep Item');
+
+ patchmap.update({
+ path: '$..[?(@.id=="item-deep")]',
+ changes: { label: 'Updated Deep' },
+ history: true,
+ });
+
+ expect(getItem(patchmap, 'item-deep').label).toBe('Updated Deep');
+
+ patchmap.undoRedoManager.undo();
+ expect(getItem(patchmap, 'item-deep').label).toBe('Deep Item');
+
+ patchmap.undoRedoManager.redo();
+ expect(getItem(patchmap, 'item-deep').label).toBe('Updated Deep');
+ });
+});
diff --git a/src/tests/undo-redo/Icon.test.js b/src/tests/undo-redo/Icon.test.js
new file mode 100644
index 00000000..9b20903d
--- /dev/null
+++ b/src/tests/undo-redo/Icon.test.js
@@ -0,0 +1,224 @@
+import { describe, expect, it } from 'vitest';
+import { setupPatchmapTests } from '../render/patchmap.setup';
+
+describe('Undo/Redo: Icon Component', () => {
+ const { getPatchmap } = setupPatchmapTests();
+
+ // A reusable factory for creating item data to reduce duplication
+ const createItemWithIcon = (iconProps) => ({
+ type: 'item',
+ id: 'item-with-icon',
+ size: { width: 200, height: 100 },
+ padding: 10, // Add padding for more complex layout tests
+ components: [
+ {
+ type: 'icon',
+ id: 'icon-1',
+ source: 'device',
+ size: 50,
+ placement: 'center',
+ ...iconProps,
+ },
+ ],
+ });
+
+ const getIcon = (patchmap) => {
+ return patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ };
+
+ it('should correctly undo/redo a simple property change (tint)', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([createItemWithIcon({ tint: 'primary.default' })]);
+
+ const icon = getIcon(patchmap);
+ const originalTint = icon.tint;
+ expect(originalTint).toBe(0x0c73bf); // 'primary.default'
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { tint: 'primary.accent' }, // #EF4444
+ history: true,
+ });
+ const updatedTint = getIcon(patchmap).tint;
+ expect(updatedTint).toBe(0xef4444);
+
+ patchmap.undoRedoManager.undo();
+ expect(getIcon(patchmap).tint).toBe(originalTint);
+
+ patchmap.undoRedoManager.redo();
+ expect(getIcon(patchmap).tint).toBe(updatedTint);
+ });
+
+ it('should correctly undo/redo a source change', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([createItemWithIcon({ source: 'device' })]);
+ const originalSource = getIcon(patchmap).props.source;
+ expect(originalSource).toBe('device');
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { source: 'wifi' },
+ history: true,
+ });
+ const updatedSource = getIcon(patchmap).props.source;
+ expect(updatedSource).toBe('wifi');
+
+ patchmap.undoRedoManager.undo();
+ expect(getIcon(patchmap).props.source).toBe(originalSource);
+
+ patchmap.undoRedoManager.redo();
+ expect(getIcon(patchmap).props.source).toBe(updatedSource);
+ });
+
+ describe('Edge cases for the "size" property', () => {
+ it('should handle percentage-based size correctly within a padded parent', () => {
+ // Parent content size: width=180 (200-2*10), height=80 (100-2*10)
+ const patchmap = getPatchmap();
+ patchmap.draw([createItemWithIcon({ size: '50%' })]);
+
+ const icon = getIcon(patchmap);
+ expect(icon.width).toBe(90); // 180 * 0.5
+ expect(icon.height).toBe(40); // 80 * 0.5
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { size: '100%' },
+ history: true,
+ });
+
+ expect(getIcon(patchmap).width).toBe(180);
+ expect(getIcon(patchmap).height).toBe(80);
+
+ patchmap.undoRedoManager.undo();
+ expect(getIcon(patchmap).width).toBe(90);
+ expect(getIcon(patchmap).height).toBe(40);
+ });
+
+ it('should handle mixed unit size object correctly', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([
+ createItemWithIcon({ size: { width: 80, height: '25%' } }),
+ ]);
+
+ const icon = getIcon(patchmap);
+ expect(icon.width).toBe(80);
+ expect(icon.height).toBe(20); // 80 * 0.25
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { size: { width: '20%', height: 30 } },
+ history: true,
+ });
+
+ expect(getIcon(patchmap).width).toBe(36); // 180 * 0.2
+ expect(getIcon(patchmap).height).toBe(30);
+
+ patchmap.undoRedoManager.undo();
+ expect(getIcon(patchmap).width).toBe(80);
+ expect(getIcon(patchmap).height).toBe(20);
+ });
+
+ it('should treat a partial size object as a merge, not a replacement', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([createItemWithIcon({ size: { width: 80, height: 40 } })]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { size: { width: '50%' } }, // Only width is provided
+ history: true,
+ });
+
+ const icon = getIcon(patchmap);
+ // Parent content width is 180, so 50% is 90
+ expect(icon.width).toBe(90);
+ // Height should remain unchanged from the initial state
+ expect(icon.height).toBe(40);
+
+ patchmap.undoRedoManager.undo();
+ const undoneIcon = getIcon(patchmap);
+ expect(undoneIcon.width).toBe(80);
+ expect(undoneIcon.height).toBe(40);
+ });
+ });
+
+ describe('Interaction between placement and margin', () => {
+ it('should correctly position the icon with right-bottom placement and margin inside a padded item', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([
+ createItemWithIcon({
+ size: 40,
+ placement: 'right-bottom',
+ margin: 5,
+ }),
+ ]);
+
+ const icon = getIcon(patchmap);
+ // Item inner size: 180x80.
+ // Right edge = padding.right(10) + contentWidth(180) = 190
+ // Bottom edge = padding.top(10) + contentHeight(80) = 90
+ // X = right_edge - margin.right(5) - icon.width(40) = 190 - 5 - 40 = 145
+ // Y = bottom_edge - margin.bottom(5) - icon.height(40) = 90 - 5 - 40 = 45
+ expect(icon.x).toBe(145);
+ expect(icon.y).toBe(45);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { margin: { x: 10, y: 15 } },
+ history: true,
+ });
+ // X = 190 - 10 - 40 = 140
+ // Y = 90 - 15 - 40 = 35
+ expect(getIcon(patchmap).x).toBe(140);
+ expect(getIcon(patchmap).y).toBe(35);
+
+ patchmap.undoRedoManager.undo();
+ expect(getIcon(patchmap).x).toBe(145);
+ expect(getIcon(patchmap).y).toBe(45);
+ });
+ });
+
+ it('should correctly add a new property (margin) from an undefined state and undo to default', () => {
+ const patchmap = getPatchmap();
+ // Create an icon without an explicit margin.
+ patchmap.draw([createItemWithIcon({ size: 50, placement: 'left-top' })]);
+
+ const icon = getIcon(patchmap);
+ const originalPosition = { x: icon.x, y: icon.y };
+ expect(icon.props.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
+ // Position with no margin inside a padded (10px) item.
+ expect(originalPosition).toEqual({ x: 10, y: 10 });
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { margin: { left: 20, top: 5 } },
+ history: true,
+ });
+
+ const updatedIcon = getIcon(patchmap);
+ expect(updatedIcon.props.margin).toEqual({
+ top: 5,
+ right: 0,
+ bottom: 0,
+ left: 20,
+ });
+ // Position is padding + margin
+ expect(updatedIcon.x).toBe(10 + 20); // 30
+ expect(updatedIcon.y).toBe(10 + 5); // 15
+
+ patchmap.undoRedoManager.undo();
+ const undoneIcon = getIcon(patchmap);
+ expect(undoneIcon.props.margin).toEqual({
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ });
+ expect(undoneIcon.x).toBe(originalPosition.x);
+ expect(undoneIcon.y).toBe(originalPosition.y);
+
+ patchmap.undoRedoManager.redo();
+ const redoneIcon = getIcon(patchmap);
+ expect(redoneIcon.x).toBe(30);
+ expect(redoneIcon.y).toBe(15);
+ });
+});
diff --git a/src/tests/undo-redo/Relations.test.js b/src/tests/undo-redo/Relations.test.js
new file mode 100644
index 00000000..b58d7052
--- /dev/null
+++ b/src/tests/undo-redo/Relations.test.js
@@ -0,0 +1,248 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { setupPatchmapTests } from '../render/patchmap.setup';
+
+describe('Undo/Redo: Relations Element', () => {
+ const { getPatchmap } = setupPatchmapTests();
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ const baseComponents = [
+ {
+ type: 'background',
+ source: { type: 'rect', fill: 'white' },
+ tint: 'gray.default',
+ },
+ ];
+
+ // Base data with items to be connected by the Relations element
+ const baseMapData = [
+ {
+ type: 'item',
+ id: 'item-A',
+ size: 50,
+ attrs: { x: 100, y: 100 },
+ components: baseComponents,
+ },
+ {
+ type: 'item',
+ id: 'item-B',
+ size: 50,
+ attrs: { x: 300, y: 100 },
+ components: baseComponents,
+ },
+ {
+ type: 'item',
+ id: 'item-C',
+ size: 50,
+ attrs: { x: 200, y: 300 },
+ components: baseComponents,
+ },
+ {
+ type: 'relations',
+ id: 'rel-1',
+ links: [{ source: 'item-A', target: 'item-B' }],
+ style: { width: 2, color: 'primary.default' },
+ },
+ ];
+
+ const getRelations = (patchmap) => {
+ return patchmap.selector('$..[?(@.id=="rel-1")]')[0];
+ };
+
+ const getPath = (patchmap) => {
+ // The actual line is a Graphics object child of the Relations element
+ return getRelations(patchmap).children[0];
+ };
+
+ it('should undo/redo changes to the "links" property', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(baseMapData);
+
+ const originalLinks = getRelations(patchmap).props.links;
+ expect(originalLinks.length).toBe(1);
+
+ const newLinks = [{ source: 'item-B', target: 'item-C' }];
+
+ patchmap.update({
+ path: '$..[?(@.id=="rel-1")]',
+ changes: { links: newLinks },
+ history: true,
+ });
+
+ const updatedLinks = getRelations(patchmap).props.links;
+ expect(updatedLinks.length).toBe(2);
+
+ patchmap.undoRedoManager.undo();
+ expect(getRelations(patchmap).props.links.length).toBe(1);
+ expect(getRelations(patchmap).props.links).toEqual(originalLinks);
+
+ patchmap.undoRedoManager.redo();
+ expect(getRelations(patchmap).props.links.length).toBe(2);
+ expect(getRelations(patchmap).props.links).toEqual([
+ ...originalLinks,
+ ...newLinks,
+ ]);
+ });
+
+ it('should undo/redo changes to the "style" property', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(baseMapData);
+
+ const path = getPath(patchmap);
+ const originalStyle = { ...path.strokeStyle };
+
+ expect(originalStyle.width).toBe(2);
+ // Note: The color is resolved from the theme
+ expect(originalStyle.color).toBe(0x0c73bf);
+
+ patchmap.update({
+ path: '$..[?(@.id=="rel-1")]',
+ changes: { style: { width: 5, color: 'primary.accent' } },
+ history: true,
+ });
+
+ const updatedStyle = getPath(patchmap).strokeStyle;
+ expect(updatedStyle.width).toBe(5);
+ expect(updatedStyle.color).toBe(0xef4444);
+
+ patchmap.undoRedoManager.undo();
+ const undoneStyle = getPath(patchmap).strokeStyle;
+ expect(undoneStyle.width).toBe(originalStyle.width);
+ expect(undoneStyle.color).toBe(originalStyle.color);
+
+ patchmap.undoRedoManager.redo();
+ const redoneStyle = getPath(patchmap).strokeStyle;
+ expect(redoneStyle.width).toBe(5);
+ expect(redoneStyle.color).toBe(0xef4444);
+ });
+
+ it('should correctly undo/redo adding a style property when it was initially undefined', async () => {
+ const patchmap = getPatchmap();
+ const dataWithoutStyle = JSON.parse(JSON.stringify(baseMapData));
+ dataWithoutStyle[3].style = undefined;
+
+ patchmap.draw(dataWithoutStyle);
+ await vi.advanceTimersByTimeAsync(100);
+
+ const relations = getRelations(patchmap);
+ const path = getPath(patchmap);
+
+ expect(relations.props.style).toStrictEqual({ color: '#1A1A1A' });
+ const initialStrokeStyle = { ...path.strokeStyle };
+ expect(initialStrokeStyle.width).toBe(1);
+
+ const newStyle = { width: 3, color: 'red', cap: 'round' };
+ patchmap.update({
+ path: '$..[?(@.id=="rel-1")]',
+ changes: { style: newStyle },
+ history: true,
+ });
+
+ const updatedStrokeStyle = getPath(patchmap).strokeStyle;
+ expect(getRelations(patchmap).props.style).toEqual(newStyle);
+ expect(updatedStrokeStyle.width).toBe(3);
+ expect(updatedStrokeStyle.color).toBe(0xff0000);
+ expect(updatedStrokeStyle.cap).toBe('round');
+
+ patchmap.undoRedoManager.undo();
+ const undoneRelations = getRelations(patchmap);
+ const undonePath = getPath(patchmap);
+ expect(undoneRelations.props.style).toStrictEqual({ color: '#1A1A1A' });
+ expect(undonePath.strokeStyle.width).toBe(initialStrokeStyle.width);
+ expect(undonePath.strokeStyle.color).toBe(initialStrokeStyle.color);
+ expect(undonePath.strokeStyle.cap).not.toBe('round');
+
+ patchmap.undoRedoManager.redo();
+ const redoneRelations = getRelations(patchmap);
+ const redonePath = getPath(patchmap);
+ expect(redoneRelations.props.style).toEqual(newStyle);
+ expect(redonePath.strokeStyle.width).toBe(3);
+ expect(redonePath.strokeStyle.color).toBe(0xff0000);
+ expect(redonePath.strokeStyle.cap).toBe('round');
+ });
+
+ it('should redraw correctly after a linked item is moved and then undone', async () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(baseMapData);
+ await vi.advanceTimersByTimeAsync(100);
+
+ const path = getPath(patchmap);
+ const originalPathSize = path.getSize();
+ expect(originalPathSize.width).toBeGreaterThan(0);
+ expect(originalPathSize.height).toBeGreaterThan(0);
+
+ patchmap.update({
+ path: '$..[?(@.id=="item-B")]',
+ changes: { attrs: { x: 400, y: 200 } },
+ history: true,
+ });
+
+ await vi.advanceTimersByTimeAsync(100);
+ const updatedPathSize = path.getSize();
+ expect(updatedPathSize.width).not.toBe(originalPathSize.width);
+ expect(updatedPathSize.height).not.toBe(originalPathSize.height);
+
+ // --- Undo the movement of item-B ---
+ patchmap.undoRedoManager.undo();
+ await vi.advanceTimersByTimeAsync(100);
+ const undonePathSize = path.getSize();
+
+ const itemB = patchmap.selector('$..[?(@.id=="item-B")]')[0];
+ expect(itemB.x).toBe(300); // Back to original position
+
+ // The path should be redrawn to its original state.
+ // Due to graphics rendering intricacies, we check for approximate equality.
+ expect(undonePathSize.width).toBeCloseTo(originalPathSize.width);
+ expect(undonePathSize.height).toBeCloseTo(originalPathSize.height);
+
+ // --- Redo the movement ---
+ patchmap.undoRedoManager.redo();
+ await vi.advanceTimersByTimeAsync(100);
+ const redonePathSize = path.getSize();
+ expect(redonePathSize.width).toBeCloseTo(updatedPathSize.width);
+ expect(redonePathSize.height).toBeCloseTo(updatedPathSize.height);
+ });
+
+ it('should handle additional stroke properties like "cap" and "join" in style', async () => {
+ const patchmap = getPatchmap();
+ patchmap.draw(baseMapData);
+ await vi.advanceTimersByTimeAsync(100);
+
+ const path = getPath(patchmap);
+ // PixiJS's default for cap is 'butt'
+ const originalCap = path.strokeStyle.cap;
+ const originalJoin = path.strokeStyle.join;
+ expect(originalCap).toBe('butt');
+ expect(originalJoin).toBe('miter');
+
+ patchmap.update({
+ path: '$..[?(@.id=="rel-1")]',
+ changes: {
+ style: {
+ cap: 'round',
+ join: 'round',
+ },
+ },
+ history: true,
+ });
+
+ const updatedStyle = getPath(patchmap).strokeStyle;
+ expect(updatedStyle.cap).toBe('round');
+ expect(updatedStyle.join).toBe('round');
+
+ patchmap.undoRedoManager.undo();
+ const undoneStyle = getPath(patchmap).strokeStyle;
+ expect(undoneStyle.cap).toBe(originalCap);
+ expect(undoneStyle.join).not.toBe('round'); // It should revert to its default
+
+ patchmap.undoRedoManager.redo();
+ const redoneStyle = getPath(patchmap).strokeStyle;
+ expect(redoneStyle.cap).toBe('round');
+ expect(redoneStyle.join).toBe('round');
+ });
+});
diff --git a/src/tests/undo-redo/Text.test.js b/src/tests/undo-redo/Text.test.js
new file mode 100644
index 00000000..0969a594
--- /dev/null
+++ b/src/tests/undo-redo/Text.test.js
@@ -0,0 +1,360 @@
+import { describe, expect, it } from 'vitest';
+import { setupPatchmapTests } from '../render/patchmap.setup';
+
+describe('Undo/Redo: Text Component', () => {
+ const { getPatchmap } = setupPatchmapTests();
+
+ const baseItemData = {
+ type: 'item',
+ id: 'item-with-text',
+ size: { width: 200, height: 100 },
+ components: [
+ {
+ type: 'text',
+ id: 'text-1',
+ text: 'Initial Text',
+ placement: 'center',
+ tint: 0xffffff, // white
+ style: {
+ fill: 'black',
+ fontSize: 24,
+ },
+ },
+ ],
+ };
+
+ const baseItemDataWithoutOptionals = {
+ type: 'item',
+ id: 'item-with-text-minimal',
+ size: { width: 200, height: 100 },
+ components: [
+ {
+ type: 'background',
+ source: { type: 'rect', borderWidth: 2, borderColor: 'black' },
+ },
+ {
+ type: 'text',
+ id: 'text-1',
+ text: 'Initial Text0',
+ },
+ ],
+ };
+
+ const getText = (patchmap) => {
+ return patchmap.selector('$..[?(@.id=="text-1")]')[0];
+ };
+
+ it('should correctly undo/redo a simple property change (text)', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const textComponent = getText(patchmap);
+ const originalText = textComponent.text;
+ expect(originalText).toBe('Initial Text');
+
+ patchmap.update({
+ path: '$..[?(@.id=="text-1")]',
+ changes: { text: 'Updated Text' },
+ history: true,
+ });
+
+ const updatedText = getText(patchmap).text;
+ expect(updatedText).toBe('Updated Text');
+
+ // Undo
+ patchmap.undoRedoManager.undo();
+ expect(getText(patchmap).text).toBe(originalText);
+
+ // Redo
+ patchmap.undoRedoManager.redo();
+ expect(getText(patchmap).text).toBe(updatedText);
+ });
+
+ it('should correctly undo/redo a placement change and verify rendered position', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const textComponent = getText(patchmap);
+ const originalPosition = { x: textComponent.x, y: textComponent.y };
+
+ patchmap.update({
+ path: '$..[?(@.id=="text-1")]',
+ changes: { placement: 'left-top' },
+ history: true,
+ });
+
+ const updatedPosition = { x: getText(patchmap).x, y: getText(patchmap).y };
+ expect(updatedPosition.x).not.toBe(originalPosition.x);
+ expect(updatedPosition.y).not.toBe(originalPosition.y);
+ expect(getText(patchmap).props.placement).toBe('left-top');
+
+ // Undo
+ patchmap.undoRedoManager.undo();
+ expect(getText(patchmap).props.placement).toBe('center');
+ expect(getText(patchmap).x).toBe(originalPosition.x);
+ expect(getText(patchmap).y).toBe(originalPosition.y);
+
+ // Redo
+ patchmap.undoRedoManager.redo();
+ expect(getText(patchmap).props.placement).toBe('left-top');
+ expect(getText(patchmap).x).toBe(updatedPosition.x);
+ expect(getText(patchmap).y).toBe(updatedPosition.y);
+ });
+
+ it('should correctly undo/redo a tint color change', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const textComponent = getText(patchmap);
+ const originalTint = textComponent.tint;
+ expect(originalTint).toBe(0xffffff);
+
+ patchmap.update({
+ path: '$..[?(@.id=="text-1")]',
+ changes: { tint: 0xff0000 }, // red
+ history: true,
+ });
+ expect(getText(patchmap).tint).toBe(0xff0000);
+
+ // Undo
+ patchmap.undoRedoManager.undo();
+ expect(getText(patchmap).tint).toBe(originalTint);
+
+ // Redo
+ patchmap.undoRedoManager.redo();
+ expect(getText(patchmap).tint).toBe(0xff0000);
+ });
+
+ it('should correctly undo/redo a split change', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const textComponent = getText(patchmap);
+ const originalText = textComponent.text;
+ expect(textComponent.props.split).toBe(0);
+
+ patchmap.update({
+ path: '$..[?(@.id=="text-1")]',
+ changes: { split: 4 },
+ history: true,
+ });
+ const updatedText = getText(patchmap).text;
+ expect(updatedText).toBe('Init\nial \nText');
+
+ // Undo
+ patchmap.undoRedoManager.undo();
+ expect(getText(patchmap).text).toBe(originalText);
+
+ // Redo
+ patchmap.undoRedoManager.redo();
+ expect(getText(patchmap).text).toBe(updatedText);
+ });
+
+ describe('style property undo/redo', () => {
+ it('should correctly undo/redo a nested style property change (fill)', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const originalFill = getText(patchmap).style.fill;
+ expect(originalFill).toBe('#1A1A1A');
+
+ patchmap.update({
+ path: '$..[?(@.id=="text-1")]',
+ changes: { style: { fill: 'red' } },
+ history: true,
+ });
+ expect(getText(patchmap).style.fill).toBe('red');
+
+ // Undo
+ patchmap.undoRedoManager.undo();
+ expect(getText(patchmap).style.fill).toBe(originalFill);
+
+ // Redo
+ patchmap.undoRedoManager.redo();
+ expect(getText(patchmap).style.fill).toBe('red');
+ });
+
+ it('should correctly undo/redo multiple style property changes', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const textComponent = getText(patchmap);
+ const originalStyle = {
+ fill: textComponent.style.fill,
+ fontSize: textComponent.style.fontSize,
+ fontWeight: textComponent.style.fontWeight,
+ };
+
+ expect(originalStyle.fill).toBe('#1A1A1A');
+ expect(originalStyle.fontSize).toBe(24);
+ expect(originalStyle.fontWeight).toBe('400');
+
+ const newStyle = { fill: 'blue', fontSize: 48, fontWeight: 'bold' };
+ patchmap.update({
+ path: '$..[?(@.id=="text-1")]',
+ changes: { style: newStyle },
+ history: true,
+ });
+
+ const updatedStyle = getText(patchmap).style;
+ expect(updatedStyle.fill).toBe('blue');
+ expect(updatedStyle.fontSize).toBe(48);
+ expect(updatedStyle.fontWeight).toBe('700');
+
+ // Undo
+ patchmap.undoRedoManager.undo();
+ const undoneStyle = getText(patchmap).style;
+ expect(undoneStyle.fill).toBe(originalStyle.fill);
+ expect(undoneStyle.fontSize).toBe(originalStyle.fontSize);
+ expect(undoneStyle.fontWeight).toBe('400');
+
+ // Redo
+ patchmap.undoRedoManager.redo();
+ const redoneStyle = getText(patchmap).style;
+ expect(redoneStyle.fill).toBe('blue');
+ expect(redoneStyle.fontSize).toBe(48);
+ expect(redoneStyle.fontWeight).toBe('700');
+ });
+ });
+
+ describe('when optional properties are initially undefined', async () => {
+ it('should correctly add a new property (margin) and undo to its default state', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemDataWithoutOptionals]);
+
+ const textComponent = getText(patchmap);
+ const originalMargin = textComponent.props.margin;
+ const originalPosition = { x: textComponent.x, y: textComponent.y };
+ expect(originalMargin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
+
+ patchmap.update({
+ path: '$..[?(@.id=="text-1")]',
+ changes: {
+ margin: { left: 20, top: 10 },
+ style: { fontWeight: 'bold' },
+ },
+ history: true,
+ });
+
+ const updatedBar = getText(patchmap);
+ expect(updatedBar.props.margin).toEqual({
+ top: 10,
+ right: 0,
+ bottom: 0,
+ left: 20,
+ });
+ expect(updatedBar.x).not.toBe(originalPosition.x);
+ expect(updatedBar.y).not.toBe(originalPosition.y);
+
+ patchmap.undoRedoManager.undo();
+ const undoneBar = getText(patchmap);
+ expect(undoneBar.props.margin).toEqual(originalMargin);
+ expect(undoneBar.x).toBe(originalPosition.x);
+ expect(undoneBar.y).toBe(originalPosition.y);
+
+ patchmap.undoRedoManager.redo();
+ const redoneBar = getText(patchmap);
+ expect(redoneBar.props.margin).toEqual({
+ top: 10,
+ right: 0,
+ bottom: 0,
+ left: 20,
+ });
+ expect(redoneBar.x).not.toBe(originalPosition.x);
+ expect(redoneBar.y).not.toBe(originalPosition.y);
+ });
+
+ it('should correctly add a new property (style) and undo to its initial state', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemDataWithoutOptionals]);
+
+ const textComponent = getText(patchmap);
+ const originalFill = textComponent.style.fill;
+ const originalFontSize = textComponent.style.fontSize;
+
+ const newStyle = { fill: 'green', fontSize: 16 };
+
+ patchmap.update({
+ path: '$..[?(@.id=="text-1")]',
+ changes: { style: newStyle },
+ history: true,
+ });
+
+ const updatedStyle = getText(patchmap).style;
+ expect(updatedStyle.fill).toBe('green');
+ expect(updatedStyle.fontSize).toBe(16);
+ expect(getText(patchmap).props.style).toBeDefined();
+
+ // Undo
+ patchmap.undoRedoManager.undo();
+ const undoneStyle = getText(patchmap).style;
+ expect(undoneStyle.fill).toBe(originalFill);
+ expect(undoneStyle.fontSize).toBe(originalFontSize);
+
+ // Redo
+ patchmap.undoRedoManager.redo();
+ const redoneStyle = getText(patchmap).style;
+ expect(redoneStyle.fill).toBe('green');
+ expect(redoneStyle.fontSize).toBe(16);
+ expect(getText(patchmap).props.style).toBeDefined();
+ });
+ });
+
+ it('should correctly undo/redo multiple different properties simultaneously', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const textComponent = getText(patchmap);
+ const originalState = {
+ text: textComponent.text,
+ tint: textComponent.tint,
+ fill: textComponent.style.fill,
+ fontSize: textComponent.style.fontSize,
+ };
+
+ const changes = {
+ text: 'Multi-update',
+ tint: 0x0000ff, // blue
+ style: { fill: 'yellow', fontSize: 12 },
+ };
+ patchmap.update({
+ path: '$..[?(@.id=="text-1")]',
+ changes,
+ history: true,
+ });
+
+ const updatedComponent = getText(patchmap);
+ const updatedState = {
+ text: updatedComponent.text,
+ tint: updatedComponent.tint,
+ fill: updatedComponent.style.fill,
+ fontSize: updatedComponent.style.fontSize,
+ };
+ expect(updatedState.text).toBe('Multi-update');
+ expect(updatedState.tint).toBe(255);
+ expect(updatedState.fill).toBe('yellow');
+ expect(updatedState.fontSize).toBe(12);
+
+ // Undo
+ patchmap.undoRedoManager.undo();
+ const undoneComponent = getText(patchmap);
+ const undoneState = {
+ text: undoneComponent.text,
+ tint: undoneComponent.tint,
+ fill: undoneComponent.style.fill,
+ fontSize: undoneComponent.style.fontSize,
+ };
+ expect(undoneState).toEqual(originalState);
+
+ // Redo
+ patchmap.undoRedoManager.redo();
+ const redoneComponent = getText(patchmap);
+ const redoneState = {
+ text: redoneComponent.text,
+ tint: redoneComponent.tint,
+ fill: redoneComponent.style.fill,
+ fontSize: redoneComponent.style.fontSize,
+ };
+ expect(redoneState).toEqual(updatedState);
+ });
+});
diff --git a/src/utils/deepmerge/deepmerge.js b/src/utils/deepmerge/deepmerge.js
index ee3835a4..a250a00d 100644
--- a/src/utils/deepmerge/deepmerge.js
+++ b/src/utils/deepmerge/deepmerge.js
@@ -56,9 +56,9 @@ const _deepMerge = (target, source, options, visited) => {
};
const mergeArray = (target, source, options, visited) => {
- const { mergeBy, arrayMerge = null } = options;
+ const { mergeBy, mergeStrategy = null } = options;
- if (arrayMerge === 'replace') {
+ if (mergeStrategy === 'replace') {
return source;
}
diff --git a/src/utils/deepmerge/deepmerge.test.js b/src/utils/deepmerge/deepmerge.test.js
index 88062216..db0f772f 100644
--- a/src/utils/deepmerge/deepmerge.test.js
+++ b/src/utils/deepmerge/deepmerge.test.js
@@ -80,7 +80,7 @@ describe('deepMerge – edge-case behavior', () => {
/* -------------------------------------------------------------------------- */
/* 3. Array/Object merge priority (id → label → type) */
/* -------------------------------------------------------------------------- */
-describe('deepMerge – arrayMerge by id → label → type', () => {
+describe('deepMerge – mergeStrategy by id → label → type', () => {
test.each([
[
{
@@ -366,13 +366,13 @@ describe('deepMerge – additional edge‑case coverage', () => {
});
});
-describe('deepMerge – arrayMerge option', () => {
+describe('deepMerge – mergeStrategy option', () => {
test.each([
{
- name: 'should replace array when arrayMerge is "replace"',
+ name: 'should replace array when mergeStrategy is "replace"',
left: { arr: [1, 2, 3] },
right: { arr: [4, 5] },
- options: { arrayMerge: 'replace' },
+ options: { mergeStrategy: 'replace' },
expected: { arr: [4, 5] },
},
{
@@ -383,12 +383,21 @@ describe('deepMerge – arrayMerge option', () => {
expected: { arr: [4, 5, 3] },
},
{
- name: 'should merge nested arrays when arrayMerge is "replace" at top level',
+ name: 'should merge nested arrays when mergeStrategy is "replace" at top level',
left: { nested: { arr: ['a', 'b'] } },
right: { nested: { arr: ['c'] } },
- options: { arrayMerge: 'replace' },
+ options: { mergeStrategy: 'replace' },
expected: { nested: { arr: ['c'] } },
},
+ {
+ name: 'should merge nested arrays when mergeStrategy is "replace" at second level',
+ left: [{ source: '1', target: '2' }],
+ right: [{ source: '2', target: '3' }],
+ expected: [
+ { source: '1', target: '2' },
+ { source: '2', target: '3' },
+ ],
+ },
])('$name', ({ left, right, options, expected }) => {
expect(deepMerge(left, right, options)).toEqual(expected);
});
diff --git a/src/utils/diff/create-patch.js b/src/utils/diff/create-patch.js
new file mode 100644
index 00000000..7d12dae2
--- /dev/null
+++ b/src/utils/diff/create-patch.js
@@ -0,0 +1,28 @@
+import { isPlainObject } from 'is-plain-object';
+import { isSame } from './is-same';
+
+export const createPatch = (obj1, obj2) => {
+ if (isSame(obj1, obj2)) return {};
+
+ if (
+ obj1 === null ||
+ obj2 === null ||
+ !isPlainObject(obj1) ||
+ !isPlainObject(obj2)
+ ) {
+ return obj2;
+ }
+
+ const result = {};
+ for (const key of Object.keys(obj2)) {
+ if (
+ !Object.prototype.hasOwnProperty.call(obj1, key) ||
+ !isSame(obj1[key], obj2[key])
+ ) {
+ const patchValue = createPatch(obj1[key], obj2[key]);
+ result[key] = patchValue;
+ }
+ }
+
+ return result;
+};
diff --git a/src/utils/diff/create-patch.test.js b/src/utils/diff/create-patch.test.js
new file mode 100644
index 00000000..efc11312
--- /dev/null
+++ b/src/utils/diff/create-patch.test.js
@@ -0,0 +1,167 @@
+import { describe, expect, test } from 'vitest';
+import { createPatch } from './create-patch';
+
+// --- 테스트용 Mock 클래스 ---
+class MockClass {
+ constructor(value) {
+ this.value = value;
+ }
+}
+
+describe('createPatch function tests', () => {
+ test.each([
+ {
+ name: 'should return an empty object for identical objects',
+ obj1: { a: 1, b: 2 },
+ obj2: { a: 1, b: 2 },
+ expected: {},
+ },
+ {
+ name: 'should return the new value for a changed primitive',
+ obj1: { a: 1, b: 'hello' },
+ obj2: { a: 1, b: 'world' },
+ expected: { b: 'world' },
+ },
+ {
+ name: 'should return an object with the new key',
+ obj1: { a: 1 },
+ obj2: { a: 1, b: 2 },
+ expected: { b: 2 },
+ },
+ {
+ name: 'should not return keys that were removed in obj2',
+ obj1: { a: 1, b: 2 },
+ obj2: { a: 1 },
+ expected: {},
+ },
+ ])('Basic Changes: $name', ({ obj1, obj2, expected }) => {
+ expect(createPatch(obj1, obj2)).toEqual(expected);
+ });
+
+ test.each([
+ {
+ name: 'should return a patch for a changed nested property',
+ obj1: { nested: { one: 1, two: 2 } },
+ obj2: { nested: { one: 1, two: 3 } },
+ expected: { nested: { two: 3 } },
+ },
+ {
+ name: 'should return a patch for a new key in a nested object',
+ obj1: { nested: { x: 10 } },
+ obj2: { nested: { x: 10, y: 20 } },
+ expected: { nested: { y: 20 } },
+ },
+ {
+ name: 'should return a deeply nested patch',
+ obj1: { level1: { level2: { value: 10 } } },
+ obj2: { level1: { level2: { value: 99 } } },
+ expected: { level1: { level2: { value: 99 } } },
+ },
+ ])('Nested Objects: $name', ({ obj1, obj2, expected }) => {
+ expect(createPatch(obj1, obj2)).toEqual(expected);
+ });
+
+ test.each([
+ {
+ name: 'should return an empty object for identical arrays',
+ obj1: { data: [1, { a: 2 }] },
+ obj2: { data: [1, { a: 2 }] },
+ expected: {},
+ },
+ {
+ name: 'should return the entire new array if length changes',
+ obj1: { data: ['Alice', 'Bob'] },
+ obj2: { data: ['Alice', 'Bob', 'Charlie'] },
+ expected: { data: ['Alice', 'Bob', 'Charlie'] },
+ },
+ {
+ name: 'should return the entire new array if an element changes',
+ obj1: { data: [{ id: 1 }, { id: 2 }] },
+ obj2: { data: [{ id: 1 }, { id: 3 }] },
+ expected: { data: [{ id: 1 }, { id: 3 }] },
+ },
+ ])('Arrays: $name', ({ obj1, obj2, expected }) => {
+ expect(createPatch(obj1, obj2)).toEqual(expected);
+ });
+
+ test.each([
+ {
+ name: 'should return obj2 when obj1 is null',
+ obj1: null,
+ obj2: { a: 1 },
+ expected: { a: 1 },
+ },
+ {
+ name: 'should return null when obj2 is null',
+ obj1: { a: 1 },
+ obj2: null,
+ expected: null,
+ },
+ {
+ name: 'should return undefined when obj2 is undefined',
+ obj1: { a: 1 },
+ obj2: undefined,
+ expected: undefined,
+ },
+ {
+ name: 'should return empty object for two null values',
+ obj1: null,
+ obj2: null,
+ expected: {},
+ },
+ {
+ name: 'should return empty object for two undefined values',
+ obj1: undefined,
+ obj2: undefined,
+ expected: {},
+ },
+ {
+ name: 'should return the new value when changing from null to undefined',
+ obj1: { value: null },
+ obj2: { value: undefined },
+ expected: { value: undefined },
+ },
+ {
+ name: 'should return the new value when changing from function to undefined',
+ obj1: { value: () => 1 },
+ obj2: { value: undefined },
+ expected: { value: undefined },
+ },
+ {
+ name: 'should return the new Date object if dates differ',
+ obj1: { date: new Date('2024-01-01') },
+ obj2: { date: new Date('2025-01-01') },
+ expected: { date: new Date('2025-01-01') },
+ },
+ {
+ name: 'should return empty object for identical Date objects',
+ obj1: { date: new Date('2024-01-01') },
+ obj2: { date: new Date('2024-01-01') },
+ expected: {},
+ },
+ {
+ name: 'should return the new class instance if values differ',
+ obj1: { instance: new MockClass(10) },
+ obj2: { instance: new MockClass(20) },
+ expected: { instance: new MockClass(20) },
+ },
+ {
+ name: 'should return empty object for identical class instances',
+ obj1: { instance: new MockClass(10) },
+ obj2: { instance: new MockClass(10) },
+ expected: {},
+ },
+ ])('Edge Cases: $name', ({ obj1, obj2, expected }) => {
+ expect(createPatch(obj1, obj2)).toEqual(expected);
+ });
+
+ test('[Function] should return the new function if functions differ', () => {
+ const func1 = () => 1;
+ const func2 = () => 2;
+ const obj1 = { action: func1 };
+ const obj2 = { action: func2 };
+
+ const result = createPatch(obj1, obj2);
+ expect(result.action).toBe(func2);
+ });
+});
diff --git a/src/utils/diff/diff-json.js b/src/utils/diff/diff-json.js
deleted file mode 100644
index ccef6e9a..00000000
--- a/src/utils/diff/diff-json.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { isPlainObject } from 'is-plain-object';
-
-export const diffJson = (obj1, obj2) => {
- if (obj1 === obj2) return {};
- if (obj1 != null && obj2 == null) return obj1;
- if (obj1 == null && obj2 != null) return obj2;
-
- if (Array.isArray(obj1) && Array.isArray(obj2)) {
- if (obj1.length !== obj2.length) {
- return obj2;
- }
-
- for (let i = 0; i < obj1.length; i++) {
- const itemDiff = diffJson(obj1[i], obj2[i]);
- if (!isPlainObject(itemDiff) || Object.keys(itemDiff).length > 0) {
- return obj2;
- }
- }
- return {};
- }
-
- if (!isPlainObject(obj1) || !isPlainObject(obj2)) {
- return obj1 === obj2 ? {} : obj2;
- }
-
- const result = {};
- for (const key of Object.keys(obj2)) {
- if (!(key in obj1)) {
- result[key] = obj2[key];
- } else {
- const diffValue = diffJson(obj1[key], obj2[key]);
- if (isPlainObject(diffValue)) {
- if (Object.keys(diffValue).length > 0) {
- result[key] = diffValue;
- }
- } else {
- if (diffValue != null) {
- result[key] = diffValue;
- }
- }
- }
- }
-
- return result;
-};
diff --git a/src/utils/diff/diff-json.test.js b/src/utils/diff/diff-json.test.js
deleted file mode 100644
index 57d9d48a..00000000
--- a/src/utils/diff/diff-json.test.js
+++ /dev/null
@@ -1,171 +0,0 @@
-import { describe, expect, test } from 'vitest';
-import { diffJson } from './diff-json';
-
-describe('diffJson function tests', () => {
- test.each([
- {
- name: 'Identical objects',
- obj1: { a: 1, b: 2 },
- obj2: { a: 1, b: 2 },
- expected: {},
- },
- {
- name: 'obj2 is null',
- obj1: { a: 1, b: 2 },
- obj2: null,
- expected: { a: 1, b: 2 },
- },
- {
- name: 'Key only in obj2',
- obj1: { a: 1 },
- obj2: { a: 1, b: 2 },
- expected: { b: 2 },
- },
- {
- name: 'Different value for the same key',
- obj1: { a: 1, b: 'hello' },
- obj2: { a: 1, b: 'world' },
- expected: { b: 'world' },
- },
- {
- name: 'Nested object - some property differs',
- obj1: {
- nested: { one: 1, two: 2 },
- },
- obj2: {
- nested: { one: 1, two: 3 },
- },
- expected: {
- nested: { two: 3 },
- },
- },
- {
- name: 'Nested object - obj2 has a new key',
- obj1: {
- nested: { x: 10 },
- },
- obj2: {
- nested: { x: 10, y: 20 },
- },
- expected: {
- nested: { y: 20 },
- },
- },
- {
- name: 'Deeply nested - some property differs',
- obj1: {
- level1: {
- level2: { value: 10, unchanged: 'same' },
- },
- },
- obj2: {
- level1: {
- level2: { value: 99, unchanged: 'same' },
- },
- },
- expected: {
- level1: {
- level2: { value: 99 },
- },
- },
- },
- {
- name: 'Deeply nested - key only in obj2',
- obj1: {
- level1: {
- level2: { name: 'Alice' },
- },
- },
- obj2: {
- level1: {
- level2: { name: 'Alice', age: 30 },
- },
- },
- expected: {
- level1: {
- level2: { age: 30 },
- },
- },
- },
- {
- name: 'Array - new item in obj2',
- obj1: {
- level1: {
- level2: { names: ['Alice', 'Bob'] },
- },
- },
- obj2: {
- level1: {
- level2: { names: ['Alice', 'Bob', 'Charlie'] },
- },
- },
- expected: {
- level1: {
- level2: { names: ['Alice', 'Bob', 'Charlie'] },
- },
- },
- },
- {
- name: 'Array of objects - new item in obj2',
- obj1: {
- level1: {
- level2: { people: [{ name: 'Alice' }, { name: 'Bob' }] },
- },
- },
- obj2: {
- level1: {
- level2: {
- people: [{ name: '1234' }, { name: 'Bob' }, { name: 'Charlie' }],
- },
- },
- },
- expected: {
- level1: {
- level2: {
- people: [{ name: '1234' }, { name: 'Bob' }, { name: 'Charlie' }],
- },
- },
- },
- },
- {
- name: 'Identical arrays of objects should return an empty object',
- obj1: {
- data: [
- { id: 1, value: 'a' },
- { id: 2, value: 'b' },
- ],
- },
- obj2: {
- data: [
- { id: 1, value: 'a' },
- { id: 2, value: 'b' },
- ],
- },
- expected: {},
- },
- {
- name: 'Different arrays of objects should return the new array',
- obj1: {
- data: [
- { id: 1, value: 'a' },
- { id: 2, value: 'b' },
- ],
- },
- obj2: {
- data: [
- { id: 1, value: 'a' },
- { id: 2, value: 'c' },
- ],
- },
- expected: {
- data: [
- { id: 1, value: 'a' },
- { id: 2, value: 'c' },
- ],
- },
- },
- ])('$name', ({ obj1, obj2, expected }) => {
- const result = diffJson(obj1, obj2);
- expect(result).toEqual(expected);
- });
-});
diff --git a/src/utils/diff/diff-replace.js b/src/utils/diff/diff-replace.js
new file mode 100644
index 00000000..a38b5b1b
--- /dev/null
+++ b/src/utils/diff/diff-replace.js
@@ -0,0 +1,30 @@
+import { isPlainObject } from 'is-plain-object';
+import { isSame } from './is-same';
+
+export const diffReplace = (obj1, obj2) => {
+ if (isSame(obj1, obj2)) {
+ return {};
+ }
+
+ if (
+ !isPlainObject(obj1) ||
+ !isPlainObject(obj2) ||
+ Object.getPrototypeOf(obj1) !== Object.getPrototypeOf(obj2)
+ ) {
+ return obj2;
+ }
+
+ const result = {};
+ const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
+
+ for (const key of allKeys) {
+ const val1 = obj1[key];
+ const val2 = obj2[key];
+
+ if (!isSame(val1, val2)) {
+ result[key] = val2;
+ }
+ }
+
+ return result;
+};
diff --git a/src/utils/diff/diff-replace.test.js b/src/utils/diff/diff-replace.test.js
new file mode 100644
index 00000000..6c4754a4
--- /dev/null
+++ b/src/utils/diff/diff-replace.test.js
@@ -0,0 +1,270 @@
+import { describe, expect, test } from 'vitest';
+import { diffReplace } from './diff-replace';
+
+class MockClass {
+ constructor(value) {
+ this.value = value;
+ }
+ getValue() {
+ return this.value;
+ }
+}
+
+describe('diffReplace function tests', () => {
+ test.each([
+ {
+ name: 'should replace the entire object if a property is removed',
+ obj1: {
+ style: { width: 2, color: '#0C73BF', cap: 'round' },
+ meta: { unchanged: true },
+ },
+ obj2: {
+ style: { width: 2, color: '#0C73BF' },
+ meta: { unchanged: true },
+ },
+ expected: { style: { width: 2, color: '#0C73BF' } },
+ },
+ {
+ name: 'should replace the entire object if a property is added',
+ obj1: { style: { width: 2, color: '#0C73BF' } },
+ obj2: { style: { width: 2, color: '#0C73BF', cap: 'round' } },
+ expected: { style: { width: 2, color: '#0C73BF', cap: 'round' } },
+ },
+ {
+ name: 'should replace a deeply nested object when its property changes',
+ obj1: {
+ level1: {
+ level2: { value: 10, config: { enabled: true } },
+ static: 'A',
+ },
+ },
+ obj2: {
+ level1: {
+ level2: { value: 10, config: { enabled: false } },
+ static: 'A',
+ },
+ },
+ expected: {
+ level1: {
+ level2: { value: 10, config: { enabled: false } },
+ static: 'A',
+ },
+ },
+ },
+ ])('$name', ({ obj1, obj2, expected }) => {
+ expect(diffReplace(obj1, obj2)).toEqual(expected);
+ });
+
+ test.each([
+ {
+ name: 'Identical objects and primitives should return an empty object',
+ obj1: { a: 1, b: { c: 3 } },
+ obj2: { a: 1, b: { c: 3 } },
+ expected: {},
+ },
+ {
+ name: 'A new key at the root level should be added',
+ obj1: { a: 1 },
+ obj2: { a: 1, b: 2 },
+ expected: { b: 2 },
+ },
+ {
+ name: 'A changed primitive value should be updated',
+ obj1: { a: 1, b: 'hello' },
+ obj2: { a: 1, b: 'world' },
+ expected: { b: 'world' },
+ },
+ ])('$name', ({ obj1, obj2, expected }) => {
+ expect(diffReplace(obj1, obj2)).toEqual(expected);
+ });
+
+ test.each([
+ {
+ name: 'should return an empty object for identical arrays',
+ obj1: { data: [{ id: 1, value: 'a' }] },
+ obj2: { data: [{ id: 1, value: 'a' }] },
+ expected: {},
+ },
+ {
+ name: 'should replace the entire array if an element inside it changes',
+ obj1: {
+ data: [
+ { id: 1, value: 'a' },
+ { id: 2, value: 'b' },
+ ],
+ },
+ obj2: {
+ data: [
+ { id: 1, value: 'a' },
+ { id: 2, value: 'c' },
+ ],
+ },
+ expected: {
+ data: [
+ { id: 1, value: 'a' },
+ { id: 2, value: 'c' },
+ ],
+ },
+ },
+ {
+ name: 'should replace the entire array if its length changes',
+ obj1: { data: [{ id: 1, value: 'a' }] },
+ obj2: {
+ data: [
+ { id: 1, value: 'a' },
+ { id: 2, value: 'b' },
+ ],
+ },
+ expected: {
+ data: [
+ { id: 1, value: 'a' },
+ { id: 2, value: 'b' },
+ ],
+ },
+ },
+ ])('$name', ({ obj1, obj2, expected }) => {
+ expect(diffReplace(obj1, obj2)).toEqual(expected);
+ });
+
+ test.each([
+ {
+ name: 'should return obj2 when obj1 is null',
+ obj1: null,
+ obj2: { a: 1 },
+ expected: { a: 1 },
+ },
+ {
+ name: 'should return obj1 when obj2 is null',
+ obj1: { a: 1 },
+ obj2: null,
+ expected: null,
+ },
+ {
+ name: 'should handle replacement of an object with a primitive',
+ obj1: { data: { isObject: true } },
+ obj2: { data: 'isPrimitive' },
+ expected: { data: 'isPrimitive' },
+ },
+ {
+ name: 'should handle replacement of a primitive with an object',
+ obj1: { data: 'isPrimitive' },
+ obj2: { data: { isObject: true } },
+ expected: { data: { isObject: true } },
+ },
+ {
+ name: 'should handle null vs undefined in nested objects',
+ obj1: { config: { setting: 'on', value: null } },
+ obj2: { config: { setting: 'on', value: undefined } },
+ expected: { config: { setting: 'on', value: undefined } },
+ },
+ ])('$name', ({ obj1, obj2, expected }) => {
+ expect(diffReplace(obj1, obj2)).toEqual(expected);
+ });
+
+ test.each([
+ {
+ name: '[Class Instance] should return empty object for identical class instances',
+ obj1: { data: new MockClass(10) },
+ obj2: { data: new MockClass(10) },
+ expected: {},
+ },
+ {
+ name: '[Class Instance] should return the new instance if properties differ',
+ obj1: { data: new MockClass(10) },
+ obj2: { data: new MockClass(20) },
+ expected: { data: new MockClass(20) },
+ },
+ {
+ name: '[Date Object] should return empty object for identical dates',
+ obj1: { timestamp: new Date('2024-01-01') },
+ obj2: { timestamp: new Date('2024-01-01') },
+ expected: {},
+ },
+ {
+ name: '[Date Object] should return the new date if dates differ',
+ obj1: { timestamp: new Date('2024-01-01') },
+ obj2: { timestamp: new Date('2025-01-01') },
+ expected: { timestamp: new Date('2025-01-01') },
+ },
+ {
+ name: '[Mixed Types] should handle mix of plain objects, instances, and primitives',
+ obj1: { id: 1, config: { a: 1 }, instance: new MockClass(1) },
+ obj2: { id: 1, config: { a: 2 }, instance: new MockClass(2) },
+ expected: {
+ config: { a: 2 },
+ instance: new MockClass(2),
+ },
+ },
+ ])('$name', ({ obj1, obj2, expected }) => {
+ const result = diffReplace(obj1, obj2);
+ if (typeof expected.action === 'function') {
+ expect(result.action).toBe(expected.action);
+ } else {
+ expect(result).toEqual(expected);
+ }
+ });
+
+ test('[Function] should return the new function if functions differ', () => {
+ const func1 = () => 1;
+ const func2 = () => 2;
+ const obj1 = { action: func1 };
+ const obj2 = { action: func2 };
+
+ const result = diffReplace(obj1, obj2);
+ expect(result.action).toBe(func2);
+ });
+
+ test('[Function] should return an empty object for identical function references', () => {
+ const func1 = () => 1;
+ const obj1 = { action: func1 };
+ const obj2 = { action: func1 };
+ expect(diffReplace(obj1, obj2)).toEqual({});
+ });
+
+ describe('Critical Edge Cases for diffReplace', () => {
+ test('should NOT throw error on circular references and return correct diff', () => {
+ const obj1 = { name: 'obj1' };
+ obj1.self = obj1;
+
+ const obj2 = { name: 'obj2' };
+ obj2.self = obj2;
+
+ const obj3 = { name: 'obj1' };
+ obj3.self = obj3;
+
+ expect(() => diffReplace(obj1, obj2)).not.toThrow();
+ expect(diffReplace(obj1, obj2)).toEqual({ name: 'obj2', self: obj2 });
+ expect(diffReplace(obj1, obj3)).toEqual({});
+ });
+
+ test('should replace object when a property is changed from a value to undefined', () => {
+ const obj1 = { a: 1, b: 2, c: 3 };
+ const obj2 = { a: 1, b: undefined, c: 3 };
+
+ expect(diffReplace(obj1, obj2)).toEqual({ b: undefined });
+ });
+
+ test('should replace object when a property is removed', () => {
+ const obj1 = { a: 1, b: 2 };
+ const obj2 = { a: 1 };
+
+ expect(diffReplace(obj1, obj2)).toEqual({});
+ expect(diffReplace(obj2, obj1)).toEqual({ b: 2 });
+ });
+
+ test('should replace array when sparse vs undefined elements are present', () => {
+ const obj1 = { data: [1, null, 3] };
+ const obj2 = { data: [1, undefined, 3] };
+
+ expect(diffReplace(obj1, obj2)).toEqual({ data: [1, undefined, 3] });
+ });
+
+ test('should correctly diff objects with different prototypes', () => {
+ const obj1 = { a: 1 };
+ const obj2 = Object.create(null);
+ obj2.a = 1;
+
+ expect(diffReplace(obj1, obj2)).toEqual(obj2);
+ });
+ });
+});
diff --git a/src/utils/diff/is-same.js b/src/utils/diff/is-same.js
new file mode 100644
index 00000000..d324a312
--- /dev/null
+++ b/src/utils/diff/is-same.js
@@ -0,0 +1,74 @@
+function _isSame(a, b, visited) {
+ if (a === b) return true;
+ if (Number.isNaN(a) && Number.isNaN(b)) return true;
+ if (
+ typeof a !== 'object' ||
+ typeof b !== 'object' ||
+ a === null ||
+ b === null
+ ) {
+ return false;
+ }
+
+ if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
+
+ if (visited.has(a)) return visited.get(a) === b;
+ visited.set(a, b);
+
+ if (a instanceof Date) {
+ return a.getTime() === b.getTime();
+ }
+
+ if (a instanceof RegExp) {
+ return a.toString() === b.toString();
+ }
+
+ if (a instanceof Map) {
+ if (a.size !== b.size) return false;
+ const entriesA = Array.from(a.entries());
+ const entriesB = Array.from(b.entries());
+ return _isSame(entriesA, entriesB, visited);
+ }
+
+ if (a instanceof Set) {
+ if (a.size !== b.size) return false;
+ const valuesA = Array.from(a.values());
+ const valuesB = Array.from(b.values());
+ return _isSame(valuesA, valuesB, visited);
+ }
+
+ if (ArrayBuffer.isView(a) && !(a instanceof DataView)) {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+ }
+
+ if (Array.isArray(a)) {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (!_isSame(a[i], b[i], visited)) return false;
+ }
+ return true;
+ }
+
+ const keysA = Reflect.ownKeys(a);
+ const keysB = Reflect.ownKeys(b);
+ if (keysA.length !== keysB.length) return false;
+
+ for (const key of keysA) {
+ if (
+ !Object.prototype.hasOwnProperty.call(b, key) ||
+ !_isSame(a[key], b[key], visited)
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+export const isSame = (value1, value2) => {
+ return _isSame(value1, value2, new WeakMap());
+};
diff --git a/src/utils/diff/is-same.test.js b/src/utils/diff/is-same.test.js
new file mode 100644
index 00000000..0a3d844d
--- /dev/null
+++ b/src/utils/diff/is-same.test.js
@@ -0,0 +1,250 @@
+import { describe, expect, test } from 'vitest';
+import { isSame } from './is-same';
+
+class MockClass {
+ constructor(value) {
+ this.value = value;
+ }
+}
+
+describe('isSame function', () => {
+ // ... 기존 테스트 스위트는 그대로 유지 ...
+ describe('Plain Objects and Primitives', () => {
+ test.each([
+ { name: 'identical numbers', v1: 1, v2: 1, expected: true },
+ { name: 'different numbers', v1: 1, v2: 2, expected: false },
+ { name: 'identical strings', v1: 'hello', v2: 'hello', expected: true },
+ { name: 'different strings', v1: 'hello', v2: 'world', expected: false },
+ { name: 'identical booleans', v1: true, v2: true, expected: true },
+ {
+ name: 'deeply identical plain objects',
+ v1: { a: 1, b: { c: 3, d: [4, 5] } },
+ v2: { a: 1, b: { c: 3, d: [4, 5] } },
+ expected: true,
+ },
+ {
+ name: 'deeply different plain objects',
+ v1: { a: 1, b: { c: 99 } },
+ v2: { a: 1, b: { c: 3 } },
+ expected: false,
+ },
+ {
+ name: 'objects where one has extra properties (forward)',
+ v1: { a: 1, b: 2 },
+ v2: { a: 1 },
+ expected: false,
+ },
+ {
+ name: 'objects where one has extra properties (backward)',
+ v1: { a: 1 },
+ v2: { a: 1, b: 2 },
+ expected: false,
+ },
+ ])('should return $expected for $name', ({ v1, v2, expected }) => {
+ expect(isSame(v1, v2)).toBe(expected);
+ });
+ });
+
+ describe('Null and Undefined Handling', () => {
+ test.each([
+ { name: 'two nulls', v1: null, v2: null, expected: true },
+ { name: 'two undefineds', v1: undefined, v2: undefined, expected: true },
+ { name: 'null and undefined', v1: null, v2: undefined, expected: false },
+ { name: 'object and null', v1: { a: 1 }, v2: null, expected: false },
+ {
+ name: 'object and undefined',
+ v1: { a: 1 },
+ v2: undefined,
+ expected: false,
+ },
+ {
+ name: 'object properties with identical nulls',
+ v1: { a: null },
+ v2: { a: null },
+ expected: true,
+ },
+ {
+ name: 'object properties with identical undefineds',
+ v1: { a: undefined },
+ v2: { a: undefined },
+ expected: true,
+ },
+ {
+ name: 'object properties with null vs undefined',
+ v1: { a: null },
+ v2: { a: undefined },
+ expected: false,
+ },
+ ])(
+ 'should return $expected when comparing $name',
+ ({ v1, v2, expected }) => {
+ expect(isSame(v1, v2)).toBe(expected);
+ },
+ );
+ });
+
+ describe('Non-Plain Object Handling (Arrays, Dates, Instances, Functions)', () => {
+ test.each([
+ {
+ name: 'identical arrays',
+ v1: [1, 2, { a: 3 }],
+ v2: [1, 2, { a: 3 }],
+ expected: true,
+ },
+ {
+ name: 'different arrays (length)',
+ v1: [1, 2],
+ v2: [1, 2, 3],
+ expected: false,
+ },
+ {
+ name: 'different arrays (value)',
+ v1: [{ a: 1 }],
+ v2: [{ a: 2 }],
+ expected: false,
+ },
+ {
+ name: 'identical Date objects',
+ v1: new Date('2024-01-01'),
+ v2: new Date('2024-01-01'),
+ expected: true,
+ },
+ {
+ name: 'different Date objects',
+ v1: new Date('2024-01-01'),
+ v2: new Date('2024-01-02'),
+ expected: false,
+ },
+ {
+ name: 'identical class instances (by value)',
+ v1: new MockClass(10),
+ v2: new MockClass(10),
+ expected: true,
+ },
+ {
+ name: 'different class instances (by value)',
+ v1: new MockClass(10),
+ v2: new MockClass(20),
+ expected: false,
+ },
+ ])('should return $expected for $name', ({ v1, v2, expected }) => {
+ expect(isSame(v1, v2)).toBe(expected);
+ });
+
+ test('should return true for identical function references', () => {
+ const func = () => {};
+ expect(isSame(func, func)).toBe(true);
+ });
+
+ test('should return false for different function references', () => {
+ const func1 = () => {};
+ const func2 = () => {};
+ expect(isSame(func1, func2)).toBe(false);
+ });
+ });
+
+ describe('Advanced Edge Cases', () => {
+ test('should handle NaN correctly, returning true', () => {
+ expect(isSame(Number.NaN, Number.NaN)).toBe(true);
+ expect(isSame({ a: Number.NaN }, { a: Number.NaN })).toBe(true);
+ expect(isSame({ a: Number.NaN }, { a: 1 })).toBe(false);
+ });
+
+ test('should differentiate between objects with explicit undefined and missing properties', () => {
+ expect(isSame({ a: 1, b: undefined }, { a: 1 })).toBe(false);
+ });
+
+ test('should differentiate between arrays and array-like objects', () => {
+ expect(isSame([1, 2, 3], { 0: 1, 1: 2, 2: 3, length: 3 })).toBe(false);
+ });
+
+ test('should handle objects created with Object.create(null)', () => {
+ const obj1 = Object.create(null);
+ obj1.a = 1;
+ const obj2 = Object.create(null);
+ obj2.a = 1;
+ const obj3 = { a: 1 };
+
+ expect(isSame(obj1, obj2)).toBe(true);
+ expect(isSame(obj1, obj3)).toBe(false);
+ });
+
+ test('should compare RegExp objects', () => {
+ expect(isSame(/abc/g, /abc/g)).toBe(true);
+ expect(isSame(/abc/g, /abc/i)).toBe(false);
+ expect(isSame(/abc/g, /abc/g)).toBe(true);
+ });
+
+ test('should handle circular references without crashing', () => {
+ const obj1 = {};
+ const obj2 = {};
+ obj1.a = obj2;
+ obj2.a = obj1;
+
+ const obj3 = {};
+ const obj4 = {};
+ obj3.a = obj4;
+ obj4.a = obj3;
+
+ expect(isSame(obj1, obj3)).toBe(true);
+ });
+ });
+
+ describe('ES6+ Data Structures and Features', () => {
+ test('should handle Symbol properties correctly', () => {
+ const sym = Symbol('id');
+ const obj1 = { [sym]: 1 };
+ const obj2 = { [sym]: 1 };
+ const obj3 = { [sym]: 2 };
+
+ expect(isSame(obj1, obj2)).toBe(true);
+ expect(isSame(obj1, obj3)).toBe(false);
+ expect(isSame({ a: 1, [sym]: 1 }, { a: 1 })).toBe(false);
+ });
+
+ test('should compare Map objects correctly', () => {
+ const map1 = new Map([
+ ['a', 1],
+ ['b', { x: 2 }],
+ ]);
+ const map2 = new Map([
+ ['a', 1],
+ ['b', { x: 2 }],
+ ]);
+ const map3 = new Map([
+ ['b', { x: 2 }],
+ ['a', 1],
+ ]);
+ const map4 = new Map([
+ ['a', 1],
+ ['b', { x: 99 }],
+ ]);
+
+ expect(isSame(map1, map2)).toBe(true);
+ expect(isSame(map1, map3)).toBe(false);
+ expect(isSame(map1, map4)).toBe(false);
+ });
+
+ test('should compare Set objects correctly', () => {
+ const set1 = new Set([1, { a: 2 }]);
+ const set2 = new Set([1, { a: 2 }]);
+ const set3 = new Set([{ a: 2 }, 1]);
+ const set4 = new Set([1, { a: 99 }]);
+
+ expect(isSame(set1, set2)).toBe(true);
+ expect(isSame(set1, set3)).toBe(false);
+ expect(isSame(set1, set4)).toBe(false);
+ });
+
+ test('should compare TypedArrays by value', () => {
+ const arr1 = new Uint8Array([1, 2, 3]);
+ const arr2 = new Uint8Array([1, 2, 3]);
+ const arr3 = new Uint8Array([1, 2, 4]);
+ const arr4 = new Float32Array([1, 2, 3]);
+
+ expect(isSame(arr1, arr2)).toBe(true);
+ expect(isSame(arr1, arr3)).toBe(false);
+ expect(isSame(arr1, arr4)).toBe(false);
+ });
+ });
+});
diff --git a/src/utils/diff/isSame.js b/src/utils/diff/isSame.js
deleted file mode 100644
index fa9deae1..00000000
--- a/src/utils/diff/isSame.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { isPlainObject } from 'is-plain-object';
-import { diffJson } from './diff-json';
-
-export const isSame = (value1, value2) => {
- if (!isPlainObject(value1) || !isPlainObject(value2)) {
- return value1 === value2;
- }
-
- const json = diffJson(value1, value2);
- return Object.keys(json).length === 0;
-};
diff --git a/src/utils/diff/isSame.test.js b/src/utils/diff/isSame.test.js
deleted file mode 100644
index 652689df..00000000
--- a/src/utils/diff/isSame.test.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { isSame } from './isSame';
-
-describe('isSame', () => {
- it('should return true for identical plain objects', () => {
- const obj1 = { a: 1, b: 2 };
- const obj2 = { a: 1, b: 2 };
- expect(isSame(obj1, obj2)).toBe(true);
- });
-
- it('should return false for different plain objects', () => {
- const obj1 = { a: 1, b: 2 };
- const obj2 = { a: 1, b: 3 };
- expect(isSame(obj1, obj2)).toBe(false);
- });
-
- it('should return true for identical non-object values', () => {
- expect(isSame(1, 1)).toBe(true);
- expect(isSame('test', 'test')).toBe(true);
- });
-
- it('should return false for different non-object values', () => {
- expect(isSame(1, 2)).toBe(false);
- expect(isSame('test', 'diff')).toBe(false);
- });
-
- it('should return true for deeply identical objects', () => {
- const obj1 = { a: { b: 2, c: 3 }, d: 4 };
- const obj2 = { a: { b: 2, c: 3 }, d: 4 };
- expect(isSame(obj1, obj2)).toBe(true);
- });
-
- it('should return false for deeply different objects', () => {
- const obj1 = { a: { b: 2, c: 3 }, d: 4 };
- const obj2 = { a: { b: 2, c: 4 }, d: 4 };
- expect(isSame(obj1, obj2)).toBe(false);
- });
-
- it('should handle undefined and null values correctly', () => {
- expect(isSame(undefined, undefined)).toBe(true);
- expect(isSame(null, null)).toBe(true);
- expect(isSame(undefined, null)).toBe(false);
- expect(isSame(null, undefined)).toBe(false);
- expect(isSame({ a: undefined }, { a: undefined })).toBe(true);
- expect(isSame({ a: null }, { a: null })).toBe(true);
- expect(isSame({ a: undefined }, { a: null })).toBe(true);
- });
-
- it('should handle objects with null and undefined properties correctly', () => {
- const obj1 = { a: null, b: undefined, c: { d: null } };
- const obj2 = { a: null, b: undefined, c: { d: null } };
- const obj3 = { a: null, b: undefined, c: { d: undefined } };
-
- expect(isSame(obj1, obj2)).toBe(true);
- expect(isSame(obj1, obj3)).toBe(true);
- });
-
- it('should handle mixed object and primitive comparisons', () => {
- expect(isSame({ a: 1 }, 1)).toBe(false);
- expect(isSame({ a: 1 }, null)).toBe(false);
- expect(isSame({ a: 1 }, undefined)).toBe(false);
- expect(isSame(null, { a: 1 })).toBe(false);
- expect(isSame(undefined, { a: 1 })).toBe(false);
- });
-});