diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8a58a0ed9..8a7d54a0274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [next] +- test(): Add cursor animation testing and migrate some easy one to jest [#9829](https://github.com/fabricjs/fabric.js/pull/9829) - fix(Group, Controls): Fix interactive group actions when negative scaling is involved [#9811](https://github.com/fabricjs/fabric.js/pull/9811) - fix(): Replace 'hasOwn' with 'in' operator in typeAssertions check [#9812](https://github.com/fabricjs/fabric.js/pull/9812) diff --git a/src/shapes/IText/ITextBehavior.test.ts b/src/shapes/IText/ITextBehavior.test.ts index 6ac3deebada..85f6c563dfc 100644 --- a/src/shapes/IText/ITextBehavior.test.ts +++ b/src/shapes/IText/ITextBehavior.test.ts @@ -1,6 +1,8 @@ import { roundSnapshotOptions } from '../../../jest.extend'; import { IText } from './IText'; +import { ValueAnimation } from '../../util/animation/ValueAnimation'; + export function matchTextStateSnapshot(text: IText) { const { styles, @@ -90,3 +92,86 @@ describe('text imperative changes', () => { expect(iText.missingNewlineOffset(0)).toBe(1); }); }); + +describe('IText cursor animation snapshot', () => { + let currentAnimation: string[] = []; + const origCalculate = ValueAnimation.prototype.calculate; + beforeAll(() => { + jest + .spyOn(ValueAnimation.prototype, 'calculate') + .mockImplementation(function (timeElapsed: number) { + const value = origCalculate.call(this, timeElapsed); + currentAnimation.push(value.value.toFixed(3)); + return value; + }); + jest.useFakeTimers(); + }); + beforeEach(() => { + jest.runAllTimers(); + currentAnimation = []; + }); + afterAll(() => { + jest.resetAllMocks(); + jest.useRealTimers(); + }); + test('initDelayedCursor false - with delay', () => { + const iText = new IText('', { canvas: {} }); + iText.initDelayedCursor(); + jest.advanceTimersByTime(2000); + expect(currentAnimation).toMatchSnapshot(); + iText.abortCursorAnimation(); + }); + test('initDelayedCursor true - with NO delay', () => { + const iText = new IText('', { canvas: {} }); + iText.initDelayedCursor(true); + jest.advanceTimersByTime(2000); + expect(currentAnimation).toMatchSnapshot(); + iText.abortCursorAnimation(); + }); + test('selectionStart/selection end will abort animation', () => { + const iText = new IText('asd', { canvas: {} }); + iText.initDelayedCursor(true); + jest.advanceTimersByTime(160); + iText.selectionStart = 0; + iText.selectionEnd = 3; + jest.advanceTimersByTime(2000); + expect(currentAnimation).toMatchSnapshot(); + iText.abortCursorAnimation(); + }); + test('exiting from a canvas will abort animation', () => { + const iText = new IText('asd', { canvas: {} }); + iText.initDelayedCursor(true); + jest.advanceTimersByTime(160); + iText.canvas = undefined; + jest.advanceTimersByTime(2000); + expect(currentAnimation).toMatchSnapshot(); + iText.abortCursorAnimation(); + }); +}); + +describe('IText _tick', () => { + const _tickMock = jest.fn(); + beforeEach(() => { + _tickMock.mockClear(); + }); + test('enter Editing will call _tick', () => { + const iText = new IText('hello\nhello'); + jest.spyOn(iText, '_tick').mockImplementation(_tickMock); + iText.enterEditing(); + expect(_tickMock).toHaveBeenCalledWith(); + }); + test('mouse up will fire an animation restart with 0 delay if is a click', () => { + const iText = new IText('hello\nhello'); + jest.spyOn(iText, '_tick').mockImplementation(_tickMock); + iText.enterEditing(); + expect(_tickMock).toHaveBeenCalledWith(); + _tickMock.mockClear(); + iText.__lastSelected = true; + iText.mouseUpHandler({ + e: { + button: 0, + }, + }); + expect(_tickMock).toHaveBeenCalledWith(0); + }); +}); diff --git a/src/shapes/IText/ITextBehavior.ts b/src/shapes/IText/ITextBehavior.ts index 5009dc55f58..9514391eddc 100644 --- a/src/shapes/IText/ITextBehavior.ts +++ b/src/shapes/IText/ITextBehavior.ts @@ -134,13 +134,10 @@ export abstract class ITextBehavior< duration, delay, onComplete, - abort: () => { - return ( - !this.canvas || - // we do not want to animate a selection, only cursor - this.selectionStart !== this.selectionEnd - ); - }, + abort: () => + !this.canvas || + // we do not want to animate a selection, only cursor + this.selectionStart !== this.selectionEnd, onChange: (value) => { this._currentCursorOpacity = value; this.renderCursorOrSelection(); @@ -197,6 +194,10 @@ export abstract class ITextBehavior< } } + /** + * Restart tue cursor animation if either is in complete state ( between animations ) + * or if it never started before + */ restartCursorIfNeeded() { if ( [this._currentTickState, this._currentTickCompleteState].some( @@ -336,10 +337,11 @@ export abstract class ITextBehavior< } /** + * TODO fix: selectionStart set as 0 will be ignored? * Selects a word based on the index * @param {Number} selectionStart Index of a character */ - selectWord(selectionStart: number) { + selectWord(selectionStart?: number) { selectionStart = selectionStart || this.selectionStart; // search backwards const newSelectionStart = this.searchWordBoundary(selectionStart, -1), @@ -357,10 +359,11 @@ export abstract class ITextBehavior< } /** + * TODO fix: selectionStart set as 0 will be ignored? * Selects a line based on the index * @param {Number} selectionStart Index of a character */ - selectLine(selectionStart: number) { + selectLine(selectionStart?: number) { selectionStart = selectionStart || this.selectionStart; const newSelectionStart = this.findLineBoundaryLeft(selectionStart), newSelectionEnd = this.findLineBoundaryRight(selectionStart); diff --git a/src/shapes/IText/ITextKeyBehavior.test.ts b/src/shapes/IText/ITextKeyBehavior.test.ts new file mode 100644 index 00000000000..9420ae1ec32 --- /dev/null +++ b/src/shapes/IText/ITextKeyBehavior.test.ts @@ -0,0 +1,347 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { config } from '../../config'; +import { noop } from '../../constants'; +import { getEnv, getFabricWindow } from '../../env'; +import { IText } from './IText'; + +const keybEventShiftFalse = { shiftKey: false } as KeyboardEvent; +const keybEventShiftTrue = { shiftKey: true } as KeyboardEvent; + +describe('IText move cursor', () => { + let iText: IText; + describe('selection changes', () => { + const _initDelayedMock = jest.fn(), + selectionMock = jest.fn(); + beforeEach(() => { + _initDelayedMock.mockClear(); + iText = new IText('test need some word\nsecond line'); + iText.initDelayedCursor = _initDelayedMock; + iText.on('selection:changed', selectionMock); + }); + afterEach(() => { + iText.dispose(); + selectionMock.mockClear(); + }); + test('enterEditing does not use delayedCursor', () => { + jest.spyOn(iText, '_tick'); + iText.enterEditing(); + // enter editing will set the cursor and set selection to 1 + expect(selectionMock).toHaveBeenCalledTimes(1); + expect(iText._tick).toBeCalledWith(); + expect(_initDelayedMock).not.toHaveBeenCalled(); + }); + test('enterEditing and onInput', () => { + iText.enterEditing(); + expect(iText.text.includes('__UNIQUE_TEXT_')).toBe(false); + const event = new (getFabricWindow().InputEvent)('input', { + inputType: 'insertText', + data: '__UNIQUE_TEXT_', + composed: true, + }); + // manually crafted events have `isTrusted` as false so they won't interact with the html element + iText.hiddenTextarea!.value = `__UNIQUE_TEXT_${ + iText.hiddenTextarea!.value + }`; + iText.hiddenTextarea!.dispatchEvent(event); + expect(iText.text.includes('__UNIQUE_TEXT_')).toBe(true); + }); + test('updateFromTextArea calls setDimensions', () => { + iText.enterEditing(); + expect(iText.width).toBeLessThan(400); // 'iText is less than 400px' + iText.hiddenTextarea!.value = `more more more more text ${ + iText.hiddenTextarea!.value + }`; + iText.updateFromTextArea(); + expect(iText.width).toBeGreaterThan(700); // 'iText is now more than 700px' + expect(_initDelayedMock).toBeCalledWith(); // restart curso animation + }); + test('selectAll', () => { + iText.selectAll(); + expect(selectionMock).toHaveBeenCalledTimes(1); // 'should fire once on selectAll'; + expect(iText.selectionStart).toBe(0); // 'should start from 0'; + expect(iText.selectionEnd).toBe(31); // 'should end at end of text'; + expect(_initDelayedMock).not.toBeCalled(); // no cursor animation changes + }); + test('selectWord', () => { + iText.selectionStart = 2; + iText.selectionEnd = 2; + iText.selectWord(); + expect(selectionMock).toHaveBeenCalledTimes(1); // 'should fire once on selectWord + expect(iText.selectionStart).toBe(0); // ' 'should start at word start'); + expect(iText.selectionEnd).toBe(4); // ' 'should end at word end'); + expect(_initDelayedMock).not.toBeCalled(); // no cursor animation changes + }); + test('selectLine', () => { + iText.selectionStart = 2; + iText.selectionEnd = 2; + iText.selectLine(); + expect(selectionMock).toHaveBeenCalledTimes(1); // 'should fire once on selectLine'); + expect(iText.selectionStart).toBe(0); // 'should start at line start'); + expect(iText.selectionEnd).toBe(19); // 'should end at line end'); + expect(_initDelayedMock).not.toBeCalled(); // no cursor animation changes + }); + test('moveCursorLeft', () => { + iText.selectionStart = 2; + iText.selectionEnd = 2; + iText.moveCursorLeft(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(1); // 'should fire once on moveCursorLeft'); + expect(iText.selectionStart).toBe(1); // 'should be 1 less than 2'); + expect(iText.selectionEnd).toBe(1); // 'should be 1 less than 2'); + expect(_initDelayedMock).toBeCalledWith(); // moving cursor with keyboard restart cursor animation + }); + test('moveCursorRight', () => { + iText.selectionStart = 2; + iText.selectionEnd = 2; + iText.moveCursorRight(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(1); // 'should fire once on moveCursorLeft'); + expect(iText.selectionStart).toBe(3); // 'should be 1 more than 2'); + expect(iText.selectionEnd).toBe(3); // 'should be 1 more than 2'); + expect(_initDelayedMock).toBeCalledWith(); // moving cursor with keyboard restart cursor animation + }); + test('moveCursorDown', () => { + iText.selectionStart = 2; + iText.selectionEnd = 2; + iText.moveCursorDown(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(1); // 'should fire once on moveCursorDown'); + expect(iText.selectionStart).toBe(22); // 'should be on second line'); + expect(iText.selectionEnd).toBe(22); // 'should be on second line'); + expect(_initDelayedMock).toBeCalledWith(); + _initDelayedMock.mockClear(); + iText.moveCursorDown(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(2); // 'should fire once on moveCursorDown'); + expect(iText.selectionStart).toBe(31); // 'should be at end of text'); + expect(iText.selectionEnd).toBe(31); // 'should be at end of text'); + expect(_initDelayedMock).toBeCalledWith(); + }); + + test('moveCursorUp', () => { + iText.selectionStart = 22; + iText.selectionEnd = 22; + iText.moveCursorUp(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(1); // should fire once on moveCursorUp'); + expect(iText.selectionStart).toBe(2); // should be back to first line'); + expect(iText.selectionEnd).toBe(2); // should be back on first line'); + expect(_initDelayedMock).toBeCalledWith(); + _initDelayedMock.mockClear(); + iText.moveCursorUp(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(2); // should fire once on moveCursorUp'); + expect(iText.selectionStart).toBe(0); // should be back to first line'); + expect(iText.selectionEnd).toBe(0); // should be back on first line'); + expect(_initDelayedMock).toBeCalledWith(); + }); + + test('moveCursorLeft or up with no change', () => { + iText.selectionStart = 0; + iText.selectionEnd = 0; + iText.moveCursorLeft(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(0); // should not fire with no change'); + expect(iText.selectionStart).toBe(0); // should not move'); + expect(iText.selectionEnd).toBe(0); // should not move'); + expect(_initDelayedMock).not.toBeCalled(); + iText.moveCursorUp(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(0); // should not fire with no change'); + expect(iText.selectionStart).toBe(0); // should not move'); + expect(iText.selectionEnd).toBe(0); // should not move'); + expect(_initDelayedMock).not.toBeCalled(); + }); + + test('moveCursorRight or down with not change', () => { + iText.selectionStart = 31; + iText.selectionEnd = 31; + iText.moveCursorRight(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(0); // should not fire with no change'); + expect(iText.selectionStart).toBe(31); // should not move'); + expect(iText.selectionEnd).toBe(31); // should not move'); + expect(_initDelayedMock).not.toBeCalled(); + iText.moveCursorDown(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(0); // should not fire with no change'); + expect(iText.selectionStart).toBe(31); // should not move'); + expect(iText.selectionEnd).toBe(31); // should not move'); + expect(_initDelayedMock).not.toBeCalled(); + }); + + test('moveCursorUp from multi selection to cursor selection', () => { + iText.selectionStart = 28; + iText.selectionEnd = 31; + iText.moveCursorUp(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(1); // should fire'); + expect(iText.selectionStart).toBe(9); // should move to upper line start'); + expect(iText.selectionEnd).toBe(9); // should move to upper line end'); + expect(_initDelayedMock).toBeCalledWith(); + }); + + test('moveCursorDown from multi selection to cursor selection', () => { + iText.selectionStart = 1; + iText.selectionEnd = 4; + iText.moveCursorDown(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(1); // should fire'); + expect(iText.selectionStart).toBe(24); // should move to down line'); + expect(iText.selectionEnd).toBe(24); // should move to down line'); + expect(_initDelayedMock).toBeCalledWith(); + }); + + test('moveCursorRight from multi selection to cursor selection', () => { + iText.selectionStart = 1; + iText.selectionEnd = 4; + iText.moveCursorRight(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(1); // should fire'); + expect(iText.selectionStart).toBe(4); // should move to right by 1'); + expect(iText.selectionEnd).toBe(4); // should move to right by 1'); + expect(_initDelayedMock).toBeCalledWith(); + }); + + test('moveCursorLeft from multi selection to cursor selection', () => { + iText.selectionStart = 28; + iText.selectionEnd = 31; + iText.moveCursorLeft(keybEventShiftFalse); + expect(selectionMock).toHaveBeenCalledTimes(1); // should fire'); + expect(iText.selectionStart).toBe(28); // should move to selection Start'); + expect(iText.selectionEnd).toBe(28); // should move to selection Start'); + expect(_initDelayedMock).toBeCalledWith(); + }); + + test('moveCursor at start with shift', () => { + iText.selectionStart = 1; + iText.selectionEnd = 1; + iText.moveCursorLeft(keybEventShiftTrue); + expect(selectionMock).toHaveBeenCalledTimes(1); + expect(_initDelayedMock).toBeCalledWith(); // will start the animation and then abort it + _initDelayedMock.mockClear(); + iText.moveCursorLeft(keybEventShiftTrue); // do it again + expect(selectionMock).toHaveBeenCalledTimes(1); // should not fire with no change'); + expect(iText.selectionStart).toBe(0); // should not move'); + expect(iText.selectionEnd).toBe(1); // should not move'); + expect(_initDelayedMock).not.toBeCalled(); + iText.moveCursorUp(keybEventShiftTrue); + expect(selectionMock).toHaveBeenCalledTimes(1); // should not fire with no change'); + expect(iText.selectionStart).toBe(0); // should not move'); + expect(iText.selectionEnd).toBe(1); // should not move'); + expect(_initDelayedMock).not.toBeCalled(); + iText.moveCursorRight(keybEventShiftTrue); + expect(selectionMock).toHaveBeenCalledTimes(2); // this time it changes back to single selection; + expect(iText.selectionStart).toBe(1); // should not move'); + expect(iText.selectionEnd).toBe(1); // should not move'); + expect(_initDelayedMock).toBeCalledWith(); + }); + + test('moveCursor at end with shift', () => { + iText.selectionStart = 30; + iText.selectionEnd = 30; + iText.moveCursorRight(keybEventShiftTrue); + expect(selectionMock).toHaveBeenCalledTimes(1); + expect(iText.selectionStart).toBe(30); // multi selection now; + expect(iText.selectionEnd).toBe(31); // multi selection now; + expect(_initDelayedMock).toBeCalledWith(); // will start the animation and then abort it + _initDelayedMock.mockClear(); + iText.moveCursorRight(keybEventShiftTrue); + expect(selectionMock).toHaveBeenCalledTimes(1); // should fire with no change + expect(iText.selectionStart).toBe(30); // should not move'); + expect(iText.selectionEnd).toBe(31); // should not move'); + expect(_initDelayedMock).not.toBeCalled(); + iText.moveCursorDown(keybEventShiftTrue); + expect(selectionMock).toHaveBeenCalledTimes(1); // should fire with no change + expect(iText.selectionStart).toBe(30); // should not move'); + expect(iText.selectionEnd).toBe(31); // should not move'); + expect(_initDelayedMock).not.toBeCalled(); + iText.moveCursorLeft(keybEventShiftTrue); + expect(selectionMock).toHaveBeenCalledTimes(2); // now it changed back to 2 + expect(iText.selectionStart).toBe(30); // should not move'); + expect(iText.selectionEnd).toBe(30); // should not move'); + expect(_initDelayedMock).toBeCalledWith(); + }); + }); + test('mousedown calls key maps', () => { + const event = { + stopPropagation: noop, + stopImmediatePropagation: noop, + preventDefault: noop, + ctrlKey: false, + keyCode: 0, + } as KeyboardEvent; + const fired: string[] = []; + class TestIText extends IText {} + ['default', 'rtl', 'ctrl'].forEach((x) => { + TestIText.prototype[`__test_${x}`] = () => fired.push(x); + }); + const iText = new TestIText('test', { + fontSize: 25, + styles: { 0: { 0: { fill: 'red' }, 1: { fill: 'blue' } } }, + }); + iText.isEditing = true; + iText.keysMap = { 0: `__test_default` }; + iText.keysMapRtl = { 0: `__test_rtl` }; + iText.ctrlKeysMapDown = { 1: `__test_ctrl` }; + iText.onKeyDown(event); + expect(fired).toEqual(['default']); + iText.direction = 'rtl'; + iText.onKeyDown(event); + expect(fired).toEqual(['default', 'rtl']); + iText.onKeyDown({ ...event, ctrlKey: true, keyCode: 1 }); + expect(fired).toEqual(['default', 'rtl', 'ctrl']); + }); + test('copy', () => { + const { copyPasteData } = getEnv(); + const iText = new IText('test', { + fontSize: 25, + styles: { 0: { 0: { fill: 'red' }, 1: { fill: 'blue' } } }, + }); + iText.selectionStart = 0; + iText.selectionEnd = 2; + iText.copy(); + expect(copyPasteData.copiedText).toBe('te'); // it copied first 2 characters'); + expect(copyPasteData.copiedTextStyle![0].fill).toEqual( + iText.styles[0][0].fill + ); // 'style is cloned' + expect(copyPasteData.copiedTextStyle![1].fill).toBe( + iText.styles[0][1].fill + ); // 'style is referenced' + expect(iText.styles[0][1].fontSize).toBe(undefined); // style had not fontSize'); + expect(copyPasteData.copiedTextStyle![1].fontSize).toBe(25); // style took fontSize from text element' + }); + + test('copy with fabric.config.disableStyleCopyPaste', () => { + const { copyPasteData } = getEnv(); + const iText = new IText('test', { + fontSize: 25, + styles: { 0: { 0: { fill: 'red' }, 1: { fill: 'blue' } } }, + }); + iText.selectionStart = 0; + iText.selectionEnd = 2; + config.configure({ disableStyleCopyPaste: true }); + iText.copy(); + expect(copyPasteData.copiedText).toBe('te'); // it copied first 2 characters + expect(copyPasteData.copiedTextStyle).toEqual(undefined); // style is not cloned + config.configure({ disableStyleCopyPaste: false }); + }); +}); + +// TODO verify and dp +// iText.selectionStart = 0; +// iText.selectionEnd = 0; +// iText.insertChars('hello'); +// expect(selection, 1); // should fire once on insert multiple chars'); +// expect(iText.selectionStart, 5); // should be at end of text inserted'); +// expect(iText.selectionEnd, 5); // should be at end of text inserted'); + +// test('copy and paste', function(assert) { +// var event = { stopPropagation: function(){}, preventDefault: function(){} }; +// var iText = new fabric.IText('test', { styles: { 0: { 0: { fill: 'red' }, 1: { fill: 'blue' }}}}); +// iText.enterEditing(); +// iText.selectionStart = 0; +// iText.selectionEnd = 2; +// iText.hiddenTextarea.selectionStart = 0 +// iText.hiddenTextarea.selectionEnd = 2 +// iText.copy(event); +// expect(fabric.copiedText); // te'); // it copied first 2 characters'); +// expect(fabric.copiedTextStyle[0], iText.styles[0][0]); // style is referenced'); +// expect(fabric.copiedTextStyle[1], iText.styles[0][1]); // style is referenced'); +// iText.selectionStart = 2; +// iText.selectionEnd = 2; +// iText.hiddenTextarea.value = 'tetest'; +// iText.paste(event); +// expect(iText.text); // tetest'); // text has been copied'); +// assert.notEqual(iText.styles[0][0], iText.styles[0][2]); // style is not referenced'); +// assert.notEqual(iText.styles[0][1], iText.styles[0][3]); // style is not referenced'); +// assert.deepEqual(iText.styles[0][0], iText.styles[0][2]); // style is copied'); +// assert.deepEqual(iText.styles[0][1], iText.styles[0][3]); // style is copied'); +// }); diff --git a/src/shapes/IText/ITextKeyBehavior.ts b/src/shapes/IText/ITextKeyBehavior.ts index e09f9bcf3c3..082ce8bb2cb 100644 --- a/src/shapes/IText/ITextKeyBehavior.ts +++ b/src/shapes/IText/ITextKeyBehavior.ts @@ -488,8 +488,9 @@ export abstract class ITextKeyBehavior< const max = this.text.length; this.selectionStart = capValue(0, this.selectionStart, max); this.selectionEnd = capValue(0, this.selectionEnd, max); + // TODO fix: abort and init should be an alternative depending + // on selectionStart/End being equal or different this.abortCursorAnimation(); - this._currentCursorOpacity = 1; this.initDelayedCursor(); this._fireSelectionChanged(); this._updateTextarea(); @@ -641,6 +642,8 @@ export abstract class ITextKeyBehavior< }` as const; this._currentCursorOpacity = 1; if (this[actionName](e)) { + // TODO fix: abort and init should be an alternative depending + // on selectionStart/End being equal or different this.abortCursorAnimation(); this.initDelayedCursor(); this._fireSelectionChanged(); diff --git a/src/shapes/IText/__snapshots__/ITextBehavior.test.ts.snap b/src/shapes/IText/__snapshots__/ITextBehavior.test.ts.snap index 7f5a8b8ec05..f9afb8bbc6a 100644 --- a/src/shapes/IText/__snapshots__/ITextBehavior.test.ts.snap +++ b/src/shapes/IText/__snapshots__/ITextBehavior.test.ts.snap @@ -1,5 +1,225 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`IText cursor animation snapshot exiting from a canvas will abort animation 1`] = ` +[ + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", +] +`; + +exports[`IText cursor animation snapshot initDelayedCursor false - with delay 1`] = ` +[ + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "0.000", + "1.000", + "0.996", + "0.986", + "0.969", + "0.944", + "0.914", + "0.876", + "0.833", + "0.784", + "0.729", + "0.669", + "0.605", + "0.536", + "0.463", + "0.388", + "0.309", + "0.228", + "0.146", +] +`; + +exports[`IText cursor animation snapshot initDelayedCursor true - with NO delay 1`] = ` +[ + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "0.000", + "1.000", + "0.996", + "0.986", + "0.969", + "0.944", + "0.914", + "0.876", + "0.833", + "0.784", + "0.729", + "0.669", + "0.605", + "0.536", + "0.463", + "0.388", + "0.309", + "0.228", + "0.146", + "0.063", + "0.000", + "1.000", + "0.000", + "0.001", + "0.004", + "0.008", + "0.014", + "0.022", + "0.031", + "0.043", + "0.056", + "0.070", + "0.086", + "0.104", + "0.124", + "0.145", + "0.167", + "0.191", + "0.216", + "0.243", + "0.271", + "0.300", + "0.331", + "0.363", + "0.395", + "0.429", + "0.464", + "0.500", + "0.537", + "0.574", + "0.612", + "0.651", + "0.691", + "0.731", + "0.772", + "0.813", + "0.854", + "0.895", + "0.937", + "0.979", + "1.000", + "0.000", + "1.000", + "0.996", + "0.986", + "0.969", + "0.944", + "0.914", + "0.876", + "0.833", + "0.784", + "0.729", + "0.669", + "0.605", + "0.536", + "0.463", + "0.388", +] +`; + +exports[`IText cursor animation snapshot selectionStart/selection end will abort animation 1`] = ` +[ + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", + "1.000", +] +`; + exports[`text imperative changes insertChars 1`] = ` { "charBounds": [ diff --git a/test/unit/itext_click_behaviour.js b/test/unit/itext_click_behaviour.js index e13bf9c8ccd..35c6dd362b2 100644 --- a/test/unit/itext_click_behaviour.js +++ b/test/unit/itext_click_behaviour.js @@ -12,17 +12,6 @@ canvas.cancelRequestedRender(); }); - function assertCursorAnimation(assert, text, active = false) { - const cursorState = [text._currentTickState, text._currentTickCompleteState].some( - (cursorAnimation) => cursorAnimation && !cursorAnimation.isDone() - ); - assert.equal(cursorState, active, `cursor animation state should be ${active}`); - } - - function wait(ms = 32) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - QUnit.test('doubleClickHandler', async function (assert) { var done = assert.async(); var iText = new fabric.IText('test need some word\nsecond line'); @@ -34,15 +23,11 @@ clientY: 10 }; iText.enterEditing(); - assertCursorAnimation(assert, iText, true); iText.doubleClickHandler({ e: eventData }); assert.equal(iText.selectionStart, 0, 'dblClick selection start is'); assert.equal(iText.selectionEnd, 4, 'dblClick selection end is'); - // wait for cursor animation tick to run - await wait(); - assertCursorAnimation(assert, iText); eventData = { which: 1, target: canvas.upperCanvasEl, @@ -54,8 +39,6 @@ }); assert.equal(iText.selectionStart, 20, 'second dblClick selection start is'); assert.equal(iText.selectionEnd, 26, 'second dblClick selection end is'); - assertCursorAnimation(assert, iText); - iText.exitEditing(); done(); }); QUnit.test('doubleClickHandler no editing', function (assert) { @@ -72,7 +55,6 @@ }); assert.equal(iText.selectionStart, 0, 'dblClick selection start is'); assert.equal(iText.selectionEnd, 0, 'dblClick selection end is'); - assertCursorAnimation(assert, iText); }); QUnit.test('tripleClickHandler', async function (assert) { var done = assert.async(); @@ -85,15 +67,11 @@ clientY: 10 }; iText.enterEditing(); - assertCursorAnimation(assert, iText, true); iText.tripleClickHandler({ e: eventData }); assert.equal(iText.selectionStart, 0, 'tripleClick selection start is'); assert.equal(iText.selectionEnd, 19, 'tripleClick selection end is'); - // wait for cursor animation tick to run - await wait(); - assertCursorAnimation(assert, iText); eventData = { which: 1, target: canvas.upperCanvasEl, @@ -105,7 +83,6 @@ }); assert.equal(iText.selectionStart, 20, 'second tripleClick selection start is'); assert.equal(iText.selectionEnd, 31, 'second tripleClick selection end is'); - assertCursorAnimation(assert, iText); iText.exitEditing(); done(); }); @@ -184,7 +161,6 @@ iText.__lastSelected = true; iText.mouseUpHandler({ e: {} }); assert.equal(iText.isEditing, true, 'iText entered editing'); - assertCursorAnimation(assert, iText, true); iText.exitEditing(); }); QUnit.test('_mouseUpHandler on a selected object does enter edit if there is an activeObject', function (assert) { @@ -198,7 +174,6 @@ iText.__lastSelected = true; iText.mouseUpHandler({ e: {} }); assert.equal(iText.isEditing, false, 'iText should not enter editing'); - assertCursorAnimation(assert, iText); iText.exitEditing(); }); QUnit.test('_mouseUpHandler on a selected text in a group does NOT enter editing', function (assert) { @@ -226,7 +201,6 @@ iText.__lastSelected = true; canvas.__onMouseUp({ clientX: 1, clientY: 1, target: canvas.upperCanvasEl }); assert.equal(iText.isEditing, true, 'iText should enter editing'); - assertCursorAnimation(assert, iText, true); iText.exitEditing(); group.interactive = false; iText.selected = true; @@ -308,7 +282,6 @@ assert.equal(iText.isEditing, true, 'Itext entered editing'); assert.equal(iText.selectionStart, 2, 'Itext set the selectionStart'); assert.equal(iText.selectionEnd, 2, 'Itext set the selectionend'); - assertCursorAnimation(assert, iText, true); assert.equal(count, 1, 'no selection:changed fired yet'); assert.equal(countCanvas, 1, 'no text:selection:changed fired yet'); done(); diff --git a/test/unit/itext_key_behaviour.js b/test/unit/itext_key_behaviour.js deleted file mode 100644 index 22b0c5896ca..00000000000 --- a/test/unit/itext_key_behaviour.js +++ /dev/null @@ -1,349 +0,0 @@ -(function(){ - var canvas = fabric.getFabricDocument().createElement('canvas'), - ctx = canvas.getContext('2d'); - - async function assertCursorAnimation(assert, text, active = false, delay = false) { - delay && await wait(typeof delay === 'number' ? delay : undefined); - const cursorState = [text._currentTickState, text._currentTickCompleteState].some( - (cursorAnimation) => cursorAnimation && !cursorAnimation.isDone() - ); - assert.equal(cursorState, active, `cursor animation state should be ${active}`); - // must return for await - return true; - } - - function wait(ms = 16) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - function setSelection(assert, iText, start, end) { - iText.selectionStart = start; - iText.selectionEnd = end; - iText.initDelayedCursor(true); - return assertCursorAnimation(assert, iText, true); - } - - QUnit.module('selection changes', function (hooks) { - let iText, selection = 0, _assertCursorAnimation, _setSelection; - hooks.beforeEach(() => { - iText = new fabric.IText('test need some word\nsecond line'); - iText.ctx = ctx; - iText.on('selection:changed', () => selection++); - _assertCursorAnimation = assertCursorAnimation.bind(null, QUnit.assert, iText); - _setSelection = setSelection.bind(null, QUnit.assert, iText); - selection = 0; - }); - hooks.after(() => { - // needed or test hangs - iText.dispose(); - }); - QUnit.test('enterEditing', async function (assert) { - iText.enterEditing(); - await _assertCursorAnimation(true); - assert.equal(selection, 1, 'will fire on enter edit since the cursor is changing for the first time'); - }); - - QUnit.test('enterEditing and onInput', function (assert) { - iText.enterEditing(); - assert.equal(iText.text.includes('__UNIQUE_TEXT_'), false, 'does not contain __UNIQUE_TEXT_'); - const event = new (fabric.getFabricWindow().InputEvent)("input", { inputType: "insertText", data: '__UNIQUE_TEXT_', composed: true }); - // manually crafted events have `isTrusted` as false so they won't interact with the html element - iText.hiddenTextarea.value = `__UNIQUE_TEXT_${iText.hiddenTextarea.value}`; - iText.hiddenTextarea.dispatchEvent(event); - assert.equal(iText.text.includes('__UNIQUE_TEXT_'), true, 'does contain __UNIQUE_TEXT_'); - }); - - QUnit.test('updateFromTextArea calls setDimensions', function (assert) { - iText.enterEditing(); - assert.equal(iText.width < 400, true, 'iText is less than 400px') - iText.hiddenTextarea.value = `more more more more text ${iText.hiddenTextarea.value}`; - iText.updateFromTextArea(); - assert.equal(iText.width > 700, true, 'iText is now more than 700px') - }); - - QUnit.test('selectAll', async function (assert) { - const done = assert.async(); - iText.selectAll(); - assert.equal(selection, 1, 'should fire once on selectAll'); - assert.equal(iText.selectionStart, 0, 'should start from 0'); - assert.equal(iText.selectionEnd, 31, 'should end at end of text'); - await _assertCursorAnimation(false, true); - done(); - }); - - QUnit.test('selectWord', async function (assert) { - const done = assert.async(); - await _setSelection(2, 2); - iText.selectWord(); - assert.equal(selection, 1, 'should fire once on selectWord'); - assert.equal(iText.selectionStart, 0, 'should start at word start'); - assert.equal(iText.selectionEnd, 4, 'should end at word end'); - await _assertCursorAnimation(false, true); - done(); - }); - - QUnit.test('selectLine', async function (assert) { - const done = assert.async(); - await _setSelection(2, 2); - iText.selectLine(); - assert.equal(selection, 1, 'should fire once on selectLine'); - assert.equal(iText.selectionStart, 0, 'should start at line start'); - assert.equal(iText.selectionEnd, 19, 'should end at line end'); - await _assertCursorAnimation(false, true); - done(); - }); - - QUnit.test('moveCursorLeft', async function (assert) { - const done = assert.async(); - await _setSelection(2, 2); - iText.moveCursorLeft({ shiftKey: false }); - assert.equal(selection, 1, 'should fire once on moveCursorLeft'); - assert.equal(iText.selectionStart, 1, 'should be 1 less than 2'); - assert.equal(iText.selectionEnd, 1, 'should be 1 less than 2'); - await _assertCursorAnimation(true, true); - done(); - }); - - QUnit.test('moveCursorRight', async function (assert) { - const done = assert.async(); - await _setSelection(2, 2); - iText.moveCursorRight({ shiftKey: false }); - assert.equal(selection, 1, 'should fire once on moveCursorRight'); - assert.equal(iText.selectionStart, 3, 'should be 1 more than 2'); - assert.equal(iText.selectionEnd, 3, 'should be 1 more than 2'); - await _assertCursorAnimation(true, true); - done(); - }); - - QUnit.test('moveCursorDown', async function (assert) { - const done = assert.async(); - await _setSelection(2, 2); - iText.moveCursorDown({ shiftKey: false }); - assert.equal(selection, 1, 'should fire once on moveCursorDown'); - assert.equal(iText.selectionStart, 22, 'should be on second line'); - assert.equal(iText.selectionEnd, 22, 'should be on second line'); - await _assertCursorAnimation(true, true); - iText.moveCursorDown({ shiftKey: false }); - assert.equal(selection, 2, 'should fire once on moveCursorDown'); - assert.equal(iText.selectionStart, 31, 'should be at end of text'); - assert.equal(iText.selectionEnd, 31, 'should be at end of text'); - await _assertCursorAnimation(true, true); - done(); - }); - - QUnit.test('moveCursorUp', async function (assert) { - const done = assert.async(); - await _setSelection(22, 22); - iText.moveCursorUp({ shiftKey: false }); - assert.equal(selection, 1, 'should fire once on moveCursorUp'); - assert.equal(iText.selectionStart, 2, 'should be back to first line'); - assert.equal(iText.selectionEnd, 2, 'should be back on first line'); - await _assertCursorAnimation(true, true); - iText.moveCursorUp({ shiftKey: false }); - assert.equal(selection, 2, 'should fire once on moveCursorUp'); - assert.equal(iText.selectionStart, 0, 'should be back to first line'); - assert.equal(iText.selectionEnd, 0, 'should be back on first line'); - await _assertCursorAnimation(true, true); - done(); - }); - - QUnit.test('moveCursorLeft', async function (assert) { - const done = assert.async(); - await _setSelection(0, 0); - iText.moveCursorLeft({ shiftKey: false }); - assert.equal(selection, 0, 'should not fire with no change'); - assert.equal(iText.selectionStart, 0, 'should not move'); - assert.equal(iText.selectionEnd, 0, 'should not move'); - await _assertCursorAnimation(false, true); - iText.moveCursorUp({ shiftKey: false }); - assert.equal(selection, 0, 'should not fire with no change'); - assert.equal(iText.selectionStart, 0, 'should not move'); - assert.equal(iText.selectionEnd, 0, 'should not move'); - await _assertCursorAnimation(false, true); - done(); - }); - - QUnit.test('moveCursorRight', async function (assert) { - const done = assert.async(); - await _setSelection(31, 31); - iText.moveCursorRight({ shiftKey: false }); - assert.equal(selection, 0, 'should not fire with no change'); - assert.equal(iText.selectionStart, 31, 'should not move'); - assert.equal(iText.selectionEnd, 31, 'should not move'); - await _assertCursorAnimation(true); - iText.moveCursorDown({ shiftKey: false }); - assert.equal(selection, 0, 'should not fire with no change'); - assert.equal(iText.selectionStart, 31, 'should not move'); - assert.equal(iText.selectionEnd, 31, 'should not move'); - await _assertCursorAnimation(true); - done(); - }); - - QUnit.test('moveCursorUp', async function (assert) { - const done = assert.async(); - await _setSelection(28, 31); - await _assertCursorAnimation(false, true); - iText.moveCursorUp({ shiftKey: false }); - assert.equal(selection, 1, 'should fire'); - assert.equal(iText.selectionStart, 9, 'should move to upper line start'); - assert.equal(iText.selectionEnd, 9, 'should move to upper line end'); - await _assertCursorAnimation(true); - done(); - }); - - QUnit.test('moveCursorDown', async function (assert) { - const done = assert.async(); - await _setSelection(1, 4); - await _assertCursorAnimation(false, true); - iText.moveCursorDown({ shiftKey: false }); - assert.equal(selection, 1, 'should fire'); - assert.equal(iText.selectionStart, 24, 'should move to down line'); - assert.equal(iText.selectionEnd, 24, 'should move to down line'); - await _assertCursorAnimation(true); - done(); - }); - - QUnit.test('moveCursorLeft', async function (assert) { - const done = assert.async(); - await _setSelection(28, 31); - await _assertCursorAnimation(false, true); - iText.moveCursorLeft({ shiftKey: false }); - assert.equal(selection, 1, 'should fire'); - assert.equal(iText.selectionStart, 28, 'should move to selection Start'); - assert.equal(iText.selectionEnd, 28, 'should move to selection Start'); - await _assertCursorAnimation(true); - done(); - }); - - QUnit.test('moveCursor at start with shift', async function (assert) { - const done = assert.async(); - await _setSelection(0, 1); - await _assertCursorAnimation(false, true); - iText._selectionDirection = 'left'; - iText.moveCursorLeft({ shiftKey: true }); - assert.equal(selection, 0, 'should not fire with no change'); - assert.equal(iText.selectionStart, 0, 'should not move'); - assert.equal(iText.selectionEnd, 1, 'should not move'); - await _assertCursorAnimation(false, true); - iText.moveCursorUp({ shiftKey: true }); - assert.equal(selection, 0, 'should not fire with no change'); - assert.equal(iText.selectionStart, 0, 'should not move'); - assert.equal(iText.selectionEnd, 1, 'should not move'); - await _assertCursorAnimation(false, true); - iText.moveCursorRight({ shiftKey: true }); - assert.equal(selection, 1, 'should not fire with no change'); - assert.equal(iText.selectionStart, 1, 'should not move'); - assert.equal(iText.selectionEnd, 1, 'should not move'); - await _assertCursorAnimation(true, true); - done(); - }); - - QUnit.test('moveCursor at end with shift', async function (assert) { - const done = assert.async(); - await _setSelection(30, 31); - await _assertCursorAnimation(false, true); - iText._selectionDirection = 'right'; - iText.moveCursorRight({ shiftKey: true }); - assert.equal(selection, 0, 'should not fire with no change'); - assert.equal(iText.selectionStart, 30, 'should not move'); - assert.equal(iText.selectionEnd, 31, 'should not move'); - await _assertCursorAnimation(false, true); - iText.moveCursorDown({ shiftKey: true }); - assert.equal(selection, 0, 'should not fire with no change'); - assert.equal(iText.selectionStart, 30, 'should not move'); - assert.equal(iText.selectionEnd, 31, 'should not move'); - await _assertCursorAnimation(false, true); - iText.moveCursorLeft({ shiftKey: true }); - assert.equal(selection, 1, 'should not fire with no change'); - assert.equal(iText.selectionStart, 30, 'should not move'); - assert.equal(iText.selectionEnd, 30, 'should not move'); - await _assertCursorAnimation(true, true); - done(); - }); - - // TODO verify and dp - // iText.selectionStart = 0; - // iText.selectionEnd = 0; - // iText.insertChars('hello'); - // assert.equal(selection, 1, 'should fire once on insert multiple chars'); - // assert.equal(iText.selectionStart, 5, 'should be at end of text inserted'); - // assert.equal(iText.selectionEnd, 5, 'should be at end of text inserted'); - - QUnit.test('mousedown calls key maps', function (assert) { - const event = { - stopPropagation: function () { }, - stopImmediatePropagation: function () { }, - preventDefault: function () { }, - ctrlKey: false, - keyCode: 0 - }; - class TestIText extends fabric.IText { - - } - ['default', 'rtl', 'ctrl'].forEach(x => { TestIText.prototype[`__test_${x}`] = () => fired.push(x) }); - const iText = new TestIText('test', { fontSize: 25, styles: { 0: { 0: { fill: 'red' }, 1: { fill: 'blue' } } } }); - iText.isEditing = true; - const fired = []; - iText.keysMap = { 0: `__test_default` }; - iText.keysMapRtl = { 0: `__test_rtl` }; - iText.ctrlKeysMapDown = { 1: `__test_ctrl` }; - iText.onKeyDown(event); - assert.deepEqual(fired, ['default']); - iText.direction = 'rtl'; - iText.onKeyDown(event); - assert.deepEqual(fired, ['default', 'rtl']); - iText.onKeyDown({ ...event, ctrlKey: true, keyCode: 1 }); - assert.deepEqual(fired, ['default', 'rtl', 'ctrl']); - }); - - // QUnit.test('copy and paste', function(assert) { - // var event = { stopPropagation: function(){}, preventDefault: function(){} }; - // var iText = new fabric.IText('test', { styles: { 0: { 0: { fill: 'red' }, 1: { fill: 'blue' }}}}); - // iText.enterEditing(); - // iText.selectionStart = 0; - // iText.selectionEnd = 2; - // iText.hiddenTextarea.selectionStart = 0 - // iText.hiddenTextarea.selectionEnd = 2 - // iText.copy(event); - // assert.equal(fabric.copiedText, 'te', 'it copied first 2 characters'); - // assert.equal(fabric.copiedTextStyle[0], iText.styles[0][0], 'style is referenced'); - // assert.equal(fabric.copiedTextStyle[1], iText.styles[0][1], 'style is referenced'); - // iText.selectionStart = 2; - // iText.selectionEnd = 2; - // iText.hiddenTextarea.value = 'tetest'; - // iText.paste(event); - // assert.equal(iText.text, 'tetest', 'text has been copied'); - // assert.notEqual(iText.styles[0][0], iText.styles[0][2], 'style is not referenced'); - // assert.notEqual(iText.styles[0][1], iText.styles[0][3], 'style is not referenced'); - // assert.deepEqual(iText.styles[0][0], iText.styles[0][2], 'style is copied'); - // assert.deepEqual(iText.styles[0][1], iText.styles[0][3], 'style is copied'); - // }); - QUnit.test('copy', function(assert) { - var event = { stopPropagation: function(){}, preventDefault: function(){} }; - const { copyPasteData } = fabric.getEnv() - var iText = new fabric.IText('test', { fontSize: 25, styles: { 0: { 0: { fill: 'red' }, 1: { fill: 'blue' }}}}); - iText.selectionStart = 0; - iText.selectionEnd = 2; - iText.copy(event); - assert.equal(copyPasteData.copiedText, 'te', 'it copied first 2 characters'); - assert.equal(copyPasteData.copiedTextStyle[0].fill, iText.styles[0][0].fill, 'style is cloned'); - assert.equal(copyPasteData.copiedTextStyle[1].fill, iText.styles[0][1].fill, 'style is referenced'); - assert.equal(iText.styles[0][1].fontSize, undefined, 'style had not fontSize'); - assert.equal(copyPasteData.copiedTextStyle[1].fontSize, 25, 'style took fontSize from text element'); - }); - - QUnit.test('copy with fabric.config.disableStyleCopyPaste', function(assert) { - var event = { stopPropagation: function(){}, preventDefault: function(){} }; - const { copyPasteData } = fabric.getEnv() - var iText = new fabric.IText('test', { fontSize: 25, styles: { 0: { 0: { fill: 'red' }, 1: { fill: 'blue' }}}}); - iText.selectionStart = 0; - iText.selectionEnd = 2; - fabric.config.configure({ disableStyleCopyPaste: true }); - iText.copy(event); - assert.equal(copyPasteData.copiedText, 'te', 'it copied first 2 characters'); - assert.equal(copyPasteData.copiedTextStyle, null, 'style is not cloned'); - fabric.config.configure({ disableStyleCopyPaste: false }); - }); - - }); -})();