From 8e1b42a91cc11188ebda30825831c5b6a0d8390f Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 3 Dec 2025 16:17:04 +0900 Subject: [PATCH 1/5] feat: add textstyle overflow --- biome.json | 3 +- src/display/components/Text.js | 2 +- src/display/data-schema/primitive-schema.js | 7 +-- src/display/mixins/Base.js | 4 +- src/display/mixins/Textable.js | 16 ++----- src/display/mixins/Textstyleable.js | 51 ++++++++++++++++++++- src/display/mixins/utils.js | 11 +++++ src/utils/diff/create-patch.js | 5 +- src/utils/diff/is-same.js | 5 +- 9 files changed, 76 insertions(+), 28 deletions(-) diff --git a/biome.json b/biome.json index e6676e71..26959f1a 100644 --- a/biome.json +++ b/biome.json @@ -21,7 +21,8 @@ "recommended": true, "suspicious": { "noDebugger": "error", - "noImportAssign": "error" + "noImportAssign": "error", + "useIterableCallbackReturn": "off" }, "correctness": { "noUnusedVariables": "error", diff --git a/src/display/components/Text.js b/src/display/components/Text.js index d03061f8..961d168b 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -9,7 +9,7 @@ import { Tintable } from '../mixins/Tintable'; import { mixins } from '../mixins/utils'; const EXTRA_KEYS = { - PLACEMENT: ['text', 'split'], + PLACEMENT: ['text', 'style', 'split'], }; const ComposedText = mixins( diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index 5ca8c694..acd4492d 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -6,13 +6,13 @@ import { DEFAULT_TEXTSTYLE, } from '../mixins/constants'; import { - HslColor, HslaColor, - HsvColor, + HslColor, HsvaColor, + HsvColor, Color as PixiColor, - RgbColor, RgbaColor, + RgbColor, } from './color-schema'; /** @@ -244,6 +244,7 @@ export const TextStyle = z 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'), }) .passthrough() .default({}); diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index e896e354..a6d6e8f4 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -49,7 +49,7 @@ export const Base = (superClass) => { } static registerHandler(keys, handler, stage) { - if (!Object.prototype.hasOwnProperty.call(this, '_handlerRegistry')) { + if (!Object.hasOwn(this, '_handlerRegistry')) { this._handlerRegistry = new Map(this._handlerRegistry); this._handlerMap = new Map(this._handlerMap); } @@ -116,7 +116,7 @@ export const Base = (superClass) => { this.constructor._handlerRegistry.get(handler).keys; const fullPayload = {}; keysForHandler.forEach((key) => { - if (Object.prototype.hasOwnProperty.call(this.props, key)) { + if (Object.hasOwn(this.props, key)) { fullPayload[key] = this.props[key]; } }); diff --git a/src/display/mixins/Textable.js b/src/display/mixins/Textable.js index 6bb5c111..6db1ae49 100644 --- a/src/display/mixins/Textable.js +++ b/src/display/mixins/Textable.js @@ -1,4 +1,5 @@ import { UPDATE_STAGES } from './constants'; +import { splitText } from './utils'; const KEYS = ['text', 'split']; @@ -6,7 +7,9 @@ export const Textable = (superClass) => { const MixedClass = class extends superClass { _applyText(relevantChanges) { const { text, split } = relevantChanges; - this.text = splitText(text, split); + this._fullText = splitText(text, split); + this.text = this._fullText; + this._isTruncated = false; } }; MixedClass.registerHandler( @@ -16,14 +19,3 @@ export const Textable = (superClass) => { ); return MixedClass; }; - -const splitText = (text, split) => { - if (split === 0) { - return text; - } - let result = ''; - for (let i = 0; i < text.length; i += split) { - result += `${text.slice(i, i + split)}\n`; - } - return result.trim(); -}; diff --git a/src/display/mixins/Textstyleable.js b/src/display/mixins/Textstyleable.js index 72326650..e8a74c49 100644 --- a/src/display/mixins/Textstyleable.js +++ b/src/display/mixins/Textstyleable.js @@ -5,7 +5,7 @@ import { FONT_WEIGHT, UPDATE_STAGES, } from './constants'; -import { getLayoutContext } from './utils'; +import { getLayoutContext, splitText } from './utils'; const KEYS = ['text', 'split', 'style', 'margin']; @@ -19,6 +19,13 @@ export const Textstyleable = (superClass) => { this.style = new TextStyle(); } + if (this._isTruncated) { + this.text = + this._fullText ?? + splitText(this.props.text || '', this.props.split || 0); + this._isTruncated = false; + } + for (const key in style) { if ( (key === 'fontSize' || key === 'wordWrapWidth') && @@ -45,6 +52,13 @@ export const Textstyleable = (superClass) => { const range = style.autoFont ?? DEFAULT_AUTO_FONT_RANGE; setAutoFontSize(this, margin, range); } + + if (style.overflow && style.overflow !== 'visible') { + const fullText = + this._fullText ?? + splitText(this.props.text || '', this.props.split || 0); + applyOverflow(this, margin, style.overflow, fullText); + } } _getFontFamily(value) { @@ -88,6 +102,41 @@ const setAutoWordWrapWidth = (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 { diff --git a/src/display/mixins/utils.js b/src/display/mixins/utils.js index 0c52198e..9d683427 100644 --- a/src/display/mixins/utils.js +++ b/src/display/mixins/utils.js @@ -149,3 +149,14 @@ export const getLayoutContext = (component) => { parentPadding, }; }; + +export const splitText = (text, split) => { + if (!split || split === 0) { + return text; + } + let result = ''; + for (let i = 0; i < text.length; i += split) { + result += `${text.slice(i, i + split)}\n`; + } + return result.trim(); +}; diff --git a/src/utils/diff/create-patch.js b/src/utils/diff/create-patch.js index 7d12dae2..e5dfa802 100644 --- a/src/utils/diff/create-patch.js +++ b/src/utils/diff/create-patch.js @@ -15,10 +15,7 @@ export const createPatch = (obj1, obj2) => { const result = {}; for (const key of Object.keys(obj2)) { - if ( - !Object.prototype.hasOwnProperty.call(obj1, key) || - !isSame(obj1[key], obj2[key]) - ) { + if (!Object.hasOwn(obj1, key) || !isSame(obj1[key], obj2[key])) { const patchValue = createPatch(obj1[key], obj2[key]); result[key] = patchValue; } diff --git a/src/utils/diff/is-same.js b/src/utils/diff/is-same.js index d324a312..c10b6427 100644 --- a/src/utils/diff/is-same.js +++ b/src/utils/diff/is-same.js @@ -58,10 +58,7 @@ function _isSame(a, b, visited) { 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) - ) { + if (!Object.hasOwn(b, key) || !_isSame(a[key], b[key], visited)) { return false; } } From a6f11125e1456141820b3945529f5018b9bb5090 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 3 Dec 2025 16:18:58 +0900 Subject: [PATCH 2/5] fix data.d.ts --- src/display/data-schema/data.d.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts index 6dc7da5a..0e52a719 100644 --- a/src/display/data-schema/data.d.ts +++ b/src/display/data-schema/data.d.ts @@ -532,6 +532,15 @@ export interface TextStyle { */ wordWrapWidth?: number | 'auto'; + /** + * Determines how to handle text that overflows its content area. + * - 'visible': Text flows outside the bounds (default). + * - 'hidden': Text exceeding the bounds is clipped. + * - 'ellipsis': Text exceeding the bounds is replaced with '...'. + * @default 'visible' + */ + overflow?: 'visible' | 'hidden' | 'ellipsis'; + /** * Allows any other properties, similar to PIXI.TextStyleOptions. * This provides flexibility for standard text styling. From 5bef38e3230f464e7620fdda6e22f0620134cf44 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 3 Dec 2025 17:18:27 +0900 Subject: [PATCH 3/5] add test --- .../data-schema/primitive-schema.test.js | 4 +- src/tests/render/components/Text.test.js | 388 ++++++++++++++++++ 2 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 src/tests/render/components/Text.test.js diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js index 8f517c11..57d8f61d 100644 --- a/src/display/data-schema/primitive-schema.test.js +++ b/src/display/data-schema/primitive-schema.test.js @@ -8,11 +8,11 @@ import { Margin, Placement, PxOrPercentSize, + pxOrPercentSchema, RelationsStyle, Size, TextStyle, TextureStyle, - pxOrPercentSchema, } from './primitive-schema'; vi.mock('../../utils/uuid'); @@ -359,6 +359,7 @@ describe('Primitive Schema Tests', () => { fontFamily: 'FiraCode', fontSize: 16, fontWeight: 400, + overflow: 'visible', }); }); @@ -369,6 +370,7 @@ describe('Primitive Schema Tests', () => { fontFamily: 'Arial', fontWeight: 'bold', fill: 'red', + overflow: 'visible', }); }); }); diff --git a/src/tests/render/components/Text.test.js b/src/tests/render/components/Text.test.js new file mode 100644 index 00000000..d3a64a28 --- /dev/null +++ b/src/tests/render/components/Text.test.js @@ -0,0 +1,388 @@ +import gsap from 'gsap'; +import { describe, expect, it } from 'vitest'; +import { setupPatchmapTests } from '../patchmap.setup'; + +describe('Text Component Tests', () => { + const { getPatchmap } = setupPatchmapTests(); + + const itemWithText = { + type: 'item', + id: 'item-with-text', + size: { width: 200, height: 100 }, + components: [ + { + type: 'text', + id: 'text-1', + text: 'Hello World', + style: { fontSize: 20, fill: 'black' }, + }, + ], + }; + + it('should render a text component with initial properties', () => { + const patchmap = getPatchmap(); + patchmap.draw([itemWithText]); + gsap.exportRoot().totalProgress(1); + + const text = patchmap.selector('$..[?(@.id=="text-1")]')[0]; + expect(text).toBeDefined(); + expect(text.text).toBe('Hello World'); + // Note: PIXI BitmapText style handling might differ, checking basic props + expect(text.props.style.fontSize).toBe(20); + expect(text.props.style.fill).toBe('black'); + }); + + it('should update text content', () => { + const patchmap = getPatchmap(); + patchmap.draw([itemWithText]); + gsap.exportRoot().totalProgress(1); + + const text = patchmap.selector('$..[?(@.id=="text-1")]')[0]; + expect(text.text).toBe('Hello World'); + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { text: 'Updated Text' }, + }); + + expect(text.text).toBe('Updated Text'); + }); + + it('should update style properties', () => { + const patchmap = getPatchmap(); + patchmap.draw([itemWithText]); + gsap.exportRoot().totalProgress(1); + + const text = patchmap.selector('$..[?(@.id=="text-1")]')[0]; + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { style: { fontSize: 30, fill: 'red' } }, + }); + + expect(text.props.style.fontSize).toBe(30); + expect(text.props.style.fill).toBe('red'); + }); + + it('should update tint', () => { + const patchmap = getPatchmap(); + patchmap.draw([itemWithText]); + gsap.exportRoot().totalProgress(1); + + const text = patchmap.selector('$..[?(@.id=="text-1")]')[0]; + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { tint: 0xff0000 }, + }); + + expect(text.tint).toBe(0xff0000); + }); + + describe('Placement and Layout', () => { + const layoutTestCases = [ + { + description: 'center placement', + itemSize: { width: 200, height: 100 }, + textPlacement: 'center', + validate: (text, itemSize) => { + expect(text.x + text.width / 2).toBeCloseTo(itemSize.width / 2); + expect(text.y + text.height / 2).toBeCloseTo(itemSize.height / 2); + }, + }, + { + description: 'top-left placement', + itemSize: { width: 200, height: 100 }, + textPlacement: 'left-top', + validate: (text) => { + expect(text.x).toBe(0); + expect(text.y).toBe(0); + }, + }, + { + description: 'bottom-right placement', + itemSize: { width: 200, height: 100 }, + textPlacement: 'right-bottom', + validate: (text, itemSize) => { + expect(text.x + text.width).toBeCloseTo(itemSize.width); + expect(text.y + text.height).toBeCloseTo(itemSize.height); + }, + }, + ]; + + it.each(layoutTestCases)( + 'should correctly position to $description', + ({ itemSize, textPlacement, validate }) => { + const patchmap = getPatchmap(); + const testItem = { + type: 'item', + id: 'test-item-layout', + size: itemSize, + components: [ + { + type: 'text', + id: 'text-layout', + text: 'Layout', // 텍스트가 있어야 너비가 생김 + placement: textPlacement, + }, + ], + }; + patchmap.draw([testItem]); + gsap.exportRoot().totalProgress(1); + + const text = patchmap.selector('$..[?(@.id=="text-layout")]')[0]; + validate(text, itemSize); + }, + ); + }); + + it('should correctly split text based on the "split" property', () => { + const patchmap = getPatchmap(); + const testItem = { + type: 'item', + id: 'test-item-split', + size: { width: 200, height: 100 }, + components: [ + { type: 'text', id: 'text-split', text: '123456', split: 0 }, + ], + }; + patchmap.draw([testItem]); + gsap.exportRoot().totalProgress(1); + + const text = patchmap.selector('$..[?(@.id=="text-split")]')[0]; + expect(text.text).toBe('123456'); + + patchmap.update({ + path: '$..[?(@.id=="text-split")]', + changes: { split: 2 }, + }); + expect(text.text).toBe('12\n34\n56'); + + patchmap.update({ + path: '$..[?(@.id=="text-split")]', + changes: { split: 3 }, + }); + expect(text.text).toBe('123\n456'); + + patchmap.update({ + path: '$..[?(@.id=="text-split")]', + changes: { text: 'abcdef' }, + }); + expect(text.text).toBe('abc\ndef'); + + patchmap.update({ + path: '$..[?(@.id=="text-split")]', + changes: { split: 0 }, + }); + expect(text.text).toBe('abcdef'); + }); + + describe('Auto Font Sizing', () => { + it('should adjust font size within min/max range to fit the container', () => { + const patchmap = getPatchmap(); + const itemSize = { width: 200, height: 50 }; + + const testItem = { + type: 'item', + id: 'item-autofont', + size: itemSize, + components: [ + { + type: 'text', + id: 'text-autofont', + text: 'A', + style: { + fontSize: 'auto', + autoFont: { min: 10, max: 100 }, + }, + }, + ], + }; + + patchmap.draw([testItem]); + gsap.exportRoot().totalProgress(1); + + const text = patchmap.selector('$..[?(@.id=="text-autofont")]')[0]; + + const initialFontSize = text.style.fontSize; + expect(initialFontSize).toBeGreaterThan(40); + expect(initialFontSize).toBeLessThanOrEqual(50); + + patchmap.update({ + path: '$..[?(@.id=="text-autofont")]', + changes: { text: 'Very Long Text String For Auto Font Test' }, + }); + + expect(text.style.fontSize).toBeLessThan(initialFontSize); + expect(text.style.fontSize).toBeGreaterThanOrEqual(10); // min 값 + + patchmap.update({ + path: '$..[?(@.id=="text-autofont")]', + changes: { + text: 'Very Very Very Very Very Very Very Long Text', // 더 긴 텍스트 + style: { autoFont: { min: 5, max: 100 } }, + }, + }); + expect(text.style.fontSize).toBeGreaterThanOrEqual(5); + }); + }); + + describe('Text Component Overflow Tests', () => { + const createTextItem = (props = {}) => ({ + type: 'item', + id: 'item-overflow', + size: { width: 100, height: 30 }, + components: [ + { + type: 'text', + id: 'text-1', + text: 'This is a very long text string', + style: { + fontSize: 20, + fontFamily: 'Arial', + ...props.style, + }, + ...props, + }, + ], + }); + + const getText = (patchmap) => { + return patchmap.selector('$..[?(@.id=="text-1")]')[0]; + }; + + it('should default to "visible" and not truncate text', () => { + const patchmap = getPatchmap(); + patchmap.draw([createTextItem()]); + + const text = getText(patchmap); + expect(text.text).toBe('This is a very long text string'); + expect(text.style.overflow).toBe('visible'); + }); + + it('should truncate text simply when overflow is "hidden"', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createTextItem({ style: { overflow: 'hidden', fontSize: 20 } }), + ]); + + const text = getText(patchmap); + expect(text.text.length).toBeLessThan( + 'This is a very long text string'.length, + ); + expect(text.text.endsWith('…')).toBe(false); + expect('This is a very long text string'.startsWith(text.text)).toBe( + true, + ); + }); + + it('should truncate text with ellipsis when overflow is "ellipsis"', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createTextItem({ style: { overflow: 'ellipsis', fontSize: 20 } }), + ]); + + const text = getText(patchmap); + expect(text.text.length).toBeLessThan( + 'This is a very long text string'.length, + ); + expect(text.text.endsWith('…')).toBe(true); + }); + + it('should update truncation when container size changes (re-calculation)', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + { + ...createTextItem({ style: { overflow: 'ellipsis', fontSize: 20 } }), + size: { width: 50, height: 30 }, + }, + ]); + + const text = getText(patchmap); + const initialText = text.text; + expect(initialText.endsWith('…')).toBe(true); + + patchmap.update({ + path: '$..[?(@.id=="item-overflow")]', + changes: { size: { width: 150, height: 30 } }, + }); + + const updatedText = getText(patchmap).text; + expect(updatedText.length).toBeGreaterThan(initialText.length); + }); + + it('should prioritize autoFont shrinking over overflow truncation', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createTextItem({ + text: 'Short Text', + style: { + fontSize: 'auto', + autoFont: { min: 10, max: 50 }, + overflow: 'ellipsis', + }, + }), + ]); + + const text = getText(patchmap); + expect(text.text).toBe('Short Text'); + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { text: 'Very very very very long text string here' }, + }); + + expect(text.style.fontSize).toBe(10); + expect(text.text.endsWith('…')).toBe(true); + }); + + it('should re-calculate overflow when margin increases (reducing content area)', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createTextItem({ + style: { overflow: 'ellipsis', fontSize: 20 }, + text: 'Hello World', + margin: 0, + }), + ]); + + patchmap.update({ + path: '$..[?(@.id=="item-overflow")]', + changes: { size: { width: 140, height: 40 } }, + }); + + const text = getText(patchmap); + expect(text.text).toBe('Hello World'); + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { margin: { left: 30, right: 30 } }, + }); + + expect(text.text).not.toBe('Hello World'); + expect(text.text.endsWith('…')).toBe(true); + }); + + it('should restore full text when switching overflow to "visible"', () => { + const patchmap = getPatchmap(); + const fullString = 'This is a very long text string'; + + patchmap.draw([ + createTextItem({ + text: fullString, + style: { overflow: 'ellipsis', fontSize: 20 }, + }), + ]); + + const text = getText(patchmap); + expect(text.text).not.toBe(fullString); + expect(text.text.endsWith('…')).toBe(true); + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { style: { overflow: 'visible' } }, + }); + + expect(text.text).toBe(fullString); + }); + }); +}); From 99d94453001d59a218671b8bc1587db48d296345 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 3 Dec 2025 17:38:53 +0900 Subject: [PATCH 4/5] fix --- src/display/mixins/Textstyleable.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/display/mixins/Textstyleable.js b/src/display/mixins/Textstyleable.js index e8a74c49..dba9fa09 100644 --- a/src/display/mixins/Textstyleable.js +++ b/src/display/mixins/Textstyleable.js @@ -54,10 +54,7 @@ export const Textstyleable = (superClass) => { } if (style.overflow && style.overflow !== 'visible') { - const fullText = - this._fullText ?? - splitText(this.props.text || '', this.props.split || 0); - applyOverflow(this, margin, style.overflow, fullText); + applyOverflow(this, margin, style.overflow, this.text); } } From 8195db68e94c7f17d4de5d17cba06cdb89f8c810 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 3 Dec 2025 17:43:24 +0900 Subject: [PATCH 5/5] fix --- biome.json | 3 +-- src/display/mixins/Base.js | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/biome.json b/biome.json index 26959f1a..e6676e71 100644 --- a/biome.json +++ b/biome.json @@ -21,8 +21,7 @@ "recommended": true, "suspicious": { "noDebugger": "error", - "noImportAssign": "error", - "useIterableCallbackReturn": "off" + "noImportAssign": "error" }, "correctness": { "noUnusedVariables": "error", diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index a6d6e8f4..b109f347 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -58,7 +58,9 @@ export const Base = (superClass) => { keys: new Set(), stage: stage ?? 99, }; - keys.forEach((key) => registration.keys.add(key)); + keys.forEach((key) => { + registration.keys.add(key); + }); this._handlerRegistry.set(handler, registration); registration.keys.forEach((key) => { if (!this._handlerMap.has(key)) this._handlerMap.set(key, new Set());