Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,17 +226,17 @@ For **detailed type definitions**, refer to the [data.d.ts](src/display/data-sch
<br/>

### `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.
- `elements` (optional, object \| array) - Direct references to one or more objects to update. Accepts a single object or an array. (Objects returned from [selector](#selectorpath), etc.).
- `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`.


Expand Down
8 changes: 4 additions & 4 deletions README_KR.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,17 +225,17 @@ draw method가 요구하는 **데이터 구조**입니다.
<br/>

### `update(options)`
캔버스에 렌더링된 객체의 속성을 업데이트합니다. 기본적으로 변경된 속성만 반영하지만, refresh 또는 arrayMerge 옵션을 통해 업데이트 동작을 정밀하게 제어할 수 있습니다.
캔버스에 렌더링된 객체의 속성을 업데이트합니다. 기본적으로 변경된 속성만 반영하지만, refresh 또는 mergeStrategy 옵션을 통해 업데이트 동작을 정밀하게 제어할 수 있습니다.

#### **`Options`**
- `path` (optional, string) - [jsonpath](https://github.com/JSONPath-Plus/JSONPath) 문법에 따른 selector로, 이벤트가 적용될 객체를 선택합니다.
- `elements` (optional, object \| array) - 업데이트할 하나 이상의 객체에 대한 직접 참조입니다. 단일 객체 또는 배열을 허용합니다. ([selector](#selectorpath)에서 반환된 객체 등).
- `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
Expand Down
15 changes: 0 additions & 15 deletions src/command/commands/base.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
}
}
1 change: 0 additions & 1 deletion src/command/commands/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
62 changes: 62 additions & 0 deletions src/command/commands/update.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
3 changes: 0 additions & 3 deletions src/command/undo-redo-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
172 changes: 172 additions & 0 deletions src/command/undo-redo-manager.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 3 additions & 6 deletions src/display/components/Text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down
Loading