diff --git a/README.md b/README.md index d7112b26..e7de313b 100644 --- a/README.md +++ b/README.md @@ -443,8 +443,8 @@ class CustomState extends State { // Define the events this state will handle as a static property. static handledEvents = ['onpointerdown', 'onkeydown']; - enter(context, customOptions) { - super.enter(context); + enter(store, customOptions) { + super.enter(store); console.log('CustomState has started.', customOptions); } @@ -461,7 +461,7 @@ class CustomState extends State { onkeydown(event) { if (event.key === 'Escape') { // Switch to the 'selection' state (the default state). - this.context.stateManager.setState('selection'); + this.store.stateManager.setState('selection'); } // Return PROPAGATE_EVENT to propagate the event to the next state in the stack. return PROPAGATE_EVENT; @@ -501,7 +501,7 @@ The default state that handles user selection and drag events. It is automatical - `onUp` (optional, function): Callback fired on `pointerup` if it was not a drag operation. - `onClick` (optional, function): Callback fired when a complete 'click' is detected. This will not fire if `onDoubleClick` fires. - `onDoubleClick` (optional, function): Callback fired when a complete 'double-click' is detected. Based on `e.detail === 2`. -- `onRightClick` (optional, function): Callback fired when a complete right-click is detected. The browser's default context menu is automatically prevented within the canvas area. +- `onRightClick` (optional, function): Callback fired when a complete right-click is detected. The browser's default store menu is automatically prevented within the canvas area. - `onDragStart` (optional, function): Callback fired *once* when a drag operation (for multi-selection) begins (after moving beyond a threshold). - `onDrag` (optional, function): Callback fired repeatedly *during* a drag operation. - `onDragEnd` (optional, function): Callback fired when the drag operation *ends* (`pointerup`). diff --git a/README_KR.md b/README_KR.md index 21ef8c12..07dd7df8 100644 --- a/README_KR.md +++ b/README_KR.md @@ -453,8 +453,8 @@ class CustomState extends State { // 이 상태가 처리할 이벤트를 static 속성으로 정의합니다. static handledEvents = ['onpointerdown', 'onkeydown']; - enter(context, customOptions) { - super.enter(context); + enter(store, customOptions) { + super.enter(store); console.log('CustomState가 시작되었습니다.', customOptions); } @@ -471,7 +471,7 @@ class CustomState extends State { onkeydown(event) { if (event.key === 'Escape') { // 'selection' 상태(기본 상태)로 전환합니다. - this.context.stateManager.setState('selection'); + this.store.stateManager.setState('selection'); } // 이벤트를 스택의 다음 상태로 전파하려면 PROPAGATE_EVENT를 반환합니다. return PROPAGATE_EVENT; diff --git a/src/assets/textures/rect.js b/src/assets/textures/rect.js index 406f3e09..5007466a 100644 --- a/src/assets/textures/rect.js +++ b/src/assets/textures/rect.js @@ -1,15 +1,29 @@ -import { Graphics } from 'pixi.js'; -import { EachRadius } from '../../display/data-schema/primitive-schema'; -import { getColor } from '../../utils/get'; +import { Rect } from '../../display/elements/Rect'; import { cacheKey, generateTexture } from './utils'; +const DEFAULT_RECT_OPTS = { + fill: 'transparent', + borderWidth: 0, + borderColor: 'black', + radius: 0, +}; + +const normalizeRectOpts = (rectOpts = {}) => { + const normalized = { ...rectOpts }; + for (const key of ['fill', 'borderWidth', 'borderColor', 'radius']) { + if (normalized[key] == null) delete normalized[key]; + } + return normalized; +}; + export const createRectTexture = (renderer, theme, rectOpts) => { + const normalizedRectOpts = normalizeRectOpts(rectOpts); const { - fill = null, - borderWidth = null, - borderColor = null, - radius = null, - } = rectOpts; + fill = DEFAULT_RECT_OPTS.fill, + borderWidth = DEFAULT_RECT_OPTS.borderWidth, + borderColor = DEFAULT_RECT_OPTS.borderColor, + radius = DEFAULT_RECT_OPTS.radius, + } = normalizedRectOpts; const rect = createRect(theme, { fill, borderWidth, borderColor, radius }); const texture = generateTexture(rect, renderer); @@ -34,36 +48,19 @@ export const createRectTexture = (renderer, theme, rectOpts) => { }; const createRect = (theme, { fill, borderWidth, borderColor, radius }) => { - const graphics = new Graphics(); - const size = 20 + borderWidth; - - const xywh = [0, 0, size, size]; - const parsedRadius = EachRadius.safeParse(radius); - if (typeof radius === 'number' && radius > 0) { - graphics.roundRect(...xywh, radius); - } else if (parsedRadius.success) { - const r = parsedRadius.data; - graphics.roundShape( - [ - { x: 0, y: 0, radius: r.topLeft }, - { x: size, y: 0, radius: r.topRight }, - { x: size, y: size, radius: r.bottomRight }, - { x: 0, y: size, radius: r.bottomLeft }, - ], - 0, - ); - } else { - graphics.rect(...xywh); - } - - if (fill) graphics.fill(getColor(theme, fill)); - if (borderWidth) { - graphics.stroke({ - width: borderWidth, - color: getColor(theme, borderColor), - }); - } - return graphics; + const safeBorderWidth = borderWidth ?? 0; + const size = 20 + safeBorderWidth; + const stroke = + safeBorderWidth > 0 + ? { width: safeBorderWidth, color: borderColor } + : undefined; + const rect = new Rect({ theme }); + const changes = { size: { width: size, height: size } }; + if (fill !== undefined) changes.fill = fill; + if (stroke !== undefined) changes.stroke = stroke; + if (radius !== undefined) changes.radius = radius; + rect.apply(changes); + return rect; }; const getSliceDimension = (radius, key1, key2) => { diff --git a/src/assets/textures/texture.js b/src/assets/textures/texture.js index 1e56de75..c23b0d6b 100644 --- a/src/assets/textures/texture.js +++ b/src/assets/textures/texture.js @@ -5,7 +5,7 @@ import { cacheKey } from './utils'; export const getTexture = (renderer, theme, config) => { let texture = null; if (typeof config === 'string') { - texture = Assets.get(config); + texture = Assets.cache.has(config) ? Assets.get(config) : null; } else { texture = Assets.cache.has(cacheKey(renderer, config)) ? Assets.cache.get(cacheKey(renderer, config)) diff --git a/src/command/commands/update.js b/src/command/commands/update.js index ad06308e..61491049 100644 --- a/src/command/commands/update.js +++ b/src/command/commands/update.js @@ -9,7 +9,7 @@ export class UpdateCommand extends Command { this.elementId = element.id; this.parent = element.parent; - this.context = element.context; + this.store = element.store; this.changes = changes; this.options = options; @@ -52,9 +52,9 @@ export class UpdateCommand extends Command { } } - if (this.context?.viewport && !this.context.viewport.destroyed) { + if (this.store?.viewport && !this.store.viewport.destroyed) { const candidates = collectCandidates( - this.context.viewport, + this.store.viewport, (node) => node.id === targetId, ); if (candidates[0]) { @@ -81,9 +81,11 @@ export class UpdateCommand extends Command { changes.attrs !== null && typeof changes.attrs === 'object' ) { - const prevAttrs = {}; + const prevAttrs = this._deepClone(currentProps.attrs || {}); for (const attrKey of Object.keys(changes.attrs)) { - prevAttrs[attrKey] = this._deepClone(this.element[attrKey]); + if (!(attrKey in prevAttrs)) { + prevAttrs[attrKey] = this._deepClone(this.element[attrKey]); + } } slice.attrs = prevAttrs; } else { diff --git a/src/display/Viewport.js b/src/display/Viewport.js index 0562fd1e..ca35450e 100644 --- a/src/display/Viewport.js +++ b/src/display/Viewport.js @@ -8,7 +8,7 @@ const ComposedViewport = mixins(Viewport, Base, Childrenable); export default class BaseViewport extends ComposedViewport { constructor(options) { - super({ type: 'canvas', ...options }); + super({ type: 'canvas', sortableChildren: true, ...options }); } apply(changes, options) { diff --git a/src/display/components/Background.js b/src/display/components/Background.js index 5dc9be1d..231a1b1b 100644 --- a/src/display/components/Background.js +++ b/src/display/components/Background.js @@ -19,8 +19,8 @@ const ComposedBackground = mixins( export class Background extends ComposedBackground { static respectsPadding = false; - constructor(context) { - super({ type: 'background', context, texture: Texture.WHITE }); + constructor(store) { + super({ type: 'background', store, texture: Texture.WHITE }); } apply(changes, options) { diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js index 1fe44425..ead3df83 100644 --- a/src/display/components/Bar.js +++ b/src/display/components/Bar.js @@ -25,8 +25,8 @@ const ComposedBar = mixins( ); export class Bar extends ComposedBar { - constructor(context) { - super({ type: 'bar', context, texture: Texture.WHITE }); + constructor(store) { + super({ type: 'bar', store, texture: Texture.WHITE }); this.constructor.registerHandler( EXTRA_KEYS.PLACEMENT, diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js index 692e7420..397e3412 100644 --- a/src/display/components/Icon.js +++ b/src/display/components/Icon.js @@ -23,8 +23,8 @@ const ComposedIcon = mixins( ); export class Icon extends ComposedIcon { - constructor(context) { - super({ type: 'icon', context, texture: Texture.WHITE }); + constructor(store) { + super({ type: 'icon', store, texture: Texture.WHITE }); this.constructor.registerHandler( EXTRA_KEYS.PLACEMENT, diff --git a/src/display/components/Text.js b/src/display/components/Text.js index 399e81b1..9eb3be58 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -4,6 +4,7 @@ import { Base } from '../mixins/Base'; import { Placementable } from '../mixins/Placementable'; import { Showable } from '../mixins/Showable'; import { Textable } from '../mixins/Textable'; +import { TextLayoutable } from '../mixins/TextLayoutable'; import { Textstyleable } from '../mixins/Textstyleable'; import { Tintable } from '../mixins/Tintable'; import { mixins } from '../mixins/utils'; @@ -18,13 +19,14 @@ const ComposedText = mixins( Showable, Textable, Textstyleable, + TextLayoutable, Tintable, Placementable, ); export class Text extends ComposedText { - constructor(context) { - super({ type: 'text', context, text: '' }); + constructor(store) { + super({ type: 'text', store, text: '' }); this.constructor.registerHandler( EXTRA_KEYS.PLACEMENT, diff --git a/src/display/components/creator.js b/src/display/components/creator.js index f0f67b0c..9979aeb7 100644 --- a/src/display/components/creator.js +++ b/src/display/components/creator.js @@ -13,9 +13,9 @@ export const registerComponent = (type, componentClass) => { creator[type] = componentClass; }; -export const newComponent = (type, context) => { +export const newComponent = (type, store) => { if (!creator[type]) { throw new Error(`Component type "${type}" has not been registered.`); } - return new creator[type](context); + return new creator[type](store); }; diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index 3db46e12..432b8071 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -2,10 +2,10 @@ import { z } from 'zod'; import { Base, Color, + LabelTextStyle, Margin, Placement, PxOrPercentSize, - TextStyle, TextureStyle, } from './primitive-schema'; @@ -68,7 +68,7 @@ export const textSchema = Base.extend({ margin: Margin.default(0), tint: Color, text: z.string().default(''), - style: TextStyle, + style: LabelTextStyle, split: z.number().int().default(0), }).strict(); diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index 9c5e4670..78f9e27b 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -1,6 +1,16 @@ import { z } from 'zod'; import { componentArraySchema } from './component-schema'; -import { Base, Gap, Margin, RelationsStyle, Size } from './primitive-schema'; +import { + Base, + Color, + EachRadius, + ElementTextStyle, + Gap, + Margin, + RelationsStyle, + Size, + StrokeStyle, +} from './primitive-schema'; /** * A viewport is a container that can be panned and zoomed. @@ -65,11 +75,50 @@ export const relationsSchema = Base.extend({ style: RelationsStyle, }).strict(); +/** + * Renders an image from a URL or an asset key. + * Visually represented by a `Container` containing a `Sprite`. + * @see {@link https://pixijs.download/release/docs/scene.Sprite.html} + */ +export const imageSchema = Base.extend({ + type: z.literal('image'), + source: z.string(), + size: Size.optional(), +}).strict(); + +/** + * Renders text using BitmapText. + * Visually represented by a `Container` containing a `BitmapText`. + * @see {@link https://pixijs.download/release/docs/text_bitmap.BitmapText.html} + */ +export const textSchema = Base.extend({ + type: z.literal('text'), + text: z.string().default(''), + style: ElementTextStyle, + size: Size.optional(), +}).strict(); + +/** + * Renders a rectangle using a Graphics object. + * Visually represented by a `Graphics`. + * @see {@link https://pixijs.download/release/docs/scene.Graphics.html} + */ +export const rectSchema = Base.extend({ + type: z.literal('rect'), + size: Size, + fill: Color.optional(), + stroke: StrokeStyle.optional(), + radius: z.union([z.number().nonnegative(), EachRadius]).default(0), +}).strict(); + export const elementTypes = z.discriminatedUnion('type', [ groupSchema, gridSchema, itemSchema, relationsSchema, + imageSchema, + textSchema, + rectSchema, ]); export const mapDataSchema = z diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js index af13f2b9..55316df7 100644 --- a/src/display/data-schema/element-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -4,9 +4,12 @@ import { uid } from '../../utils/uuid'; import { gridSchema, groupSchema, + imageSchema, itemSchema, mapDataSchema, + rectSchema, relationsSchema, + textSchema, } from './element-schema.js'; // Mock component-schema as its details are not relevant for these element tests. @@ -190,6 +193,109 @@ describe('Element Schemas', () => { }); }); + describe('Image Schema', () => { + it('should parse a valid image with source', () => { + const imageData = { + type: 'image', + id: 'img-1', + source: 'https://example.com/image.png', + }; + const parsed = imageSchema.parse(imageData); + expect(parsed.source).toBe('https://example.com/image.png'); + }); + + it('should parse a valid image with source and size', () => { + const imageData = { + type: 'image', + id: 'img-1', + source: 'asset-key', + size: { width: 100, height: 200 }, + }; + const parsed = imageSchema.parse(imageData); + expect(parsed.size).toEqual({ width: 100, height: 200 }); + }); + + it('should fail if source is missing', () => { + const imageData = { type: 'image', id: 'img-1' }; + expect(() => imageSchema.parse(imageData)).toThrow(); + }); + + it('should fail if an unknown property is provided', () => { + const imageData = { + type: 'image', + id: 'img-1', + source: 'url', + invalid: true, + }; + expect(() => imageSchema.parse(imageData)).toThrow(); + }); + }); + + describe('Text Schema', () => { + it('should parse a valid text element', () => { + const textData = { + type: 'text', + id: 'text-1', + text: 'hello world', + }; + const parsed = textSchema.parse(textData); + expect(parsed.text).toBe('hello world'); + }); + + it('should parse with style and size', () => { + const textData = { + type: 'text', + id: 'text-1', + text: 'hello', + style: { fontSize: 20, fill: 'red' }, + size: { width: 100, height: 50 }, + }; + const parsed = textSchema.parse(textData); + expect(parsed.style.fontSize).toBe(20); + expect(parsed.size).toEqual({ width: 100, height: 50 }); + }); + + it('should fail if an unknown property is provided', () => { + const textData = { + type: 'text', + id: 'text-1', + text: 'hello', + unknown: 'property', + }; + expect(() => textSchema.parse(textData)).toThrow(); + }); + }); + + describe('Rect Schema', () => { + it('should parse a valid rect element', () => { + const rectData = { + type: 'rect', + id: 'rect-1', + size: { width: 120, height: 80 }, + fill: 'red', + stroke: { color: 'blue', width: 2 }, + radius: 6, + }; + const parsed = rectSchema.parse(rectData); + expect(parsed.size).toEqual({ width: 120, height: 80 }); + }); + + it('should fail if size is missing', () => { + const rectData = { type: 'rect', id: 'rect-1' }; + expect(() => rectSchema.parse(rectData)).toThrow(); + }); + + it('should fail if an unknown property is provided', () => { + const rectData = { + type: 'rect', + id: 'rect-1', + size: { width: 10, height: 10 }, + unknown: 'property', + }; + expect(() => rectSchema.parse(rectData)).toThrow(); + }); + }); + describe('mapDataSchema (Full Integration)', () => { it('should parse a valid array of mixed elements with unique IDs', () => { const data = [ diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index acd4492d..cdf0fd3c 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -218,33 +218,50 @@ export const TextureStyle = z /** * @see {@link https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html} */ -export const RelationsStyle = z +export const StrokeStyle = z .object({ color: Color.default(DEFAULT_PATHSTYLE.color), }) - .passthrough() - .default({}); + .passthrough(); /** - * @see {@link https://pixijs.download/release/docs/text.TextStyleOptions.html} + * @see {@link https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html} + */ +export const RelationsStyle = StrokeStyle.default({}); + +/** + * Common text properties shared by all text-based objects. */ export const TextStyle = z .object({ - fontSize: z.union([z.number(), z.literal('auto'), z.string()]).optional(), - autoFont: z - .object({ - min: z.number().positive().default(DEFAULT_AUTO_FONT_RANGE.min), - max: z.number().positive().default(DEFAULT_AUTO_FONT_RANGE.max), - }) - .refine((data) => data.min <= data.max, { - message: 'autoFont.min must not be greater than autoFont.max', - }) - .default({}), fontFamily: z.any().default(DEFAULT_TEXTSTYLE.fontFamily), fontWeight: z.any().default(DEFAULT_TEXTSTYLE.fontWeight), fill: z.any().default(DEFAULT_TEXTSTYLE.fill), - wordWrapWidth: z.union([z.number(), z.literal('auto')]).optional(), - overflow: z.enum(['visible', 'hidden', 'ellipsis']).default('visible'), + fontSize: z.number().default(16), }) - .passthrough() - .default({}); + .passthrough(); + +/** + * Text style for item labels/components that supports auto-sizing. + * @see {@link https://pixijs.download/release/docs/text.TextStyleOptions.html} + */ +export const LabelTextStyle = TextStyle.extend({ + fontSize: z.union([z.number(), z.literal('auto'), z.string()]).optional(), + autoFont: z + .object({ + min: z.number().positive().default(DEFAULT_AUTO_FONT_RANGE.min), + max: z.number().positive().default(DEFAULT_AUTO_FONT_RANGE.max), + }) + .default({}), + wordWrapWidth: z.union([z.number(), z.literal('auto')]).optional(), + overflow: z.enum(['visible', 'hidden', 'ellipsis']).default('visible'), +}).default({}); + +/** + * Text style for standalone Text elements, following design tool behaviors. + */ +export const ElementTextStyle = TextStyle.extend({ + wordWrap: z.boolean().default(true), + lineHeight: z.number().optional(), + letterSpacing: z.number().default(0), +}).default({}); diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js index 57d8f61d..38e16d2d 100644 --- a/src/display/data-schema/primitive-schema.test.js +++ b/src/display/data-schema/primitive-schema.test.js @@ -5,13 +5,13 @@ import { Base, Color, Gap, + LabelTextStyle, Margin, Placement, PxOrPercentSize, pxOrPercentSchema, RelationsStyle, Size, - TextStyle, TextureStyle, } from './primitive-schema'; @@ -350,10 +350,10 @@ describe('Primitive Schema Tests', () => { }); }); - describe('TextStyle Schema', () => { + describe('LabelTextStyle Schema', () => { it('should apply default styles for a partial object', () => { const data = { fontSize: 16 }; - expect(TextStyle.parse(data)).toEqual({ + expect(LabelTextStyle.parse(data)).toEqual({ autoFont: { min: 1, max: 100 }, fill: 'black', fontFamily: 'FiraCode', @@ -365,7 +365,7 @@ describe('Primitive Schema Tests', () => { it('should not override provided styles', () => { const data = { fontFamily: 'Arial', fill: 'red', fontWeight: 'bold' }; - expect(TextStyle.parse(data)).toEqual({ + expect(LabelTextStyle.parse(data)).toEqual({ autoFont: { min: 1, max: 100 }, fontFamily: 'Arial', fontWeight: 'bold', diff --git a/src/display/draw.js b/src/display/draw.js index 4a978f67..236547e2 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -1,7 +1,7 @@ import Element from './elements/Element'; -export const draw = (context, data) => { - const { viewport } = context; +export const draw = (store, data) => { + const { viewport } = store; destroyChildren(viewport); viewport.apply( { type: 'canvas', children: data }, diff --git a/src/display/elements/Grid.js b/src/display/elements/Grid.js index 89f8c071..738e56ee 100644 --- a/src/display/elements/Grid.js +++ b/src/display/elements/Grid.js @@ -7,8 +7,8 @@ import Element from './Element'; const ComposedGrid = mixins(Element, Cellsable, Itemable); export class Grid extends ComposedGrid { - constructor(context) { - super({ type: 'grid', context }); + constructor(store) { + super({ type: 'grid', store }); } apply(changes, options) { diff --git a/src/display/elements/Group.js b/src/display/elements/Group.js index c4f267ab..cc0ef6df 100644 --- a/src/display/elements/Group.js +++ b/src/display/elements/Group.js @@ -6,8 +6,8 @@ import Element from './Element'; const ComposedGroup = mixins(Element, Childrenable); export class Group extends ComposedGroup { - constructor(context) { - super({ type: 'group', context, isRenderGroup: true }); + constructor(store) { + super({ type: 'group', store, isRenderGroup: true }); } apply(changes, options) { diff --git a/src/display/elements/Image.js b/src/display/elements/Image.js new file mode 100644 index 00000000..36190c0e --- /dev/null +++ b/src/display/elements/Image.js @@ -0,0 +1,24 @@ +import { Sprite, Texture } from 'pixi.js'; +import { imageSchema } from '../data-schema/element-schema'; +import { ImageSizeable } from '../mixins/ImageSizeable'; +import { Sourceable } from '../mixins/Sourceable'; +import { mixins } from '../mixins/utils'; +import Element from './Element'; + +const ComposedImage = mixins(Element, Sourceable, ImageSizeable); + +export class Image extends ComposedImage { + static isSelectable = true; + static hitScope = 'children'; + + constructor(store) { + super({ type: 'image', store }); + this.sprite = new Sprite(Texture.EMPTY); + this.addChild(this.sprite); + this._loadToken = 0; + } + + apply(changes, options) { + super.apply(changes, imageSchema, options); + } +} diff --git a/src/display/elements/Item.js b/src/display/elements/Item.js index 62bd1e41..3383219d 100644 --- a/src/display/elements/Item.js +++ b/src/display/elements/Item.js @@ -10,8 +10,8 @@ export class Item extends ComposedItem { static isSelectable = true; static hitScope = 'children'; - constructor(context) { - super({ type: 'item', context }); + constructor(store) { + super({ type: 'item', store }); } apply(changes, options) { diff --git a/src/display/elements/Rect.js b/src/display/elements/Rect.js new file mode 100644 index 00000000..4f87a072 --- /dev/null +++ b/src/display/elements/Rect.js @@ -0,0 +1,72 @@ +import { Graphics } from 'pixi.js'; +import { getColor } from '../../utils/get'; +import { rectSchema } from '../data-schema/element-schema'; +import { EachRadius } from '../data-schema/primitive-schema'; +import { Base } from '../mixins/Base'; +import { UPDATE_STAGES } from '../mixins/constants'; +import { Showable } from '../mixins/Showable'; +import { mixins } from '../mixins/utils'; + +const KEYS = ['size', 'fill', 'stroke', 'radius']; +const ComposedRect = mixins(Graphics, Base, Showable); + +export class Rect extends ComposedRect { + static isSelectable = true; + static hitScope = 'self'; + + constructor(store) { + super({ type: 'rect', store }); + this.eventMode = 'static'; + } + + apply(changes, options) { + super.apply(changes, rectSchema, options); + } + + _applyRect() { + const { size, fill, stroke, radius } = this.props; + this.clear(); + + if (!size) return; + const width = size.width ?? 0; + const height = size.height ?? 0; + if (width <= 0 || height <= 0) return; + + this._drawPath(width, height, radius); + + const theme = this.store?.theme; + if (fill !== undefined && fill !== null) { + this.fill(getColor(theme, fill)); + } + + if (stroke?.width > 0) { + const strokeStyle = { ...stroke }; + if (strokeStyle.color !== undefined) { + strokeStyle.color = getColor(theme, strokeStyle.color); + } + this.stroke(strokeStyle); + } + } + + _drawPath(width, height, radius) { + const parsedRadius = EachRadius.safeParse(radius); + if (typeof radius === 'number' && radius > 0) { + this.roundRect(0, 0, width, height, radius); + } else if (parsedRadius.success) { + const r = parsedRadius.data; + this.roundShape( + [ + { x: 0, y: 0, radius: r.topLeft }, + { x: width, y: 0, radius: r.topRight }, + { x: width, y: height, radius: r.bottomRight }, + { x: 0, y: height, radius: r.bottomLeft }, + ], + 0, + ); + } else { + this.rect(0, 0, width, height); + } + } +} + +Rect.registerHandler(KEYS, Rect.prototype._applyRect, UPDATE_STAGES.VISUAL); diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 52e95c7b..3d397abe 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -15,8 +15,8 @@ export class Relations extends ComposedRelations { _renderDirty = true; - constructor(context) { - super({ type: 'relations', context }); + constructor(store) { + super({ type: 'relations', store }); this.path = this.initPath(); } @@ -80,10 +80,10 @@ export class Relations extends ComposedRelations { continue; } - const sourceBounds = this.context.viewport.toLocal( + const sourceBounds = this.store.viewport.toLocal( calcOrientedBounds(sourceObject).center, ); - const targetBounds = this.context.viewport.toLocal( + const targetBounds = this.store.viewport.toLocal( calcOrientedBounds(targetObject).center, ); diff --git a/src/display/elements/Text.js b/src/display/elements/Text.js new file mode 100644 index 00000000..0fdf5cb5 --- /dev/null +++ b/src/display/elements/Text.js @@ -0,0 +1,35 @@ +import { BitmapText } from 'pixi.js'; +import { textSchema } from '../data-schema/element-schema'; +import { ElementTextLayoutable } from '../mixins/ElementTextLayoutable'; +import { Textable } from '../mixins/Textable'; +import { Textstyleable } from '../mixins/Textstyleable'; +import { mixins } from '../mixins/utils'; +import Element from './Element'; + +const ComposedText = mixins( + Element, + Textable, + Textstyleable, + ElementTextLayoutable, +); + +/** + * Text element that renders BitmapText. + * It's an independent element in the element tree. + */ +export class Text extends ComposedText { + static isSelectable = true; + static hitScope = 'children'; + + constructor(store) { + super({ type: 'text', store }); + + // Initialize internal BitmapText + this.bitmapText = new BitmapText({ text: '', style: {} }); + this.addChild(this.bitmapText); + } + + apply(changes, options) { + super.apply(changes, textSchema, options); + } +} diff --git a/src/display/elements/creator.js b/src/display/elements/creator.js index 54e256df..3e416586 100644 --- a/src/display/elements/creator.js +++ b/src/display/elements/creator.js @@ -13,9 +13,9 @@ export const registerElement = (type, elementClass) => { creator[type] = elementClass; }; -export const newElement = (type, context) => { +export const newElement = (type, store) => { if (!creator[type]) { throw new Error(`Element type "${type}" has not been registered.`); } - return new creator[type](context); + return new creator[type](store); }; diff --git a/src/display/elements/registry.js b/src/display/elements/registry.js index 74ce021a..464cb2c0 100644 --- a/src/display/elements/registry.js +++ b/src/display/elements/registry.js @@ -1,10 +1,16 @@ +import { registerElement } from './creator'; import { Grid } from './Grid'; import { Group } from './Group'; +import { Image } from './Image'; import { Item } from './Item'; +import { Rect } from './Rect'; import { Relations } from './Relations'; -import { registerElement } from './creator'; +import { Text } from './Text'; registerElement('group', Group); registerElement('grid', Grid); registerElement('item', Item); +registerElement('rect', Rect); registerElement('relations', Relations); +registerElement('image', Image); +registerElement('text', Text); diff --git a/src/display/mixins/Animationsizeable.js b/src/display/mixins/Animationsizeable.js index a30ff9d2..9faea71a 100644 --- a/src/display/mixins/Animationsizeable.js +++ b/src/display/mixins/Animationsizeable.js @@ -12,7 +12,7 @@ export const AnimationSizeable = (superClass) => { const newSize = calcSize(this, { source, size, margin }); if (animation) { - this.context.animationContext.add(() => { + this.store.animationContext.add(() => { this.killTweens(); const tween = gsap.to(this, { pixi: { diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index 8d5573ae..d43eabca 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -13,12 +13,12 @@ export const Base = (superClass) => { return class extends Type(superClass) { static _handlerMap = new Map(); static _handlerRegistry = new Map(); - #context; + #store; constructor(options = {}) { - const { context = null, ...rest } = options; + const { store = null, ...rest } = options; super(rest); - this.#context = context; + this.#store = store; this.props = {}; this._lastLocalTransform = tempMatrix.clone(); @@ -28,8 +28,8 @@ export const Base = (superClass) => { }; } - get context() { - return this.#context; + get store() { + return this.#store; } _afterRender() {} @@ -38,7 +38,7 @@ export const Base = (superClass) => { if (!this.localTransform || !this.visible) return; if (!this.localTransform.equals(this._lastLocalTransform)) { - this.context.viewport.emit('object_transformed', this); + this.store.viewport?.emit('object_transformed', this); this._lastLocalTransform.copyFrom(this.localTransform); } } @@ -81,9 +81,13 @@ export const Base = (superClass) => { if (isValidationError(nextProps)) throw nextProps; const actualChanges = diffReplace(this.props, nextProps) ?? {}; - if (options?.historyId && Object.keys(actualChanges).length > 0) { + if ( + options?.historyId && + Object.keys(actualChanges).length > 0 && + this.store.undoRedoManager + ) { const command = new UpdateCommand(this, changes, options); - this.context.undoRedoManager.execute(command, options); + this.store?.undoRedoManager.execute(command, options); return; } diff --git a/src/display/mixins/Cellsable.js b/src/display/mixins/Cellsable.js index daa26118..79ad3884 100644 --- a/src/display/mixins/Cellsable.js +++ b/src/display/mixins/Cellsable.js @@ -30,7 +30,7 @@ export const Cellsable = (superClass) => { x: colIndex * (itemProps.size.width + gap.x), y: rowIndex * (itemProps.size.height + gap.y), }; - const item = newElement('item', this.context); + const item = newElement('item', this.store); item.apply({ type: 'item', id, @@ -53,7 +53,7 @@ export const Cellsable = (superClass) => { item.destroy({ children: true }); } }); - this.context.viewport.emit('object_transformed', this); + this.store.viewport.emit('object_transformed', this); } }; MixedClass.registerHandler( diff --git a/src/display/mixins/Childrenable.js b/src/display/mixins/Childrenable.js index c9234769..a98e873c 100644 --- a/src/display/mixins/Childrenable.js +++ b/src/display/mixins/Childrenable.js @@ -30,7 +30,7 @@ export const Childrenable = (superClass) => { this.addChild(element); } } else { - element = newElement(childChange.type, this.context); + element = newElement(childChange.type, this.store); this.addChild(element); } element.apply(childChange, options); @@ -43,6 +43,10 @@ export const Childrenable = (superClass) => { element.destroy({ children: true }); }); } + + if (this.sortableChildren) { + this.sortDirty = true; + } } _onChildUpdate(childId, changes, mergeStrategy) { diff --git a/src/display/mixins/Componentsable.js b/src/display/mixins/Componentsable.js index ccf8a917..d7402b84 100644 --- a/src/display/mixins/Componentsable.js +++ b/src/display/mixins/Componentsable.js @@ -30,7 +30,7 @@ export const Componentsable = (superClass) => { this.addChild(component); } } else { - component = newComponent(componentChange.type, this.context); + component = newComponent(componentChange.type, this.store); this.addChild(component); } component.apply( diff --git a/src/display/mixins/ElementTextLayoutable.js b/src/display/mixins/ElementTextLayoutable.js new file mode 100644 index 00000000..8b51b3b8 --- /dev/null +++ b/src/display/mixins/ElementTextLayoutable.js @@ -0,0 +1,30 @@ +import { UPDATE_STAGES } from './constants'; + +const KEYS = ['size', 'style']; + +export const ElementTextLayoutable = (superClass) => { + const MixedClass = class extends superClass { + _applyElementTextLayout() { + const { size, style } = this.props; + const visual = this.bitmapText || this; + + if (style?.wordWrap && size?.width !== undefined) { + visual.style.wordWrap = true; + visual.style.wordWrapWidth = size.width; + } else { + visual.style.wordWrap = false; + } + + // Emit transformation event as text wrapping might change local bounds/alignment + this.store.viewport.emit('object_transformed', this); + } + }; + + MixedClass.registerHandler( + KEYS, + MixedClass.prototype._applyElementTextLayout, + UPDATE_STAGES.LAYOUT, + ); + + return MixedClass; +}; diff --git a/src/display/mixins/ImageSizeable.js b/src/display/mixins/ImageSizeable.js new file mode 100644 index 00000000..df341d5f --- /dev/null +++ b/src/display/mixins/ImageSizeable.js @@ -0,0 +1,30 @@ +import { UPDATE_STAGES } from './constants'; + +const KEYS = ['size']; + +export const ImageSizeable = (superClass) => { + const MixedClass = class extends superClass { + _applyImageSize() { + const { size } = this.props; + if (!size || !this.sprite) return; + + if (size.width !== undefined) this.sprite.width = size.width; + if (size.height !== undefined) this.sprite.height = size.height; + + this.store.viewport.emit('object_transformed', this); + } + + // Hook from Sourceable to ensure size is applied after async texture load + _onTextureApplied() { + this._applyImageSize(); + } + }; + + MixedClass.registerHandler( + KEYS, + MixedClass.prototype._applyImageSize, + UPDATE_STAGES.SIZE, + ); + + return MixedClass; +}; diff --git a/src/display/mixins/Relationstyleable.js b/src/display/mixins/Relationstyleable.js index 4d97e8ba..0bd84454 100644 --- a/src/display/mixins/Relationstyleable.js +++ b/src/display/mixins/Relationstyleable.js @@ -12,7 +12,7 @@ export const Relationstyleable = (superClass) => { if (!path) return; if ('color' in style) { - style.color = getColor(this.context.theme, style.color); + style.color = getColor(this.store.theme, style.color); } const newStrokeStyle = diff --git a/src/display/mixins/Sourceable.js b/src/display/mixins/Sourceable.js index 0c54dca9..c9008302 100644 --- a/src/display/mixins/Sourceable.js +++ b/src/display/mixins/Sourceable.js @@ -1,3 +1,4 @@ +import { Assets, Texture } from 'pixi.js'; import { getTexture } from '../../assets/textures/texture'; import { UPDATE_STAGES } from './constants'; @@ -5,11 +6,62 @@ const KEYS = ['source']; export const Sourceable = (superClass) => { const MixedClass = class extends superClass { - _applySource(relevantChanges) { + async _applySource(relevantChanges) { const { source } = relevantChanges; - const { viewport, theme } = this.context; - const texture = getTexture(viewport.app.renderer, theme, source); - Object.assign(this, { texture, ...(texture?.metadata?.slice ?? {}) }); + const { viewport, theme } = this.store; + + if (!source) { + this._setTexture(Texture.EMPTY); + return; + } + + // 1. Try synchronous retrieval (cached or predefined textures) + let texture = null; + try { + texture = getTexture(viewport.app.renderer, theme, source); + } catch { + // Fallback to async loading + } + + if (texture) { + this._setTexture(texture); + return; + } + + if (typeof source !== 'string') { + this._setTexture(Texture.EMPTY); + return; + } + + // 2. Asynchronous loading for URLs or unregistered asset keys + if (this._loadToken === undefined) this._loadToken = 0; + const currentToken = ++this._loadToken; + + try { + const loadedTexture = await Assets.load(source); + if (currentToken === this._loadToken) { + this._setTexture(loadedTexture); + } + } catch (err) { + console.warn('[patchmap:source] failed to load', source, err); + if (currentToken === this._loadToken) { + this._setTexture(Texture.EMPTY); + } + } + } + + _setTexture(texture) { + const metadata = texture?.metadata?.slice ?? {}; + if (this.sprite) { + this.sprite.texture = texture; + } else { + Object.assign(this, { texture, ...metadata }); + } + + // Hook for post-texture-loading actions + if (typeof this._onTextureApplied === 'function') { + this._onTextureApplied(texture); + } } }; MixedClass.registerHandler( diff --git a/src/display/mixins/TextLayoutable.js b/src/display/mixins/TextLayoutable.js new file mode 100644 index 00000000..ef81e0f4 --- /dev/null +++ b/src/display/mixins/TextLayoutable.js @@ -0,0 +1,105 @@ +import { DEFAULT_AUTO_FONT_RANGE, UPDATE_STAGES } from './constants'; +import { getLayoutContext, splitText } from './utils'; + +const KEYS = ['text', 'split', 'style', 'margin', 'size']; + +export const TextLayoutable = (superClass) => { + const MixedClass = class extends superClass { + _applyTextLayout(relevantChanges) { + const { style, margin } = relevantChanges; + const visual = this.bitmapText || this; + + // 1. Recover original text if previously truncated + if (this._isTruncated) { + visual.text = + this._fullText || + splitText(this.props.text || '', this.props.split || 0); + this._isTruncated = false; + } + + const bounds = this._getLayoutBounds(visual, margin); + + // 2. Word Wrap: Auto + if (style?.wordWrapWidth === 'auto') { + visual.style.wordWrapWidth = bounds.width; + } + + // 3. Font Size: Auto + if (style?.fontSize === 'auto') { + const range = style.autoFont ?? DEFAULT_AUTO_FONT_RANGE; + this._setAutoFontSize(visual, bounds, range); + } + + // 4. Overflow Handling + if (style?.overflow && style.overflow !== 'visible') { + this._applyOverflow(visual, bounds, style.overflow); + } + } + + _getLayoutBounds(visual, margin) { + const { contentWidth, contentHeight } = getLayoutContext(visual); + return { + width: Math.max(0, contentWidth - (margin.left + margin.right)), + height: Math.max(0, contentHeight - (margin.top + margin.bottom)), + }; + } + + _setAutoFontSize(visual, bounds, range) { + let { min, max } = range; + let bestSize = range.min; + + while (min <= max) { + const mid = Math.floor((min + max) / 2); + visual.style.fontSize = mid; + const metrics = visual.getLocalBounds(); + + if (metrics.width <= bounds.width && metrics.height <= bounds.height) { + bestSize = mid; + min = mid + 1; + } else { + max = mid - 1; + } + } + visual.style.fontSize = bestSize; + } + + _applyOverflow(visual, bounds, overflowType) { + const metrics = visual.getLocalBounds(); + if (metrics.width <= bounds.width && metrics.height <= bounds.height) { + return; + } + + const fullText = visual.text; + const suffix = overflowType === 'ellipsis' ? '…' : ''; + let low = 0; + let high = fullText.length; + let bestText = ''; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = + fullText.slice(0, mid) + (mid < fullText.length ? suffix : ''); + + visual.text = candidate; + const b = visual.getLocalBounds(); + + if (b.width <= bounds.width && b.height <= bounds.height) { + bestText = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + visual.text = bestText; + this._isTruncated = bestText !== fullText; + } + }; + + MixedClass.registerHandler( + KEYS, + MixedClass.prototype._applyTextLayout, + UPDATE_STAGES.LAYOUT, + ); + + return MixedClass; +}; diff --git a/src/display/mixins/Textable.js b/src/display/mixins/Textable.js index 6db1ae49..8d00af0a 100644 --- a/src/display/mixins/Textable.js +++ b/src/display/mixins/Textable.js @@ -7,8 +7,14 @@ export const Textable = (superClass) => { const MixedClass = class extends superClass { _applyText(relevantChanges) { const { text, split } = relevantChanges; - this._fullText = splitText(text, split); - this.text = this._fullText; + const fullText = splitText(text, split); + + if (this.bitmapText) { + this.bitmapText.text = fullText; + } else { + this.text = fullText; + } + this._fullText = fullText; this._isTruncated = false; } }; diff --git a/src/display/mixins/Textstyleable.js b/src/display/mixins/Textstyleable.js index dba9fa09..8b2b016f 100644 --- a/src/display/mixins/Textstyleable.js +++ b/src/display/mixins/Textstyleable.js @@ -1,71 +1,55 @@ import { TextStyle } from 'pixi.js'; import { getColor } from '../../utils/get'; -import { - DEFAULT_AUTO_FONT_RANGE, - FONT_WEIGHT, - UPDATE_STAGES, -} from './constants'; -import { getLayoutContext, splitText } from './utils'; +import { FONT_WEIGHT, UPDATE_STAGES } from './constants'; -const KEYS = ['text', 'split', 'style', 'margin']; +const KEYS = ['style']; export const Textstyleable = (superClass) => { const MixedClass = class extends superClass { _applyTextstyle(relevantChanges, options) { - const { style, margin } = relevantChanges; - const { theme } = this.context; + const { style } = relevantChanges; + if (!style) return; + + const { theme } = this.store; + const visual = this.bitmapText || this; if (options.mergeStrategy === 'replace') { - this.style = new TextStyle(); + visual.style = new TextStyle(); } - if (this._isTruncated) { - this.text = - this._fullText ?? - splitText(this.props.text || '', this.props.split || 0); - this._isTruncated = false; + // 1. Optimized Font Styling + if ('fontFamily' in style || 'fontWeight' in style) { + visual.style.fontWeight = this._getFontWeight(style.fontWeight, visual); + visual.style.fontFamily = this._getFontFamily(style.fontFamily, visual); } + // 2. Apply Other Style Properties + const bypassKeys = ['fontFamily', 'fontWeight']; for (const key in style) { - if ( - (key === 'fontSize' || key === 'wordWrapWidth') && - style[key] === 'auto' - ) { - continue; - } + if (bypassKeys.includes(key)) continue; - if (key === 'fontFamily' || key === '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); + if (key === 'fill') { + visual.style[key] = getColor(theme, style.fill); } else { - this.style[key] = style[key]; + visual.style[key] = style[key]; } } - - if (style.wordWrapWidth === 'auto') { - setAutoWordWrapWidth(this, margin); - } - - if (style.fontSize === 'auto') { - const range = style.autoFont ?? DEFAULT_AUTO_FONT_RANGE; - setAutoFontSize(this, margin, range); - } - - if (style.overflow && style.overflow !== 'visible') { - applyOverflow(this, margin, style.overflow, this.text); - } } - _getFontFamily(value) { - return `${value ?? this.style.fontFamily.split(' ')[0]} ${FONT_WEIGHT.STRING[this.style.fontWeight]}`; + _getFontFamily(value, visual) { + const current = visual.style.fontFamily || ''; + const baseFamily = current.split(' ')[0] || 'Arial'; + const family = value ?? baseFamily; + const weightStr = + FONT_WEIGHT.STRING[visual.style.fontWeight] || 'regular'; + return `${family} ${weightStr}`.trim(); } - _getFontWeight(value) { - return FONT_WEIGHT.NUMBER[value ?? this.style.fontWeight]; + _getFontWeight(value, visual) { + return FONT_WEIGHT.NUMBER[value ?? visual.style.fontWeight]; } }; + MixedClass.registerHandler( KEYS, MixedClass.prototype._applyTextstyle, @@ -73,71 +57,3 @@ export const Textstyleable = (superClass) => { ); return MixedClass; }; - -const setAutoFontSize = (object, margin, range) => { - const { contentWidth, contentHeight } = getContentSize(object, margin); - - let { min: minSize, max: maxSize } = range; - let bestFitSize = range.min; - while (minSize <= maxSize) { - const fontSize = Math.floor((minSize + maxSize) / 2); - object.style.fontSize = fontSize; - - const metrics = object.getLocalBounds(); - if (metrics.width <= contentWidth && metrics.height <= contentHeight) { - bestFitSize = fontSize; - minSize = fontSize + 1; - } else { - maxSize = fontSize - 1; - } - } - object.style.fontSize = bestFitSize; -}; - -const setAutoWordWrapWidth = (object, margin) => { - const { contentWidth } = getContentSize(object, margin); - object.style.wordWrapWidth = contentWidth; -}; - -const applyOverflow = (object, margin, overflowType, fullText) => { - const { contentWidth, contentHeight } = getContentSize(object, margin); - const bounds = object.getLocalBounds(); - - if (bounds.width <= contentWidth && bounds.height <= contentHeight) { - return; - } - - const suffix = overflowType === 'ellipsis' ? '…' : ''; - let low = 0; - let high = fullText.length; - let bestFitText = ''; - - while (low <= high) { - const mid = Math.floor((low + high) / 2); - const sliced = fullText.slice(0, mid); - const candidate = mid === fullText.length ? sliced : sliced + suffix; - - if (doesFit(candidate)) { - bestFitText = candidate; - low = mid + 1; - } else { - high = mid - 1; - } - } - object.text = bestFitText; - object._isTruncated = bestFitText !== fullText; - - function doesFit(text) { - object.text = text; - const b = object.getLocalBounds(); - return b.width <= contentWidth && b.height <= contentHeight; - } -}; - -const getContentSize = (object, margin) => { - const { contentWidth, contentHeight } = getLayoutContext(object); - return { - contentWidth: contentWidth - (margin.left + margin.right), - contentHeight: contentHeight - (margin.top + margin.bottom), - }; -}; diff --git a/src/display/mixins/Tintable.js b/src/display/mixins/Tintable.js index f7e7cbe1..8bc9c20e 100644 --- a/src/display/mixins/Tintable.js +++ b/src/display/mixins/Tintable.js @@ -7,7 +7,7 @@ export const Tintable = (superClass) => { const MixedClass = class extends superClass { _applyTint(relevantChanges) { const { tint } = relevantChanges; - this.tint = getColor(this.context.theme, tint); + this.tint = getColor(this.store.theme, tint); } }; MixedClass.registerHandler( diff --git a/src/display/mixins/linksable.js b/src/display/mixins/linksable.js index c0f4d8a3..23ff547d 100644 --- a/src/display/mixins/linksable.js +++ b/src/display/mixins/linksable.js @@ -9,15 +9,15 @@ export const Linksable = (superClass) => { super(options); this._boundOnObjectTransformed = this._onObjectTransformed.bind(this); - this.context?.viewport?.on( + this.store?.viewport?.on( 'object_transformed', this._boundOnObjectTransformed, ); } destroy(options) { - if (this.context?.viewport) { - this.context?.viewport?.off( + if (this.store?.viewport) { + this.store?.viewport?.off( 'object_transformed', this._boundOnObjectTransformed, ); @@ -44,7 +44,7 @@ export const Linksable = (superClass) => { _applyLinks(relevantChanges) { const { links } = relevantChanges; - this.linkedObjects = uniqueLinked(this.context.viewport, links); + this.linkedObjects = uniqueLinked(this.store.viewport, links); this._renderDirty = true; } }; diff --git a/src/display/mixins/utils.js b/src/display/mixins/utils.js index 9d683427..6c2c5c7b 100644 --- a/src/display/mixins/utils.js +++ b/src/display/mixins/utils.js @@ -109,8 +109,8 @@ export const validateAndPrepareChanges = (currentElements, changes, schema) => { }; /** - * Calculates the layout context of a component (content area size, padding, etc). - * @param {PIXI.DisplayObject} component - The component for which to calculate the layout context + * Calculates the layout store of a component (content area size, padding, etc). + * @param {PIXI.DisplayObject} component - The component for which to calculate the layout store * @returns {{parentWidth: number, parentHeight: number, contentWidth: number, contentHeight: number, parentPadding: object}} */ export const getLayoutContext = (component) => { diff --git a/src/display/update.js b/src/display/update.js index 50d6f332..5531b835 100644 --- a/src/display/update.js +++ b/src/display/update.js @@ -42,13 +42,8 @@ export const update = (viewport, opts) => { }; const applyRelativeTransform = (element, changes) => { - const { x, y, rotation, angle } = changes; - - Object.assign(changes, { - x: element.x + (typeof x === 'number' ? x : 0), - y: element.y + (typeof y === 'number' ? y : 0), - rotation: element.rotation + (typeof rotation === 'number' ? rotation : 0), - angle: element.angle + (typeof angle === 'number' ? angle : 0), + ['x', 'y', 'rotation', 'angle'].forEach((key) => { + if (typeof changes[key] === 'number') changes[key] += element[key]; }); return changes; }; diff --git a/src/events/StateManager.js b/src/events/StateManager.js index e32321e3..a4dfb915 100644 --- a/src/events/StateManager.js +++ b/src/events/StateManager.js @@ -16,7 +16,7 @@ import { PROPAGATE_EVENT } from './states/State'; */ export default class StateManager extends WildcardEventEmitter { /** @private */ - #context; + #store; /** @private */ #stateRegistry = new Map(); /** @private */ @@ -29,12 +29,12 @@ export default class StateManager extends WildcardEventEmitter { #eventListeners = {}; /** - * Initializes the StateManager with a context. - * @param {object} context - The context in which the StateManager operates, typically containing the viewport and other global instances. + * Initializes the StateManager with a store. + * @param {object} store - The store in which the StateManager operates, typically containing the viewport and other global instances. */ - constructor(context) { + constructor(store) { super(); - this.#context = context; + this.#store = store; } /** @@ -131,7 +131,7 @@ export default class StateManager extends WildcardEventEmitter { } this.#stateStack.push(instance); - instance.enter?.(this.#context, ...args); + instance.enter?.(this.#store, ...args); this.emit('state:pushed', { pushedState: instance, @@ -225,7 +225,7 @@ export default class StateManager extends WildcardEventEmitter { } this.#modifierState = instance; - this.#modifierState.enter?.(this.#context, ...args); + this.#modifierState.enter?.(this.#store, ...args); this.emit('modifier:activated', { modifierState: this.#modifierState, @@ -256,7 +256,7 @@ export default class StateManager extends WildcardEventEmitter { * @param {string[]} [eventNames=[]] - The names of the events to ensure listeners for (e.g., 'onpointerdown'). */ _ensureEventListeners(eventNames = []) { - const viewport = this.#context.viewport; + const viewport = this.#store.viewport; const dispatch = (eventName, event) => { if (this.#modifierState) { this.#modifierState[eventName]?.(event); @@ -303,7 +303,7 @@ export default class StateManager extends WildcardEventEmitter { if (pixiEventName.startsWith('key')) { window.removeEventListener(pixiEventName, listener); } else { - this.#context.viewport.off(pixiEventName, listener); + this.#store.viewport.off(pixiEventName, listener); } } this.#stateRegistry.forEach((stateDef) => { diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index a37e1f63..e4604265 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -103,14 +103,14 @@ export default class SelectionState extends State { _lastPaintPoint = null; /** - * Enters the selection state with a given context and configuration. + * Enters the selection state with a given store and configuration. * @param {...*} args - Additional arguments passed to the state. */ enter(...args) { super.enter(...args); const [_, config] = args; this.config = deepMerge(defaultConfig, config); - this.viewport = this.context.viewport; + this.viewport = this.store.viewport; this.viewport.addChild(this._selectionBox); } @@ -237,8 +237,8 @@ export default class SelectionState extends State { }); } - onpointerleave(e) { - this.onpointerup(e); + onpointerleave() { + this.#clear({ state: true, selectionBox: true, gesture: true }); } #processClick(e, callback) { @@ -307,7 +307,7 @@ export default class SelectionState extends State { */ #getSelectionAncestors() { const selectionAncestors = new Set(); - for (const element of this.context.transformer.elements) { + for (const element of this.store.transformer.elements) { let current = element.parent; while (current) { if (current.type === 'canvas') break; diff --git a/src/events/states/State.js b/src/events/states/State.js index 5d96b8cc..baaa41e8 100644 --- a/src/events/states/State.js +++ b/src/events/states/State.js @@ -33,12 +33,12 @@ export default class State { constructor(name) { /** - * A reference to the shared context object provided by the StateManager. - * This context typically contains references to global objects like the viewport, + * A reference to the shared store object provided by the StateManager. + * This store typically contains references to global objects like the viewport, * the application instance, etc. It is null until `enter()` is called. * @type {object | null} */ - this.context = null; + this.store = null; this.key = name; } @@ -48,11 +48,11 @@ export default class State { * adding temporary scene elements. * A new AbortController is created here for the state's lifecycle. * - * @param {object} context - The shared application context from the StateManager. + * @param {object} store - The shared application store from the StateManager. * @param {...*} args - Additional arguments passed to the state. */ - enter(context, ...args) { - this.context = context; + enter(store, ...args) { + this.store = store; this.args = args; this.abortController = new AbortController(); } diff --git a/src/init.js b/src/init.js index 9b49f22f..3b218b72 100644 --- a/src/init.js +++ b/src/init.js @@ -60,7 +60,7 @@ export const initApp = async (app, opts = {}) => { app.renderer.uid = uid(); }; -export const initViewport = (app, opts = {}, context) => { +export const initViewport = (app, opts = {}, store) => { const options = deepMerge( { ...DEFAULT_INIT_OPTIONS.viewport, @@ -70,8 +70,8 @@ export const initViewport = (app, opts = {}, context) => { }, opts, ); - const viewport = new BaseViewport({ ...options, context }); - context.viewport = viewport; + const viewport = new BaseViewport({ ...options, store }); + store.viewport = viewport; viewport.app = app; viewport.events = {}; viewport.plugin = { diff --git a/src/patchmap.js b/src/patchmap.js index 9a1ce682..e8fdef2a 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -128,12 +128,12 @@ class Patchmap extends WildcardEventEmitter { this._app = new Application(); await initApp(this.app, { resizeTo: element, ...appOptions }); - const context = { + const store = { undoRedoManager: this.undoRedoManager, theme: this.theme, animationContext: this.animationContext, }; - this.viewport = initViewport(this.app, viewportOptions, context); + this.viewport = initViewport(this.app, viewportOptions, store); await initAsset(assetsOptions); initCanvas(element, this.app); @@ -182,7 +182,7 @@ class Patchmap extends WildcardEventEmitter { const validatedData = validateMapData(processedData); if (isValidationError(validatedData)) throw validatedData; - const context = { + const store = { viewport: this.viewport, undoRedoManager: this.undoRedoManager, theme: this.theme, @@ -193,7 +193,7 @@ class Patchmap extends WildcardEventEmitter { this.undoRedoManager.clear(); this.animationContext.revert(); event.removeAllEvent(this.viewport); - draw(context, validatedData); + draw(store, validatedData); // 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 diff --git a/src/tests/render/components/Icon.test.js b/src/tests/render/components/Icon.test.js index 2035689e..85c2f6c5 100644 --- a/src/tests/render/components/Icon.test.js +++ b/src/tests/render/components/Icon.test.js @@ -80,10 +80,6 @@ describe('Icon Component Tests', () => { }); const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0]; - expect(consoleSpy).toHaveBeenCalledWith( - 'PixiJS Warning: ', - '[Assets] Asset id unregistered-icon-asset was not found in the Cache', - ); expect(icon.texture).toBeDefined(); expect(icon.props.source).toBe(unregisteredSource); consoleSpy.mockRestore(); diff --git a/src/tests/undo-redo/Update.test.js b/src/tests/undo-redo/Update.test.js new file mode 100644 index 00000000..af9ff385 --- /dev/null +++ b/src/tests/undo-redo/Update.test.js @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { setupPatchmapTests } from '../render/patchmap.setup'; + +describe('Undo/Redo: UpdateCommand Attrs Preservation', () => { + const { getPatchmap } = setupPatchmapTests(); + + it('should preserve other attributes when undoing a partial attrs update', async () => { + const patchmap = getPatchmap(); + + // 1. Initial data with multiple attributes + const initialData = [ + { + type: 'item', + id: 'item-1', + label: 'item-1', + components: [ + { + type: 'background', + id: 'bg-component', + source: { type: 'rect', fill: 'white' }, + }, + ], + size: { width: 50, height: 50 }, + attrs: { x: 100, y: 100, width: 50, height: 50 }, + }, + ]; + + patchmap.draw(initialData); + const item = patchmap.selector('$..[?(@.id=="item-1")]')[0]; + + // Verify initial state + expect(item.props.attrs).toEqual({ x: 100, y: 100, width: 50, height: 50 }); + + // 2. Update only 'x' attribute with history: true + patchmap.update({ + path: '$..[?(@.id=="item-1")]', + changes: { attrs: { x: 200 } }, + history: true, + }); + + // Verify update applied correctly + expect(item.props.attrs.x).toBe(200); + expect(item.props.attrs.y).toBe(100); + expect(item.props.attrs.width).toBe(50); + expect(item.props.attrs.height).toBe(50); + + // 3. Undo the change + patchmap.undoRedoManager.undo(); + + // 4. Verify that ALL attributes are restored to original state + // Before the fix, y, width, and height would have been lost because undo used 'replace' strategy + // with a slice that only contained 'x'. + expect(item.props.attrs).toEqual({ x: 100, y: 100, width: 50, height: 50 }); + + // 5. Redo and verify + patchmap.undoRedoManager.redo(); + expect(item.props.attrs.x).toBe(200); + expect(item.props.attrs.y).toBe(100); + expect(item.props.attrs.width).toBe(50); + expect(item.props.attrs.height).toBe(50); + }); + + it('should correctly capture initial instance values if not present in props', async () => { + const patchmap = getPatchmap(); + + // Background normally has x, y in props, but let's test a case where something might be missing + const initialData = [ + { + type: 'item', + id: 'item-2', + label: 'item-2', + size: { width: 50, height: 50 }, + attrs: { width: 50, height: 50 }, // x, y (0,0) implied but not in props + }, + ]; + + patchmap.draw(initialData); + const item = patchmap.selector('$..[?(@.id=="item-2")]')[0]; + + // In this case, props.attrs only has width/height + expect(item.props.attrs).not.toHaveProperty('x'); + expect(item.x).toBe(0); + + // Update x + patchmap.update({ + path: '$..[?(@.id=="item-2")]', + changes: { attrs: { x: 100 } }, + history: true, + }); + + expect(item.x).toBe(100); + + // Undo + patchmap.undoRedoManager.undo(); + + // Verify x is back to 0 + expect(item.x).toBe(0); + + // And props.attrs should now ideally have captured x: 0 + expect(item.props.attrs.x).toBe(0); + }); +}); diff --git a/src/transformer/Transformer.js b/src/transformer/Transformer.js index 01c25105..a1dc03fc 100644 --- a/src/transformer/Transformer.js +++ b/src/transformer/Transformer.js @@ -266,7 +266,7 @@ export default class Transformer extends Container { /** * Marks the transformer as dirty, scheduling a redraw on the next frame. - * This method is an arrow function to preserve `this` context when used as an event listener. + * This method is an arrow function to preserve `this` store when used as an event listener. */ update = () => { this._renderDirty = true; diff --git a/src/utils/get.js b/src/utils/get.js index 969f5d91..fa6005ea 100644 --- a/src/utils/get.js +++ b/src/utils/get.js @@ -21,8 +21,8 @@ export const getColor = (theme, color) => { export const getViewport = (displayObject) => { if (!displayObject) return null; - if (displayObject?.context?.viewport) { - return displayObject.context.viewport; + if (displayObject?.store?.viewport) { + return displayObject.store.viewport; } if (displayObject instanceof Viewport) { return displayObject;