diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets new file mode 100644 index 000000000..7308da0ac --- /dev/null +++ b/.vscode/snippets.code-snippets @@ -0,0 +1,34 @@ +{ + // Place your tldraw workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.log('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + "createComment": { + "scope": "typescript", + "prefix": "/**", + "body": [ + "/**", + " * ${1:description}", + " *", + " * ### Example", + " *", + " *```ts", + " * ${2:example}", + " *```", + " */" + ], + "description": "comment" + } +} diff --git a/__tests__/__snapshots__/project.test.ts.snap b/__tests__/__snapshots__/project.test.ts.snap index 42de82873..7e309f227 100644 --- a/__tests__/__snapshots__/project.test.ts.snap +++ b/__tests__/__snapshots__/project.test.ts.snap @@ -116,3 +116,120 @@ Object { }, } `; + +exports[`restoring project remounts the state after mutating the current state: data after re-mount from file 1`] = ` +Object { + "code": Object { + "file0": Object { + "code": "", + "id": "file0", + "name": "index.ts", + }, + }, + "id": "0001", + "name": "My Document", + "pages": Object { + "page1": Object { + "childIndex": 0, + "id": "page1", + "name": "Page 1", + "shapes": Object { + "1f6c251c-e12e-40b4-8dd2-c1847d80b72f": Object { + "childIndex": 24, + "id": "1f6c251c-e12e-40b4-8dd2-c1847d80b72f", + "isAspectRatioLocked": false, + "isGenerated": false, + "isHidden": false, + "isLocked": false, + "name": "Rectangle", + "parentId": "page1", + "point": Array [ + 0, + 0, + ], + "radius": 2, + "rotation": 0, + "seed": 0.6440313303074272, + "size": Array [ + 67.22075383450237, + 72.92795609221832, + ], + "style": Object { + "color": "Black", + "dash": "Solid", + "isFilled": false, + "size": "Small", + }, + "type": "rectangle", + }, + "5ca167d7-54de-47c9-aa8f-86affa25e44d": Object { + "bend": 0, + "childIndex": 16, + "decorations": Object { + "end": null, + "middle": null, + "start": null, + }, + "handles": Object { + "bend": Object { + "id": "bend", + "index": 2, + "point": Array [ + 3.2518097616315345, + 140.54510317291172, + ], + }, + "end": Object { + "id": "end", + "index": 1, + "point": Array [ + 0, + 0, + ], + }, + "start": Object { + "id": "start", + "index": 0, + "point": Array [ + 6.503619523263069, + 281.09020634582345, + ], + }, + }, + "id": "5ca167d7-54de-47c9-aa8f-86affa25e44d", + "isAspectRatioLocked": false, + "isGenerated": false, + "isHidden": false, + "isLocked": false, + "name": "Arrow", + "parentId": "page1", + "point": Array [ + 100, + 100, + ], + "points": Array [ + Array [ + 6.503619523263069, + 281.09020634582345, + ], + Array [ + 0, + 0, + ], + ], + "rotation": 0, + "seed": 0.08116783083496548, + "style": Object { + "color": "Black", + "dash": "Solid", + "isFilled": false, + "size": "Small", + }, + "type": "arrow", + }, + }, + "type": "page", + }, + }, +} +`; diff --git a/__tests__/bounds.test.ts b/__tests__/bounds.test.ts new file mode 100644 index 000000000..fee0821c3 --- /dev/null +++ b/__tests__/bounds.test.ts @@ -0,0 +1,72 @@ +import { getShapeUtils } from 'state/shape-utils' +import { getCommonBounds } from 'utils' +import TestState, { arrowId, rectangleId } from './test-utils' + +describe('selection', () => { + const tt = new TestState() + + it('measures correct bounds for selected item', () => { + // Note: Each item should test its own bounds in its ./shapes/[shape].tsx file + + const shape = tt.getShape(rectangleId) + + tt.deselectAll().clickShape(rectangleId) + + expect(tt.state.values.selectedBounds).toStrictEqual( + getShapeUtils(shape).getBounds(shape) + ) + }) + + it('measures correct bounds for rotated selected item', () => { + const shape = tt.getShape(rectangleId) + + getShapeUtils(shape).rotateBy(shape, Math.PI * 2 * Math.random()) + + tt.deselectAll().clickShape(rectangleId) + + expect(tt.state.values.selectedBounds).toStrictEqual( + getShapeUtils(shape).getBounds(shape) + ) + + getShapeUtils(shape).rotateBy(shape, -Math.PI * 2 * Math.random()) + + expect(tt.state.values.selectedBounds).toStrictEqual( + getShapeUtils(shape).getBounds(shape) + ) + }) + + it('measures correct bounds for selected items', () => { + const shape1 = tt.getShape(rectangleId) + const shape2 = tt.getShape(arrowId) + + tt.deselectAll() + .clickShape(shape1.id) + .clickShape(shape2.id, { shiftKey: true }) + + expect(tt.state.values.selectedBounds).toStrictEqual( + getCommonBounds( + getShapeUtils(shape1).getRotatedBounds(shape1), + getShapeUtils(shape2).getRotatedBounds(shape2) + ) + ) + }) + + it('measures correct bounds for rotated selected items', () => { + const shape1 = tt.getShape(rectangleId) + const shape2 = tt.getShape(arrowId) + + getShapeUtils(shape1).rotateBy(shape1, Math.PI * 2 * Math.random()) + getShapeUtils(shape2).rotateBy(shape2, Math.PI * 2 * Math.random()) + + tt.deselectAll() + .clickShape(shape1.id) + .clickShape(shape2.id, { shiftKey: true }) + + expect(tt.state.values.selectedBounds).toStrictEqual( + getCommonBounds( + getShapeUtils(shape1).getRotatedBounds(shape1), + getShapeUtils(shape2).getRotatedBounds(shape2) + ) + ) + }) +}) diff --git a/__tests__/children.test.ts b/__tests__/children.test.ts new file mode 100644 index 000000000..e1c62ae4f --- /dev/null +++ b/__tests__/children.test.ts @@ -0,0 +1,300 @@ +import { MoveType, ShapeType } from 'types' +import TestState from './test-utils' + +describe('shapes with children', () => { + const tt = new TestState() + + tt.resetDocumentState() + .createShape( + { + type: ShapeType.Rectangle, + point: [0, 0], + size: [100, 100], + childIndex: 1, + }, + 'delete-me-bottom' + ) + .createShape( + { + type: ShapeType.Rectangle, + point: [0, 0], + size: [100, 100], + childIndex: 2, + }, + '1' + ) + .createShape( + { + type: ShapeType.Rectangle, + point: [300, 0], + size: [100, 100], + childIndex: 3, + }, + '2' + ) + .createShape( + { + type: ShapeType.Rectangle, + point: [0, 300], + size: [100, 100], + childIndex: 4, + }, + 'delete-me-middle' + ) + .createShape( + { + type: ShapeType.Rectangle, + point: [0, 300], + size: [100, 100], + childIndex: 5, + }, + '3' + ) + .createShape( + { + type: ShapeType.Rectangle, + point: [300, 300], + size: [100, 100], + childIndex: 6, + }, + '4' + ) + + // Delete shapes at the start and in the middle of the list + + tt.clickShape('delete-me-bottom') + .send('DELETED') + .clickShape('delete-me-middle') + .send('DELETED') + + it('has shapes in order', () => { + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.childIndex) + ).toStrictEqual([2, 3, 5, 6]) + }) + + it('moves a shape to back', () => { + tt.clickShape('3').send('MOVED', { + type: MoveType.ToBack, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['3', '1', '2', '4']) + }) + + it('moves two adjacent siblings to back', () => { + tt.clickShape('4').clickShape('2', { shiftKey: true }).send('MOVED', { + type: MoveType.ToBack, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['2', '4', '3', '1']) + }) + + it('moves two non-adjacent siblings to back', () => { + tt.clickShape('4').clickShape('1', { shiftKey: true }).send('MOVED', { + type: MoveType.ToBack, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['4', '1', '2', '3']) + }) + + it('moves a shape backward', () => { + tt.clickShape('3').send('MOVED', { + type: MoveType.Backward, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['4', '1', '3', '2']) + }) + + it('moves a shape at first index backward', () => { + tt.clickShape('4').send('MOVED', { + type: MoveType.Backward, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['4', '1', '3', '2']) + }) + + it('moves two adjacent siblings backward', () => { + tt.clickShape('3').clickShape('2', { shiftKey: true }).send('MOVED', { + type: MoveType.Backward, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['4', '3', '2', '1']) + }) + + it('moves two non-adjacent siblings backward', () => { + tt.clickShape('3').clickShape('1', { shiftKey: true }).send('MOVED', { + type: MoveType.Backward, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['3', '4', '1', '2']) + }) + + it('moves two adjacent siblings backward at zero index', () => { + tt.clickShape('3').clickShape('4', { shiftKey: true }).send('MOVED', { + type: MoveType.Backward, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['3', '4', '1', '2']) + }) + + it('moves a shape forward', () => { + tt.clickShape('4').send('MOVED', { + type: MoveType.Forward, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['3', '1', '4', '2']) + }) + + it('moves a shape forward at the top index', () => { + tt.clickShape('2').send('MOVED', { + type: MoveType.Forward, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['3', '1', '4', '2']) + }) + + it('moves two adjacent siblings forward', () => { + tt.deselectAll() + .clickShape('4') + .clickShape('1', { shiftKey: true }) + .send('MOVED', { + type: MoveType.Forward, + }) + + expect(tt.idsAreSelected(['1', '4'])).toBe(true) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['3', '2', '1', '4']) + }) + + it('moves two non-adjacent siblings forward', () => { + tt.deselectAll() + .clickShape('3') + .clickShape('1', { shiftKey: true }) + .send('MOVED', { + type: MoveType.Forward, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['2', '3', '4', '1']) + }) + + it('moves two adjacent siblings forward at top index', () => { + tt.deselectAll() + .clickShape('3') + .clickShape('1', { shiftKey: true }) + .send('MOVED', { + type: MoveType.Forward, + }) + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['2', '4', '3', '1']) + }) + + it('moves a shape to front', () => { + tt.deselectAll().clickShape('2').send('MOVED', { + type: MoveType.ToFront, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['4', '3', '1', '2']) + }) + + it('moves two adjacent siblings to front', () => { + tt.deselectAll() + .clickShape('3') + .clickShape('1', { shiftKey: true }) + .send('MOVED', { + type: MoveType.ToFront, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['4', '2', '3', '1']) + }) + + it('moves two non-adjacent siblings to front', () => { + tt.deselectAll() + .clickShape('4') + .clickShape('3', { shiftKey: true }) + .send('MOVED', { + type: MoveType.ToFront, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['2', '1', '4', '3']) + }) + + it('moves siblings already at front to front', () => { + tt.deselectAll() + .clickShape('4') + .clickShape('3', { shiftKey: true }) + .send('MOVED', { + type: MoveType.ToFront, + }) + + expect( + Object.values(tt.data.document.pages[tt.data.currentParentId].shapes) + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + ).toStrictEqual(['2', '1', '4', '3']) + }) +}) diff --git a/__tests__/coop.test.ts b/__tests__/coop.test.ts new file mode 100644 index 000000000..75f78b0f1 --- /dev/null +++ b/__tests__/coop.test.ts @@ -0,0 +1,46 @@ +import state from 'state' +import coopState from 'state/coop/coop-state' +import * as json from './__mocks__/document.json' + +state.reset() +state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) +state.send('CLEARED_PAGE') + +coopState.reset() + +describe('coop', () => { + it('joins a room', () => { + // TODO + null + }) + + it('leaves a room', () => { + // TODO + null + }) + + it('rejoins a room', () => { + // TODO + null + }) + + it('handles another user joining room', () => { + // TODO + null + }) + + it('handles another user leaving room', () => { + // TODO + null + }) + + it('sends mouse movements', () => { + // TODO + null + }) + + it('receives mouse movements', () => { + // TODO + null + }) +}) diff --git a/__tests__/create.test.ts b/__tests__/create.test.ts index ded87a0c2..0b276df52 100644 --- a/__tests__/create.test.ts +++ b/__tests__/create.test.ts @@ -7,18 +7,22 @@ state.send('CLEARED_PAGE') describe('arrow shape', () => { it('creates a shape', () => { + // TODO null }) it('cancels shape while creating', () => { + // TODO null }) it('removes shape on undo and restores it on redo', () => { + // TODO null }) it('does not create shape when readonly', () => { + // TODO null }) }) diff --git a/__tests__/dashes.test.ts b/__tests__/dashes.test.ts index 79ff5cee2..e53264ba9 100644 --- a/__tests__/dashes.test.ts +++ b/__tests__/dashes.test.ts @@ -1,4 +1,4 @@ -import { getPerfectDashProps } from 'utils/dashes' +import { getPerfectDashProps } from 'utils' describe('ellipse dash props', () => { it('renders dashed props on a circle correctly', () => { diff --git a/__tests__/delete.test.ts b/__tests__/delete.test.ts index 4a3e4edd9..b9d539605 100644 --- a/__tests__/delete.test.ts +++ b/__tests__/delete.test.ts @@ -1,157 +1,120 @@ -import state from 'state' -import inputs from 'state/inputs' import { ShapeType } from 'types' -import { - idsAreSelected, - point, - rectangleId, - arrowId, - getOnlySelectedShape, - assertShapeProps, -} from './test-utils' -import tld from 'utils/tld' -import * as json from './__mocks__/document.json' +import TestState, { rectangleId, arrowId } from './test-utils' describe('deleting single shapes', () => { - state.reset() - state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) + const tt = new TestState() - it('deletes a shape and undoes the delete', () => { - state - .send('CANCELED') - .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) - .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) + describe('deleting single shapes', () => { + it('deletes a shape and undoes the delete', () => { + tt.deselectAll().clickShape(rectangleId).pressDelete() - expect(idsAreSelected(state.data, [rectangleId])).toBe(true) + expect(tt.idsAreSelected([])).toBe(true) - state.send('DELETED') + expect(tt.getShape(rectangleId)).toBe(undefined) - expect(idsAreSelected(state.data, [])).toBe(true) - expect(tld.getShape(state.data, rectangleId)).toBe(undefined) + tt.undo() - state.send('UNDO') + expect(tt.getShape(rectangleId)).toBeTruthy() + expect(tt.idsAreSelected([rectangleId])).toBe(true) - expect(tld.getShape(state.data, rectangleId)).toBeTruthy() - expect(idsAreSelected(state.data, [rectangleId])).toBe(true) + tt.redo() - state.send('REDO') - - expect(tld.getShape(state.data, rectangleId)).toBe(undefined) - - state.send('UNDO') - }) -}) - -describe('deletes and restores grouped shapes', () => { - state.reset() - state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) - - it('creates a group', () => { - state - .send('CANCELED') - .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) - .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) - .send( - 'POINTED_SHAPE', - inputs.pointerDown(point({ shiftKey: true }), arrowId) - ) - .send( - 'STOPPED_POINTING', - inputs.pointerUp(point({ shiftKey: true }), arrowId) - ) - - expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true) - - state.send('GROUPED') - - const group = getOnlySelectedShape(state.data) - - // Should select the group - expect(assertShapeProps(group, { type: ShapeType.Group })) - - const arrow = tld.getShape(state.data, arrowId) - - // The arrow should be have the group as its parent - expect(assertShapeProps(arrow, { parentId: group.id })) + expect(tt.getShape(rectangleId)).toBe(undefined) + }) }) - // it('selects the new group', () => { - // expect(idsAreSelected(state.data, [groupId])).toBe(true) - // }) + describe('deleting and restoring grouped shapes', () => { + it('creates a group', () => { + tt.reset() + .deselectAll() + .clickShape(rectangleId) + .clickShape(arrowId, { shiftKey: true }) + .send('GROUPED') - // it('assigns a new parent', () => { - // expect(groupId === state.data.currentPageId).toBe(false) - // }) + const group = tt.getOnlySelectedShape() - // // Rectangle has the same new parent? - // it('assigns new parent to all selected shapes', () => { - // expect(hasParent(state.data, arrowId, groupId)).toBe(true) - // }) + // Should select the group + expect(tt.assertShapeProps(group, { type: ShapeType.Group })).toBe(true) - // // New parent is selected? - // it('selects the new parent', () => { - // expect(idsAreSelected(state.data, [groupId])).toBe(true) - // }) + const arrow = tt.getShape(arrowId) + + // The arrow should be have the group as its parent + expect(tt.assertShapeProps(arrow, { parentId: group.id })).toBe(true) + }) + + it('selects the new group', () => { + const groupId = tt.getShape(arrowId).parentId + + expect(tt.idsAreSelected([groupId])).toBe(true) + }) + + it('assigns a new parent', () => { + const groupId = tt.getShape(arrowId).parentId + + expect(groupId === tt.data.currentPageId).toBe(false) + }) + + // Rectangle has the same new parent? + it('assigns new parent to all selected shapes', () => { + const groupId = tt.getShape(arrowId).parentId + + expect(tt.hasParent(arrowId, groupId)).toBe(true) + }) + }) + + describe('selecting within the group', () => { + it('selects the group when pointing a shape', () => { + const groupId = tt.getShape(arrowId).parentId + + tt.deselectAll().clickShape(rectangleId) + + expect(tt.idsAreSelected([groupId])).toBe(true) + }) + + it('keeps selection when pointing group shape', () => { + const groupId = tt.getShape(arrowId).parentId + + tt.deselectAll().clickShape(groupId) + + expect(tt.idsAreSelected([groupId])).toBe(true) + }) + + it('selects a grouped shape by double-pointing', () => { + tt.deselectAll().doubleClickShape(rectangleId) + + expect(tt.idsAreSelected([rectangleId])).toBe(true) + }) + + it('selects a sibling on point after double-pointing into a grouped shape children', () => { + tt.deselectAll().doubleClickShape(rectangleId).clickShape(arrowId) + + expect(tt.idsAreSelected([arrowId])).toBe(true) + }) + + it('rises up a selection level when escape is pressed', () => { + const groupId = tt.getShape(arrowId).parentId + + tt.deselectAll().doubleClickShape(rectangleId).send('CANCELLED') + + tt.clickShape(rectangleId) + + expect(tt.idsAreSelected([groupId])).toBe(true) + }) + + // it('deletes and restores one shape', () => { + // // Delete the rectangle first + // state.send('UNDO') + + // expect(tld.getShape(tt.data, rectangleId)).toBeTruthy() + // expect(tt.idsAreSelected([rectangleId])).toBe(true) + + // state.send('REDO') + + // expect(tld.getShape(tt.data, rectangleId)).toBe(undefined) + + // state.send('UNDO') + + // expect(tld.getShape(tt.data, rectangleId)).toBeTruthy() + // expect(tt.idsAreSelected([rectangleId])).toBe(true) + }) }) - -// // it('selects the group when pointing a shape', () => { -// // state -// // .send('CANCELED') -// // .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) -// // .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) - -// // expect(idsAreSelected(state.data, [groupId])).toBe(true) -// // }) - -// // it('keeps selection when pointing bounds', () => { -// // state -// // .send('CANCELED') -// // .send('POINTED_BOUNDS', inputs.pointerDown(point(), 'bounds')) -// // .send('STOPPED_POINTING', inputs.pointerUp(point(), 'bounds')) - -// // expect(idsAreSelected(state.data, [groupId])).toBe(true) -// // }) - -// // it('selects a grouped shape by double-pointing', () => { -// // state -// // .send('CANCELED') -// // .send('DOUBLE_POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) -// // .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) - -// // expect(idsAreSelected(state.data, [rectangleId])).toBe(true) -// // }) - -// // it('selects a sibling on point when selecting a grouped shape', () => { -// // state -// // .send('POINTED_SHAPE', inputs.pointerDown(point(), arrowId)) -// // .send('STOPPED_POINTING', inputs.pointerUp(point(), arrowId)) - -// // expect(idsAreSelected(state.data, [arrowId])).toBe(true) -// // }) - -// // it('rises up a selection level when escape is pressed', () => { -// // state -// // .send('CANCELED') -// // .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) -// // .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) - -// // expect(idsAreSelected(state.data, [groupId])).toBe(true) -// // }) - -// // it('deletes and restores one shape', () => { -// // // Delete the rectangle first -// // state.send('UNDO') - -// // expect(tld.getShape(state.data, rectangleId)).toBeTruthy() -// // expect(idsAreSelected(state.data, [rectangleId])).toBe(true) - -// // state.send('REDO') - -// // expect(tld.getShape(state.data, rectangleId)).toBe(undefined) - -// // state.send('UNDO') - -// // expect(tld.getShape(state.data, rectangleId)).toBeTruthy() -// // expect(idsAreSelected(state.data, [rectangleId])).toBe(true) -// // }) -// }) diff --git a/__tests__/locked.test.ts b/__tests__/locked.test.ts new file mode 100644 index 000000000..12eaca8b0 --- /dev/null +++ b/__tests__/locked.test.ts @@ -0,0 +1,61 @@ +import TestState from './test-utils' + +describe('locked shapes', () => { + const tt = new TestState() + tt.resetDocumentState() + + it('toggles a locked shape', () => { + // TODO + null + }) + + it('selects a locked shape', () => { + // TODO + null + }) + + it('does not translate a locked shape', () => { + // TODO + null + }) + + it('does not translate a locked shape in a group', () => { + // TODO + null + }) + + it('does not rotate a locked shape', () => { + // TODO + null + }) + + it('does not rotate a locked shape in a group', () => { + // TODO + null + }) + + it('dpes not transform a locked single shape', () => { + // TODO + null + }) + + it('does not transform a locked shape in a multiple selection', () => { + // TODO + null + }) + + it('does not transform a locked shape in a group', () => { + // TODO + null + }) + + it('does not change the style of a locked shape', () => { + // TODO + null + }) + + it('does not change the handles of a locked shape', () => { + // TODO + null + }) +}) diff --git a/__tests__/project.test.ts b/__tests__/project.test.ts index 6ae8325b0..6c7e0c920 100644 --- a/__tests__/project.test.ts +++ b/__tests__/project.test.ts @@ -7,12 +7,36 @@ describe('project', () => { it('mounts the state', () => { state.send('MOUNTED') + expect(state.isIn('ready')).toBe(true) }) it('loads file from json', () => { state.send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) + expect(state.isIn('ready')).toBe(true) expect(state.data.document).toMatchSnapshot('data after mount from file') }) }) + +describe('restoring project', () => { + state.reset() + state.enableLog(true) + + it('remounts the state after mutating the current state', () => { + state + .send('MOUNTED') + .send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) + .send('CLEARED_PAGE') + + expect( + state.data.document.pages[state.data.currentPageId].shapes + ).toStrictEqual({}) + + state + .send('MOUNTED') + .send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) + + expect(state.data.document).toMatchSnapshot('data after re-mount from file') + }) +}) diff --git a/__tests__/selection.test.ts b/__tests__/selection.test.ts index 3aaed98db..960bace48 100644 --- a/__tests__/selection.test.ts +++ b/__tests__/selection.test.ts @@ -1,139 +1,81 @@ -import state from 'state' -import inputs from 'state/inputs' -import { idsAreSelected, point, rectangleId, arrowId } from './test-utils' -import * as json from './__mocks__/document.json' - -// Mount the state and load the test file from json -state.reset() -state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) +import TestState, { rectangleId, arrowId } from './test-utils' describe('selection', () => { - it('selects a shape', () => { - state - .send('CANCELED') - .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) - .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) + const tt = new TestState() - expect(idsAreSelected(state.data, [rectangleId])).toBe(true) + it('selects a shape', () => { + tt.deselectAll().clickShape(rectangleId) + + expect(tt.idsAreSelected([rectangleId])).toBe(true) }) it('selects and deselects a shape', () => { - state - .send('CANCELED') - .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) - .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) + tt.deselectAll().clickShape(rectangleId).clickCanvas() - expect(idsAreSelected(state.data, [rectangleId])).toBe(true) - - state - .send('POINTED_CANVAS', inputs.pointerDown(point(), 'canvas')) - .send('STOPPED_POINTING', inputs.pointerUp(point(), 'canvas')) - - expect(idsAreSelected(state.data, [])).toBe(true) + expect(tt.idsAreSelected([])).toBe(true) }) it('selects multiple shapes', () => { - expect(idsAreSelected(state.data, [])).toBe(true) + tt.deselectAll() + .clickShape(rectangleId) + .clickShape(arrowId, { shiftKey: true }) - state - .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) - .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) - .send( - 'POINTED_SHAPE', - inputs.pointerDown(point({ shiftKey: true }), arrowId) - ) - .send( - 'STOPPED_POINTING', - inputs.pointerUp(point({ shiftKey: true }), arrowId) - ) - - expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true) + expect(tt.idsAreSelected([rectangleId, arrowId])).toBe(true) }) it('shift-selects to deselect shapes', () => { - state - .send('CANCELLED') - .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) - .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) - .send( - 'POINTED_SHAPE', - inputs.pointerDown(point({ shiftKey: true }), arrowId) - ) - .send( - 'STOPPED_POINTING', - inputs.pointerUp(point({ shiftKey: true }), arrowId) - ) - .send( - 'POINTED_SHAPE', - inputs.pointerDown(point({ shiftKey: true }), rectangleId) - ) - .send( - 'STOPPED_POINTING', - inputs.pointerUp(point({ shiftKey: true }), rectangleId) - ) + tt.deselectAll() + .clickShape(rectangleId) + .clickShape(arrowId, { shiftKey: true }) + .clickShape(rectangleId, { shiftKey: true }) - expect(idsAreSelected(state.data, [arrowId])).toBe(true) + expect(tt.idsAreSelected([arrowId])).toBe(true) }) - it('single-selects shape in selection on pointerup', () => { - state - .send('CANCELLED') - .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) - .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) - .send( - 'POINTED_SHAPE', - inputs.pointerDown(point({ shiftKey: true }), arrowId) - ) - .send( - 'STOPPED_POINTING', - inputs.pointerUp(point({ shiftKey: true }), arrowId) - ) + it('single-selects shape in selection on click', () => { + tt.deselectAll() + .clickShape(rectangleId) + .clickShape(arrowId, { shiftKey: true }) + .clickShape(arrowId) - expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true) + expect(tt.idsAreSelected([arrowId])).toBe(true) + }) - state.send('POINTED_SHAPE', inputs.pointerDown(point(), arrowId)) + it('single-selects shape in selection on pointerup only', () => { + tt.deselectAll() + .clickShape(rectangleId) + .clickShape(arrowId, { shiftKey: true }) - expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true) + expect(tt.idsAreSelected([rectangleId, arrowId])).toBe(true) - state.send('STOPPED_POINTING', inputs.pointerUp(point(), arrowId)) + tt.startClick(arrowId) - expect(idsAreSelected(state.data, [arrowId])).toBe(true) + expect(tt.idsAreSelected([rectangleId, arrowId])).toBe(true) + + tt.stopClick(arrowId) + + expect(tt.idsAreSelected([arrowId])).toBe(true) }) it('selects shapes if shift key is lifted before pointerup', () => { - state - .send('CANCELLED') - .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId)) - .send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId)) - .send( - 'POINTED_SHAPE', - inputs.pointerDown(point({ shiftKey: true }), arrowId) - ) - .send( - 'STOPPED_POINTING', - inputs.pointerUp(point({ shiftKey: true }), arrowId) - ) - .send( - 'POINTED_SHAPE', - inputs.pointerDown(point({ shiftKey: true }), arrowId) - ) - .send('STOPPED_POINTING', inputs.pointerUp(point(), arrowId)) + tt.deselectAll() + .clickShape(rectangleId) + .clickShape(arrowId, { shiftKey: true }) + .startClick(rectangleId, { shiftKey: true }) + .stopClick(rectangleId) - expect(idsAreSelected(state.data, [arrowId])).toBe(true) + expect(tt.idsAreSelected([rectangleId])).toBe(true) }) it('does not select on meta-click', () => { - state - .send('CANCELLED') - .send( - 'POINTED_SHAPE', - inputs.pointerDown(point({ ctrlKey: true }), rectangleId) - ) - .send( - 'STOPPED_POINTING', - inputs.pointerUp(point({ ctrlKey: true }), rectangleId) - ) + tt.deselectAll().clickShape(rectangleId, { ctrlKey: true }) - expect(idsAreSelected(state.data, [])).toBe(true) + expect(tt.idsAreSelected([])).toBe(true) + }) + + it('does not select on meta-shift-click', () => { + tt.deselectAll().clickShape(rectangleId, { ctrlKey: true, shiftKey: true }) + + expect(tt.idsAreSelected([])).toBe(true) }) }) diff --git a/__tests__/shapes/arrow.test.ts b/__tests__/shapes/arrow.test.ts index 52e381751..d76bf74ff 100644 --- a/__tests__/shapes/arrow.test.ts +++ b/__tests__/shapes/arrow.test.ts @@ -7,62 +7,74 @@ state.send('CLEARED_PAGE') describe('arrow shape', () => { it('creates shape', () => { + // TODO null }) it('cancels shape while creating', () => { + // TODO null }) it('moves shape', () => { + // TODO null }) it('rotates shape', () => { + // TODO null }) - it('measures bounds', () => { + it('rotates shape in a group', () => { + // TODO null }) - it('measures rotated bounds', () => { + it('measures shape bounds', () => { + // TODO null }) - it('transforms single', () => { + it('measures shape rotated bounds', () => { + // TODO + null + }) + + it('transforms single shape', () => { + // TODO null }) it('transforms in a group', () => { + // TODO null }) /* -------------------- Specific -------------------- */ it('creates compass-aligned shape with shift key', () => { + // TODO null }) it('changes start handle', () => { + // TODO null }) it('changes end handle', () => { + // TODO null }) it('changes bend handle', () => { + // TODO null }) it('resets bend handle when double-pointed', () => { - null - }) - - /* -------------------- Readonly -------------------- */ - - it('does not create shape when readonly', () => { + // TODO null }) }) diff --git a/__tests__/style.test.ts b/__tests__/style.test.ts new file mode 100644 index 000000000..41037b8c0 --- /dev/null +++ b/__tests__/style.test.ts @@ -0,0 +1,28 @@ +import state from 'state' +import * as json from './__mocks__/document.json' + +state.reset() +state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) +state.send('CLEARED_PAGE') + +describe('shape styles', () => { + it('sets the color style of a shape', () => { + // TODO + null + }) + + it('sets the size style of a shape', () => { + // TODO + null + }) + + it('sets the dash style of a shape', () => { + // TODO + null + }) + + it('sets the isFilled style of a shape', () => { + // TODO + null + }) +}) diff --git a/__tests__/test-utils.ts b/__tests__/test-utils.ts index a928b7aee..6594d74df 100644 --- a/__tests__/test-utils.ts +++ b/__tests__/test-utils.ts @@ -1,11 +1,18 @@ -import { Data, Shape, ShapeType } from 'types' +import _state from 'state' import tld from 'utils/tld' +import inputs from 'state/inputs' +import { createShape, getShapeUtils } from 'state/shape-utils' +import { Data, Shape, ShapeType, ShapeUtility } from 'types' +import { deepCompareArrays, uniqueId, vec } from 'utils' +import * as json from './__mocks__/document.json' + +type State = typeof _state export const rectangleId = '1f6c251c-e12e-40b4-8dd2-c1847d80b72f' export const arrowId = '5ca167d7-54de-47c9-aa8f-86affa25e44d' interface PointerOptions { - id?: string + id?: number x?: number y?: number shiftKey?: boolean @@ -13,77 +20,573 @@ interface PointerOptions { ctrlKey?: boolean } -export function point( - options: PointerOptions = {} as PointerOptions -): PointerEvent { - const { - id = '1', - x = 0, - y = 0, - shiftKey = false, - altKey = false, - ctrlKey = false, - } = options +class TestState { + state: State - return { - shiftKey, - altKey, - ctrlKey, - pointerId: id, - clientX: x, - clientY: y, - } as any -} + constructor() { + this.state = _state + this.reset() + } -export function idsAreSelected( - data: Data, - ids: string[], - strict = true -): boolean { - const selectedIds = tld.getSelectedIds(data) - return ( - (strict ? selectedIds.size === ids.length : true) && - ids.every((id) => selectedIds.has(id)) - ) -} + /** + * Reset the test state. + * + * ### Example + * + *```ts + * tt.reset() + *``` + */ + reset(): TestState { + this.state.reset() + this.state + .send('MOUNTED') + .send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) -export function hasParent( - data: Data, - childId: string, - parentId: string -): boolean { - return tld.getShape(data, childId).parentId === parentId -} + return this + } -export function getOnlySelectedShape(data: Data): Shape { - const selectedShapes = tld.getSelectedShapes(data) - return selectedShapes.length === 1 ? selectedShapes[0] : undefined -} + /** + * Reset the document state. Will remove all shapes and extra pages. + * + * ### Example + * + *```ts + * tt.resetDocumentState() + *``` + */ + resetDocumentState(): TestState { + this.state.send('RESET_DOCUMENT_STATE') + return this + } -export function assertShapeType( - data: Data, - shapeId: string, - type: ShapeType -): boolean { - const shape = tld.getShape(data, shapeId) - if (shape.type !== type) { - throw new TypeError( - `expected shape ${shapeId} to be of type ${type}, found ${shape?.type} instead` + /** + * Send a message to the state. + * + * ### Example + * + *```ts + * tt.send("MOVED_TO_FRONT") + *``` + */ + send(eventName: string, payload?: unknown): TestState { + this.state.send(eventName, payload) + return this + } + + /** + * Create a new shape on the current page. Optionally provide an id. + * + * ### Example + * + *```ts + * tt.createShape({ type: ShapeType.Rectangle, point: [100, 100]}) + * tt.createShape({ type: ShapeType.Rectangle, point: [100, 100]}, "myId") + *``` + */ + createShape(props: Partial, id = uniqueId()): TestState { + const shape = createShape(props.type, props) + getShapeUtils(shape).setProperty(shape, 'id', id) + this.data.document.pages[this.data.currentPageId].shapes[shape.id] = shape + return this + } + + /** + * Get whether the provided ids are the current selected ids. If the `strict` argument is `true`, then the result will be false if the state has selected ids in addition to those provided. + * + * ### Example + * + *```ts + * tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId']) + * tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId'], true) + *``` + */ + idsAreSelected(ids: string[], strict = true): boolean { + const selectedIds = tld.getSelectedIds(this.data) + return ( + (strict ? selectedIds.size === ids.length : true) && + ids.every((id) => selectedIds.has(id)) ) } - return true -} -export function assertShapeProps( - shape: T, - props: { [K in keyof Partial]: T[K] } -): boolean { - for (const key in props) { - if (shape[key] !== props[key]) { + /** + * Get whether the shape with the provided id has the provided parent id. + * + * ### Example + * + *```ts + * tt.hasParent('childId', 'parentId') + *``` + */ + hasParent(childId: string, parentId: string): boolean { + return tld.getShape(this.data, childId).parentId === parentId + } + + /** + * Get the only selected shape. If more than one shape is selected, the test will fail. + * + * ### Example + * + *```ts + * tt.getOnlySelectedShape() + *``` + */ + getOnlySelectedShape(): Shape { + const selectedShapes = tld.getSelectedShapes(this.data) + return selectedShapes.length === 1 ? selectedShapes[0] : undefined + } + + /** + * Assert that a shape has the provided type. + * + * ### Example + * + *```ts + * tt.example + *``` + */ + assertShapeType(shapeId: string, type: ShapeType): boolean { + const shape = tld.getShape(this.data, shapeId) + if (shape.type !== type) { throw new TypeError( - `expected shape ${shape.id} to have property ${key}: ${props[key]}, found ${key}: ${shape[key]} instead` + `expected shape ${shapeId} to be of type ${type}, found ${shape?.type} instead` ) } + return true + } + + /** + * Assert that the provided shape has the provided props. + * + * ### Example + * + *``` + * tt.assertShapeProps(myShape, { point: [0,0], style: { color: ColorStyle.Blue } } ) + *``` + */ + assertShapeProps( + shape: T, + props: { [K in keyof Partial]: T[K] } + ): boolean { + for (const key in props) { + let result: boolean + const value = props[key] + + if (Array.isArray(value)) { + result = deepCompareArrays(value, shape[key] as typeof value) + } else if (typeof value === 'object') { + const target = shape[key] as typeof value + result = + target && + Object.entries(value).every(([k, v]) => target[k] === props[key][v]) + } else { + result = shape[key] === value + } + + if (!result) { + throw new TypeError( + `expected shape ${shape.id} to have property ${key}: ${props[key]}, found ${key}: ${shape[key]} instead` + ) + } + } + + return true + } + + /** + * Click a shape. + * + * ### Example + * + *```ts + * tt.clickShape("myShapeId") + *``` + */ + clickShape(id: string, options: PointerOptions = {}): TestState { + this.state + .send('POINTED_SHAPE', inputs.pointerDown(TestState.point(options), id)) + .send('STOPPED_POINTING', inputs.pointerUp(TestState.point(options), id)) + + return this + } + + /** + * Start a click (but do not stop it). + * + * ### Example + * + *```ts + * tt.startClick("myShapeId") + *``` + */ + startClick(id: string, options: PointerOptions = {}): TestState { + this.state.send( + 'POINTED_SHAPE', + inputs.pointerDown(TestState.point(options), id) + ) + + return this + } + + /** + * Stop a click (after starting it). + * + * ### Example + * + *```ts + * tt.stopClick("myShapeId") + *``` + */ + stopClick(id: string, options: PointerOptions = {}): TestState { + this.state.send( + 'STOPPED_POINTING', + inputs.pointerUp(TestState.point(options), id) + ) + + return this + } + + /** + * Double click a shape. + * + * ### Example + * + *```ts + * tt.clickShape("myShapeId") + *``` + */ + doubleClickShape(id: string, options: PointerOptions = {}): TestState { + this.state + .send( + 'DOUBLE_POINTED_SHAPE', + inputs.pointerDown(TestState.point(options), id) + ) + .send('STOPPED_POINTING', inputs.pointerUp(TestState.point(options), id)) + + return this + } + + /** + * Click the canvas. + * + * ### Example + * + *```ts + * tt.clickCanvas("myShapeId") + *``` + */ + clickCanvas(options: PointerOptions = {}): TestState { + this.state + .send( + 'POINTED_CANVAS', + inputs.pointerDown(TestState.point(options), 'canvas') + ) + .send( + 'STOPPED_POINTING', + inputs.pointerUp(TestState.point(options), 'canvas') + ) + + return this + } + + /** + * Click the background / body of the bounding box. + * + * ### Example + * + *```ts + * tt.clickBounds() + *``` + */ + clickBounds(options: PointerOptions = {}): TestState { + this.state + .send( + 'POINTED_BOUNDS', + inputs.pointerDown(TestState.point(options), 'bounds') + ) + .send( + 'STOPPED_POINTING', + inputs.pointerUp(TestState.point(options), 'bounds') + ) + + return this + } + + /** + * Move the pointer to a new point, or to several points in order. + * + * ### Example + * + *```ts + * tt.movePointerTo([100, 100]) + * tt.movePointerTo([100, 100], { shiftKey: true }) + * tt.movePointerTo([[100, 100], [150, 150], [200, 200]]) + *``` + */ + movePointerTo( + to: number[] | number[][], + options: Omit = {} + ): TestState { + if (Array.isArray(to[0])) { + ;(to as number[][]).forEach(([x, y]) => { + this.state.send( + 'MOVED_POINTER', + inputs.pointerMove(TestState.point({ x, y, ...options })) + ) + }) + } else { + const [x, y] = to as number[] + this.state.send( + 'MOVED_POINTER', + inputs.pointerMove(TestState.point({ x, y, ...options })) + ) + } + + return this + } + + /** + * Move the pointer by a delta. + * + * ### Example + * + *```ts + * tt.movePointerBy([10,10]) + * tt.movePointerBy([10,10], { shiftKey: true }) + *``` + */ + movePointerBy( + by: number[] | number[][], + options: Omit = {} + ): TestState { + let pt = inputs.pointer?.point || [0, 0] + + if (Array.isArray(by[0])) { + ;(by as number[][]).forEach((delta) => { + pt = vec.add(pt, delta) + + this.state.send( + 'MOVED_POINTER', + inputs.pointerMove( + TestState.point({ x: pt[0], y: pt[1], ...options }) + ) + ) + }) + } else { + pt = vec.add(pt, by as number[]) + + this.state.send( + 'MOVED_POINTER', + inputs.pointerMove(TestState.point({ x: pt[0], y: pt[1], ...options })) + ) + } + + return this + } + + /** + * Move pointer over a shape. Will move the pointer to the top-left corner of the shape. + * + * ### + * ``` + * tt.movePointerOverShape('myShapeId', [100, 100]) + * ``` + */ + movePointerOverShape( + id: string, + options: Omit = {} + ): TestState { + const shape = tld.getShape(this.state.data, id) + const [x, y] = vec.add(shape.point, [1, 1]) + + this.state.send( + 'MOVED_OVER_SHAPE', + inputs.pointerEnter(TestState.point({ x, y, ...options }), id) + ) + + return this + } + + /** + * Move the pointer over a group. Will move the pointer to the top-left corner of the group. + * + * ### Example + * + *```ts + * tt.movePointerOverHandle('myGroupId') + * tt.movePointerOverHandle('myGroupId', { shiftKey: true }) + *``` + */ + movePointerOverGroup( + id: string, + options: Omit = {} + ): TestState { + const shape = tld.getShape(this.state.data, id) + const [x, y] = vec.add(shape.point, [1, 1]) + + this.state.send( + 'MOVED_OVER_GROUP', + inputs.pointerEnter(TestState.point({ x, y, ...options }), id) + ) + + return this + } + + /** + * Move the pointer over a handle. Will move the pointer to the top-left corner of the handle. + * + * ### Example + * + *```ts + * tt.movePointerOverHandle('bend') + * tt.movePointerOverHandle('bend', { shiftKey: true }) + *``` + */ + movePointerOverHandle( + id: string, + options: Omit = {} + ): TestState { + const shape = tld.getShape(this.state.data, id) + const handle = shape.handles?.[id] + const [x, y] = vec.add(handle.point, [1, 1]) + + this.state.send( + 'MOVED_OVER_HANDLE', + inputs.pointerEnter(TestState.point({ x, y, ...options }), id) + ) + + return this + } + + /** + * Deselect all shapes. + * + * ### Example + * + *```ts + * tt.deselectAll() + *``` + */ + deselectAll(): TestState { + this.state.send('DESELECTED_ALL') + return this + } + + /** + * Delete the selected shapes + * + * ### Example + * + *```ts + * tt.pressDelete() + *``` + */ + pressDelete(): TestState { + this.state.send('DELETED') + return this + } + + /** + * Get a shape and test it. + * + * ### Example + * + *```ts + * tt.testShape("myShapeId", myShape => myShape ) + *``` + */ + testShape( + id: string, + fn: (shape: T, shapeUtils: ShapeUtility) => boolean + ): boolean { + const shape = this.getShape(id) + return fn(shape, shape && getShapeUtils(shape)) + } + + /** + * Get a shape + * + * ### Example + * + *```ts + * tt.getShape("myShapeId") + *``` + */ + getShape(id: string): T { + return tld.getShape(this.data, id) as T + } + + /** + * Undo. + * + * ### Example + * + *```ts + * tt.undo() + *``` + */ + undo(): TestState { + this.state.send('UNDO') + return this + } + + /** + * Redo. + * + * ### Example + * + *```ts + * tt.redo() + *``` + */ + redo(): TestState { + this.state.send('REDO') + return this + } + + /** + * Get the state's current data. + * + * ### Example + * + *```ts + * tt.data + *``` + */ + get data(): Readonly { + return this.state.data + } + + /** + * Get a fake PointerEvent. + * + * ### Example + * + *```ts + * tt.point() + * tt.point({ x: 0, y: 0}) + * tt.point({ x: 0, y: 0, shiftKey: true } ) + *``` + */ + static point(options: PointerOptions = {} as PointerOptions): PointerEvent { + const { + id = 1, + x = 0, + y = 0, + shiftKey = false, + altKey = false, + ctrlKey = false, + } = options + + return { + shiftKey, + altKey, + ctrlKey, + pointerId: id, + clientX: x, + clientY: y, + } as any } - return true } + +export default TestState diff --git a/__tests__/transform.test.ts b/__tests__/transform.test.ts new file mode 100644 index 000000000..e3c87506b --- /dev/null +++ b/__tests__/transform.test.ts @@ -0,0 +1,91 @@ +import state from 'state' +import * as json from './__mocks__/document.json' + +state.reset() +state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) +state.send('CLEARED_PAGE') + +describe('transforms shapes', () => { + it('transforms from the top edge', () => { + // TODO + null + }) + + it('transforms from the right edge', () => { + // TODO + null + }) + + it('transforms from the bottom edge', () => { + // TODO + null + }) + + it('transforms from the left edge', () => { + // TODO + null + }) + + it('transforms from the top-left corner', () => { + // TODO + null + }) + + it('transforms from the top-right corner', () => { + // TODO + null + }) + + it('transforms from the bottom-right corner', () => { + // TODO + null + }) + + it('transforms from the bottom-left corner', () => { + // TODO + null + }) +}) + +describe('transforms shapes while aspect-ratio locked', () => { + // Fixed + + it('transforms from the top edge while aspect-ratio locked', () => { + // TODO + null + }) + + it('transforms from the right edge while aspect-ratio locked', () => { + // TODO + null + }) + + it('transforms from the bottom edge while aspect-ratio locked', () => { + // TODO + null + }) + it('transforms from the left edge while aspect-ratio locked', () => { + // TODO + null + }) + + it('transforms from the top-left corner while aspect-ratio locked', () => { + // TODO + null + }) + + it('transforms from the top-right corner while aspect-ratio locked', () => { + // TODO + null + }) + + it('transforms from the bottom-right corner while aspect-ratio locked', () => { + // TODO + null + }) + + it('transforms from the bottom-left corner while aspect-ratio locked', () => { + // TODO + null + }) +}) diff --git a/__tests__/translate.test.ts b/__tests__/translate.test.ts new file mode 100644 index 000000000..6edcf263b --- /dev/null +++ b/__tests__/translate.test.ts @@ -0,0 +1,38 @@ +import state from 'state' +import * as json from './__mocks__/document.json' + +state.reset() +state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) +state.send('CLEARED_PAGE') + +describe('translates shapes', () => { + it('translates a single selected shape', () => { + // TODO + null + }) + + it('translates multiple selected shape', () => { + // TODO + null + }) + + it('translates while axis-locked', () => { + // TODO + null + }) + + it('translates after leaving axis-locked state', () => { + // TODO + null + }) + + it('creates clones while translating', () => { + // TODO + null + }) + + it('removes clones after leaving cloning state', () => { + // TODO + null + }) +}) diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 6d3c6a7cb..f68d6b0ce 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -71,8 +71,8 @@ const MainSVG = styled('svg', { function ErrorFallback({ error, resetErrorBoundary }) { React.useEffect(() => { - console.error(error) const copy = 'Sorry, something went wrong. Clear canvas and continue?' + console.error(error) if (window.confirm(copy)) { state.send('CLEARED_PAGE') resetErrorBoundary() diff --git a/components/canvas/coop/coop.tsx b/components/canvas/coop/coop.tsx index 9cec229bc..3d576889b 100644 --- a/components/canvas/coop/coop.tsx +++ b/components/canvas/coop/coop.tsx @@ -6,23 +6,24 @@ export default function Presence(): JSX.Element { const others = useCoopSelector((s) => s.data.others) const currentPageId = useSelector((s) => s.data.currentPageId) + if (!others) return null + return ( <> - {Object.values(others).map(({ connectionId, presence }) => { - if (presence === null) return null - if (presence.pageId !== currentPageId) return null - - return ( - - ) - })} + {Object.values(others) + .filter(({ presence }) => presence?.pageId === currentPageId) + .map(({ connectionId, presence }) => { + return ( + + ) + })} ) } diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index fbb4a6d8d..fc3f2f0f2 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -8,11 +8,11 @@ import useShapeEvents from 'hooks/useShapeEvents' import vec from 'utils/vec' import { getShapeStyle } from 'state/shape-styles' import useShapeDef from 'hooks/useShape' -import { ShapeUtility } from 'types' +import { BooleanArraySupportOption } from 'prettier' interface ShapeProps { id: string - isSelecting: boolean + isSelecting: BooleanArraySupportOption } function Shape({ id, isSelecting }: ShapeProps): JSX.Element { @@ -51,28 +51,27 @@ function Shape({ id, isSelecting }: ShapeProps): JSX.Element { ` }) - // From here on, not reactive—if we're here, we can trust that the - // shape in state is a shape with changes that we need to render. + const isCurrentParent = useSelector((s) => { + return s.data.currentParentId === id + }) + + const events = useShapeEvents(id, isCurrentParent, rGroup) const shape = tld.getShape(state.data, id) - const shapeUtils = shape ? getShapeUtils(shape) : ({} as ShapeUtility) - - const { - isParent = false, - isForeignObject = false, - canStyleFill = false, - } = shapeUtils - - const events = useShapeEvents(id, isParent, rGroup) - if (!shape) return null + // From here on, not reactive—if we're here, we can trust that the + // shape in state is a shape with changes that we need to render. + + const { isParent, isForeignObject, canStyleFill } = getShapeUtils(shape) + return ( {isSelecting && @@ -204,4 +203,24 @@ const EventSoak = styled('use', { const StyledGroup = styled('g', { outline: 'none', + + '& > *[data-shy=true]': { + opacity: 0, + }, + + '&:hover': { + '& > *[data-shy=true]': { + opacity: 1, + }, + }, + + variants: { + isCurrentParent: { + true: { + '& > *[data-shy=true]': { + opacity: 1, + }, + }, + }, + }, }) diff --git a/components/code-panel/types-import.ts b/components/code-panel/types-import.ts index 51e742c0c..4df0ea3dc 100644 --- a/components/code-panel/types-import.ts +++ b/components/code-panel/types-import.ts @@ -145,43 +145,39 @@ interface GroupShape extends BaseShape { size: number[] } -// type DeepPartial = { -// [P in keyof T]?: DeepPartial -// } - type ShapeProps = { [P in keyof T]?: P extends 'style' ? Partial : T[P] } -type MutableShape = - | DotShape - | EllipseShape - | LineShape - | RayShape - | PolylineShape - | DrawShape - | RectangleShape - | ArrowShape - | TextShape - | GroupShape - -interface Shapes { - [ShapeType.Dot]: Readonly - [ShapeType.Ellipse]: Readonly - [ShapeType.Line]: Readonly - [ShapeType.Ray]: Readonly - [ShapeType.Polyline]: Readonly - [ShapeType.Draw]: Readonly - [ShapeType.Rectangle]: Readonly - [ShapeType.Arrow]: Readonly - [ShapeType.Text]: Readonly - [ShapeType.Group]: Readonly +interface MutableShapes { + [ShapeType.Dot]: DotShape + [ShapeType.Ellipse]: EllipseShape + [ShapeType.Line]: LineShape + [ShapeType.Ray]: RayShape + [ShapeType.Polyline]: PolylineShape + [ShapeType.Draw]: DrawShape + [ShapeType.Rectangle]: RectangleShape + [ShapeType.Arrow]: ArrowShape + [ShapeType.Text]: TextShape + [ShapeType.Group]: GroupShape } +type MutableShape = MutableShapes[keyof MutableShapes] + +type Shapes = { [K in keyof MutableShapes]: Readonly } + type Shape = Readonly type ShapeByType = Shapes[T] +type IsParent = 'children' extends RequiredKeys ? T : never + +type ParentShape = { + [K in keyof MutableShapes]: IsParent +}[keyof MutableShapes] + +type ParentTypes = ParentShape['type'] & 'page' + enum Decoration { Arrow = 'Arrow', } @@ -232,6 +228,15 @@ interface PointerInfo { altKey: boolean } +interface KeyboardInfo { + key: string + keys: string[] + shiftKey: boolean + ctrlKey: boolean + metaKey: boolean + altKey: boolean +} + enum Edge { Top = 'top_edge', Right = 'right_edge', @@ -276,8 +281,6 @@ interface BoundsSnapshot extends PointSnapshot { nh: number } -type Difference = A extends B ? never : A - type ShapeSpecificProps = Pick< T, Difference @@ -561,6 +564,16 @@ interface ShapeUtility { shouldRender(this: ShapeUtility, shape: K, previous: K): boolean } +/* -------------------------------------------------- */ +/* Utilities */ +/* -------------------------------------------------- */ + +type Difference = A extends B ? never : A + +type RequiredKeys = { + [K in keyof T]-?: Record extends Pick ? never : K +}[keyof T] + @@ -695,43 +708,39 @@ interface GroupShape extends BaseShape { size: number[] } -// type DeepPartial = { -// [P in keyof T]?: DeepPartial -// } - type ShapeProps = { [P in keyof T]?: P extends 'style' ? Partial : T[P] } -type MutableShape = - | DotShape - | EllipseShape - | LineShape - | RayShape - | PolylineShape - | DrawShape - | RectangleShape - | ArrowShape - | TextShape - | GroupShape - -interface Shapes { - [ShapeType.Dot]: Readonly - [ShapeType.Ellipse]: Readonly - [ShapeType.Line]: Readonly - [ShapeType.Ray]: Readonly - [ShapeType.Polyline]: Readonly - [ShapeType.Draw]: Readonly - [ShapeType.Rectangle]: Readonly - [ShapeType.Arrow]: Readonly - [ShapeType.Text]: Readonly - [ShapeType.Group]: Readonly +interface MutableShapes { + [ShapeType.Dot]: DotShape + [ShapeType.Ellipse]: EllipseShape + [ShapeType.Line]: LineShape + [ShapeType.Ray]: RayShape + [ShapeType.Polyline]: PolylineShape + [ShapeType.Draw]: DrawShape + [ShapeType.Rectangle]: RectangleShape + [ShapeType.Arrow]: ArrowShape + [ShapeType.Text]: TextShape + [ShapeType.Group]: GroupShape } +type MutableShape = MutableShapes[keyof MutableShapes] + +type Shapes = { [K in keyof MutableShapes]: Readonly } + type Shape = Readonly type ShapeByType = Shapes[T] +type IsParent = 'children' extends RequiredKeys ? T : never + +type ParentShape = { + [K in keyof MutableShapes]: IsParent +}[keyof MutableShapes] + +type ParentTypes = ParentShape['type'] & 'page' + enum Decoration { Arrow = 'Arrow', } @@ -782,6 +791,15 @@ interface PointerInfo { altKey: boolean } +interface KeyboardInfo { + key: string + keys: string[] + shiftKey: boolean + ctrlKey: boolean + metaKey: boolean + altKey: boolean +} + enum Edge { Top = 'top_edge', Right = 'right_edge', @@ -826,8 +844,6 @@ interface BoundsSnapshot extends PointSnapshot { nh: number } -type Difference = A extends B ? never : A - type ShapeSpecificProps = Pick< T, Difference @@ -1111,6 +1127,16 @@ interface ShapeUtility { shouldRender(this: ShapeUtility, shape: K, previous: K): boolean } +/* -------------------------------------------------- */ +/* Utilities */ +/* -------------------------------------------------- */ + +type Difference = A extends B ? never : A + +type RequiredKeys = { + [K in keyof T]-?: Record extends Pick ? never : K +}[keyof T] + diff --git a/hooks/useLoadOnMount.ts b/hooks/useLoadOnMount.ts index 5c5263b6a..caab8ba17 100644 --- a/hooks/useLoadOnMount.ts +++ b/hooks/useLoadOnMount.ts @@ -8,6 +8,7 @@ export default function useLoadOnMount(roomId?: string) { fonts.load('12px Verveine Regular', 'Fonts are loaded!').then(() => { state.send('MOUNTED') + if (roomId !== undefined) { state.send('RT_LOADED_ROOM', { id: roomId }) } diff --git a/hooks/useShapeEvents.ts b/hooks/useShapeEvents.ts index 0e3c26cbd..515f46bc5 100644 --- a/hooks/useShapeEvents.ts +++ b/hooks/useShapeEvents.ts @@ -6,14 +6,15 @@ import Vec from 'utils/vec' export default function useShapeEvents( id: string, - isParent: boolean, + isCurrentParent: boolean, rGroup: MutableRefObject ) { const handlePointerDown = useCallback( (e: React.PointerEvent) => { - if (isParent) return + if (isCurrentParent) return if (!inputs.canAccept(e.pointerId)) return e.stopPropagation() + rGroup.current.setPointerCapture(e.pointerId) const info = inputs.pointerDown(e, id) @@ -28,30 +29,30 @@ export default function useShapeEvents( state.send('RIGHT_POINTED', info) } }, - [id] + [id, isCurrentParent] ) const handlePointerUp = useCallback( (e: React.PointerEvent) => { + if (isCurrentParent) return if (!inputs.canAccept(e.pointerId)) return e.stopPropagation() + rGroup.current.releasePointerCapture(e.pointerId) state.send('STOPPED_POINTING', inputs.pointerUp(e, id)) }, - [id] + [id, isCurrentParent] ) const handlePointerEnter = useCallback( (e: React.PointerEvent) => { + if (isCurrentParent) return if (!inputs.canAccept(e.pointerId)) return + e.stopPropagation() - if (isParent) { - state.send('HOVERED_GROUP', inputs.pointerEnter(e, id)) - } else { - state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id)) - } + state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id)) }, - [id] + [id, isCurrentParent] ) const handlePointerMove = useCallback( @@ -72,26 +73,22 @@ export default function useShapeEvents( return } - if (isParent) { - state.send('MOVED_OVER_GROUP', inputs.pointerEnter(e, id)) - } else { - state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id)) - } + if (isCurrentParent) return + + state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id)) }, - [id] + [id, isCurrentParent] ) const handlePointerLeave = useCallback( (e: React.PointerEvent) => { + if (isCurrentParent) return if (!inputs.canAccept(e.pointerId)) return + e.stopPropagation() - if (isParent) { - state.send('UNHOVERED_GROUP', { target: id }) - } else { - state.send('UNHOVERED_SHAPE', { target: id }) - } + state.send('UNHOVERED_SHAPE', { target: id }) }, - [id] + [id, isCurrentParent] ) const handleTouchStart = useCallback((e: React.TouchEvent) => { diff --git a/package.json b/package.json index 8d1b419be..6dfa4c7f3 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "start": "next start", "test-all": "yarn lint && yarn type-check && yarn test", "test:update": "jest --updateSnapshot", - "test:watch": "jest --watchAll --verbose=false --silent=false", + "test:watch": "jest --watchAll", "test": "jest --watchAll=false", "type-check": "tsc --pretty --noEmit" }, @@ -96,4 +96,4 @@ "tabWidth": 2, "useTabs": false } -} +} \ No newline at end of file diff --git a/state/code/arrow.ts b/state/code/arrow.ts index 7daf1ad24..f39c68938 100644 --- a/state/code/arrow.ts +++ b/state/code/arrow.ts @@ -1,5 +1,5 @@ import CodeShape from './index' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { ArrowShape, Decoration, ShapeProps, ShapeType } from 'types' import { defaultStyle } from 'state/shape-styles' import { getShapeUtils } from 'state/shape-utils' diff --git a/state/code/control.ts b/state/code/control.ts index 7762a127c..3cf335391 100644 --- a/state/code/control.ts +++ b/state/code/control.ts @@ -5,7 +5,7 @@ import { TextCodeControl, VectorCodeControl, } from 'types' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' export const controls: Record = {} diff --git a/state/code/dot.ts b/state/code/dot.ts index 80687f2f1..236d2004a 100644 --- a/state/code/dot.ts +++ b/state/code/dot.ts @@ -1,5 +1,5 @@ import CodeShape from './index' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { DotShape, ShapeProps, ShapeType } from 'types' import { defaultStyle } from 'state/shape-styles' diff --git a/state/code/draw.ts b/state/code/draw.ts index 6c13d0f9c..8bd8ae6ee 100644 --- a/state/code/draw.ts +++ b/state/code/draw.ts @@ -1,5 +1,5 @@ import CodeShape from './index' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { DrawShape, ShapeProps, ShapeType } from 'types' import { defaultStyle } from 'state/shape-styles' diff --git a/state/code/ellipse.ts b/state/code/ellipse.ts index f90a812ab..d779c387b 100644 --- a/state/code/ellipse.ts +++ b/state/code/ellipse.ts @@ -1,5 +1,5 @@ import CodeShape from './index' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { EllipseShape, ShapeProps, ShapeType } from 'types' import { defaultStyle } from 'state/shape-styles' diff --git a/state/code/line.ts b/state/code/line.ts index a8cb1bbd0..9b31cd3a4 100644 --- a/state/code/line.ts +++ b/state/code/line.ts @@ -1,5 +1,5 @@ import CodeShape from './index' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { LineShape, ShapeProps, ShapeType } from 'types' import { defaultStyle } from 'state/shape-styles' diff --git a/state/code/polyline.ts b/state/code/polyline.ts index 26d2c5a27..78d1f9f0e 100644 --- a/state/code/polyline.ts +++ b/state/code/polyline.ts @@ -1,5 +1,5 @@ import CodeShape from './index' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { PolylineShape, ShapeProps, ShapeType } from 'types' import { defaultStyle } from 'state/shape-styles' diff --git a/state/code/ray.ts b/state/code/ray.ts index 8783a5025..15dcd2950 100644 --- a/state/code/ray.ts +++ b/state/code/ray.ts @@ -1,5 +1,5 @@ import CodeShape from './index' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { RayShape, ShapeProps, ShapeType } from 'types' import { defaultStyle } from 'state/shape-styles' diff --git a/state/code/rectangle.ts b/state/code/rectangle.ts index b96450aee..8f0fa7487 100644 --- a/state/code/rectangle.ts +++ b/state/code/rectangle.ts @@ -1,5 +1,5 @@ import CodeShape from './index' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { RectangleShape, ShapeProps, ShapeType } from 'types' import { defaultStyle } from 'state/shape-styles' import { getShapeUtils } from 'state/shape-utils' diff --git a/state/code/text.ts b/state/code/text.ts index aedbad2e3..78efd5d94 100644 --- a/state/code/text.ts +++ b/state/code/text.ts @@ -1,5 +1,5 @@ import CodeShape from './index' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { TextShape, ShapeProps, ShapeType } from 'types' import { defaultStyle } from 'state/shape-styles' import { getShapeUtils } from 'state/shape-utils' diff --git a/state/commands/change-page.ts b/state/commands/change-page.ts index 98d7bcbd9..9cbfb7649 100644 --- a/state/commands/change-page.ts +++ b/state/commands/change-page.ts @@ -16,10 +16,12 @@ export default function changePage(data: Data, toPageId: string): void { storage.savePage(data, data.document.id, fromPageId) storage.loadPage(data, data.document.id, toPageId) data.currentPageId = toPageId + data.currentParentId = toPageId }, undo(data) { storage.loadPage(data, data.document.id, fromPageId) data.currentPageId = fromPageId + data.currentParentId = fromPageId }, }) ) diff --git a/state/commands/create-page.ts b/state/commands/create-page.ts index 3103a51ff..540b961c9 100644 --- a/state/commands/create-page.ts +++ b/state/commands/create-page.ts @@ -1,7 +1,7 @@ import Command from './command' import history from '../history' import { Data, Page, PageState } from 'types' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import storage from 'state/storage' export default function createPage(data: Data, goToPage = true): void { diff --git a/state/commands/duplicate.ts b/state/commands/duplicate.ts index 3d6f8b11c..637e0f160 100644 --- a/state/commands/duplicate.ts +++ b/state/commands/duplicate.ts @@ -3,7 +3,7 @@ import history from '../history' import { Data } from 'types' import { deepClone } from 'utils' import tld from 'utils/tld' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import vec from 'utils/vec' export default function duplicateCommand(data: Data): void { diff --git a/state/commands/move.ts b/state/commands/move.ts index 0cd29898e..54bab0c8e 100644 --- a/state/commands/move.ts +++ b/state/commands/move.ts @@ -57,6 +57,7 @@ export default function moveCommand(data: Data, type: MoveType): void { .sort((a, b) => b.childIndex - a.childIndex) .forEach((shape) => moveForward(shape, siblings, visited)) } + break } case MoveType.Backward: { diff --git a/state/commands/paste.ts b/state/commands/paste.ts index 1d37a7774..716f9f9d7 100644 --- a/state/commands/paste.ts +++ b/state/commands/paste.ts @@ -3,7 +3,7 @@ import history from '../history' import { Data, Shape } from 'types' import { getCommonBounds, setToArray } from 'utils' import tld from 'utils/tld' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import vec from 'utils/vec' import { getShapeUtils } from 'state/shape-utils' import state from 'state/state' diff --git a/state/coop/client-liveblocks.ts b/state/coop/client-liveblocks.ts index ef5d76381..8072d1368 100644 --- a/state/coop/client-liveblocks.ts +++ b/state/coop/client-liveblocks.ts @@ -6,7 +6,7 @@ import { MyPresenceCallback, OthersEventCallback, } from '@liveblocks/client/lib/cjs/types' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' class CoopClient { id = uniqueId() diff --git a/state/inputs.tsx b/state/inputs.tsx index bd0ddb8b2..4d10b8ea3 100644 --- a/state/inputs.tsx +++ b/state/inputs.tsx @@ -160,6 +160,7 @@ class Inputs { } this.pointer = info + return info } diff --git a/state/sessions/translate-session.ts b/state/sessions/translate-session.ts index d1a3ef5c4..5b8dc1d2b 100644 --- a/state/sessions/translate-session.ts +++ b/state/sessions/translate-session.ts @@ -2,7 +2,7 @@ import { Data, GroupShape, Shape, ShapeType } from 'types' import vec from 'utils/vec' import BaseSession from './base-session' import commands from 'state/commands' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { getShapeUtils } from 'state/shape-utils' import tld from 'utils/tld' diff --git a/state/shape-utils/arrow.tsx b/state/shape-utils/arrow.tsx index 8eb7376b9..4bceb6571 100644 --- a/state/shape-utils/arrow.tsx +++ b/state/shape-utils/arrow.tsx @@ -1,12 +1,16 @@ -import { getArcLength, uniqueId } from 'utils' import vec from 'utils/vec' import { + getArcLength, + uniqueId, getSvgPathFromStroke, rng, getBoundsFromPoints, translateBounds, pointInBounds, pointInCircle, + circleFromThreePoints, + isAngleBetween, + getPerfectDashProps, } from 'utils' import { ArrowShape, @@ -15,7 +19,6 @@ import { ShapeHandle, ShapeType, } from 'types' -import { circleFromThreePoints, isAngleBetween } from 'utils' import { intersectArcBounds, intersectLineSegmentBounds, @@ -24,7 +27,6 @@ import { defaultStyle, getShapeStyle } from 'state/shape-styles' import getStroke from 'perfect-freehand' import React from 'react' import { registerShapeUtils } from './register' -import { getPerfectDashProps } from 'utils/dashes' const pathCache = new WeakMap([]) @@ -37,55 +39,65 @@ function getCtp(shape: ArrowShape) { const arrow = registerShapeUtils({ boundsCache: new WeakMap([]), + defaultProps: { + id: uniqueId(), + type: ShapeType.Arrow, + isGenerated: false, + name: 'Arrow', + parentId: 'page1', + childIndex: 0, + point: [0, 0], + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + bend: 0, + handles: { + start: { + id: 'start', + index: 0, + point: [0, 0], + }, + end: { + id: 'end', + index: 1, + point: [1, 1], + }, + bend: { + id: 'bend', + index: 2, + point: [0.5, 0.5], + }, + }, + decorations: { + start: null, + middle: null, + end: Decoration.Arrow, + }, + style: { + ...defaultStyle, + isFilled: false, + }, + }, + create(props) { - const { - point = [0, 0], - handles = { - start: { - id: 'start', - index: 0, - point: [0, 0], - }, - end: { - id: 'end', - index: 1, - point: [1, 1], - }, - bend: { - id: 'bend', - index: 2, - point: [0.5, 0.5], - }, - }, - } = props - - return { - id: uniqueId(), - - type: ShapeType.Arrow, - isGenerated: false, - name: 'Arrow', - parentId: 'page1', - childIndex: 0, - point, - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, - bend: 0, - handles, - decorations: { - start: null, - middle: null, - end: Decoration.Arrow, - }, + const shape = { + ...this.defaultProps, ...props, + decorations: { + ...this.defaultProps.decorations, + ...props.decorations, + }, style: { - ...defaultStyle, + ...this.defaultProps.style, ...props.style, isFilled: false, }, } + + // shape.handles.bend.point = getBendPoint(shape) + + return shape }, shouldRender(shape, prev) { diff --git a/state/shape-utils/dot.tsx b/state/shape-utils/dot.tsx index 86aa005ab..f63b3a9f6 100644 --- a/state/shape-utils/dot.tsx +++ b/state/shape-utils/dot.tsx @@ -1,4 +1,4 @@ -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import { DotShape, ShapeType } from 'types' import { intersectCircleBounds } from 'utils/intersections' import { boundsContained, translateBounds } from 'utils' @@ -8,27 +8,19 @@ import { registerShapeUtils } from './register' const dot = registerShapeUtils({ boundsCache: new WeakMap([]), - create(props) { - return { - id: uniqueId(), - - type: ShapeType.Dot, - isGenerated: false, - name: 'Dot', - parentId: 'page1', - childIndex: 0, - point: [0, 0], - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, - ...props, - style: { - ...defaultStyle, - ...props.style, - isFilled: false, - }, - } + defaultProps: { + id: uniqueId(), + type: ShapeType.Dot, + isGenerated: false, + name: 'Dot', + parentId: 'page1', + childIndex: 0, + point: [0, 0], + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + style: defaultStyle, }, render({ id }) { diff --git a/state/shape-utils/draw.tsx b/state/shape-utils/draw.tsx index eb50355fc..2bda4e075 100644 --- a/state/shape-utils/draw.tsx +++ b/state/shape-utils/draw.tsx @@ -1,4 +1,4 @@ -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import vec from 'utils/vec' import { DashStyle, DrawShape, ShapeStyles, ShapeType } from 'types' import { intersectPolylineBounds } from 'utils/intersections' @@ -22,27 +22,20 @@ const draw = registerShapeUtils({ canStyleFill: true, - create(props) { - return { - id: uniqueId(), - - type: ShapeType.Draw, - isGenerated: false, - name: 'Draw', - parentId: 'page1', - childIndex: 0, - point: [0, 0], - points: [], - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, - ...props, - style: { - ...defaultStyle, - ...props.style, - }, - } + defaultProps: { + id: uniqueId(), + type: ShapeType.Draw, + isGenerated: false, + name: 'Draw', + parentId: 'page1', + childIndex: 0, + point: [0, 0], + points: [], + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + style: defaultStyle, }, shouldRender(shape, prev) { diff --git a/state/shape-utils/ellipse.tsx b/state/shape-utils/ellipse.tsx index 3d7acc041..b4ddd3a5c 100644 --- a/state/shape-utils/ellipse.tsx +++ b/state/shape-utils/ellipse.tsx @@ -1,4 +1,3 @@ -import { getPerfectDashProps } from 'utils/dashes' import vec from 'utils/vec' import { DashStyle, EllipseShape, ShapeType } from 'types' import { getShapeUtils } from './index' @@ -11,6 +10,7 @@ import { pointInEllipse, boundsContained, getRotatedEllipseBounds, + getPerfectDashProps, } from 'utils' import { defaultStyle, getShapeStyle } from 'state/shape-styles' import getStroke from 'perfect-freehand' @@ -21,25 +21,21 @@ const pathCache = new WeakMap([]) const ellipse = registerShapeUtils({ boundsCache: new WeakMap([]), - create(props) { - return { - id: uniqueId(), - - type: ShapeType.Ellipse, - isGenerated: false, - name: 'Ellipse', - parentId: 'page1', - childIndex: 0, - point: [0, 0], - radiusX: 1, - radiusY: 1, - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, - style: defaultStyle, - ...props, - } + defaultProps: { + id: uniqueId(), + type: ShapeType.Ellipse, + isGenerated: false, + name: 'Ellipse', + parentId: 'page1', + childIndex: 0, + point: [0, 0], + radiusX: 1, + radiusY: 1, + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + style: defaultStyle, }, shouldRender(shape, prev) { diff --git a/state/shape-utils/group.tsx b/state/shape-utils/group.tsx index 772ce60b4..4caaf2ee2 100644 --- a/state/shape-utils/group.tsx +++ b/state/shape-utils/group.tsx @@ -1,4 +1,4 @@ -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import vec from 'utils/vec' import { GroupShape, ShapeType } from 'types' import { getShapeUtils } from './index' @@ -12,26 +12,21 @@ const group = registerShapeUtils({ isShy: true, isParent: true, - create(props) { - return { - id: uniqueId(), - - type: ShapeType.Group, - isGenerated: false, - name: 'Group', - parentId: 'page1', - childIndex: 0, - point: [0, 0], - size: [1, 1], - radius: 2, - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, - style: defaultStyle, - children: [], - ...props, - } + defaultProps: { + id: uniqueId(), + type: ShapeType.Group, + isGenerated: false, + name: 'Group', + parentId: 'page1', + childIndex: 0, + point: [0, 0], + size: [1, 1], + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + style: defaultStyle, + children: [], }, render(shape) { diff --git a/state/shape-utils/line.tsx b/state/shape-utils/line.tsx index 56e5875ae..ae002b5ec 100644 --- a/state/shape-utils/line.tsx +++ b/state/shape-utils/line.tsx @@ -1,4 +1,4 @@ -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import vec from 'utils/vec' import { LineShape, ShapeType } from 'types' import { intersectCircleBounds } from 'utils/intersections' @@ -10,28 +10,20 @@ import { registerShapeUtils } from './register' const line = registerShapeUtils({ boundsCache: new WeakMap([]), - create(props) { - return { - id: uniqueId(), - - type: ShapeType.Line, - isGenerated: false, - name: 'Line', - parentId: 'page1', - childIndex: 0, - point: [0, 0], - direction: [0, 0], - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, - ...props, - style: { - ...defaultStyle, - ...props.style, - isFilled: false, - }, - } + defaultProps: { + id: uniqueId(), + type: ShapeType.Line, + isGenerated: false, + name: 'Line', + parentId: 'page1', + childIndex: 0, + point: [0, 0], + direction: [0, 0], + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + style: defaultStyle, }, shouldRender(shape, prev) { diff --git a/state/shape-utils/polyline.tsx b/state/shape-utils/polyline.tsx index 50507a5e0..23511ac83 100644 --- a/state/shape-utils/polyline.tsx +++ b/state/shape-utils/polyline.tsx @@ -1,4 +1,4 @@ -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import vec from 'utils/vec' import { PolylineShape, ShapeType } from 'types' import { intersectPolylineBounds } from 'utils/intersections' @@ -13,24 +13,20 @@ import { registerShapeUtils } from './register' const polyline = registerShapeUtils({ boundsCache: new WeakMap([]), - create(props) { - return { - id: uniqueId(), - - type: ShapeType.Polyline, - isGenerated: false, - name: 'Polyline', - parentId: 'page1', - childIndex: 0, - point: [0, 0], - points: [[0, 0]], - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, - style: defaultStyle, - ...props, - } + defaultProps: { + id: uniqueId(), + type: ShapeType.Polyline, + isGenerated: false, + name: 'Polyline', + parentId: 'page1', + childIndex: 0, + point: [0, 0], + points: [[0, 0]], + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + style: defaultStyle, }, shouldRender(shape, prev) { diff --git a/state/shape-utils/ray.tsx b/state/shape-utils/ray.tsx index 8ca5a2610..62b8954ce 100644 --- a/state/shape-utils/ray.tsx +++ b/state/shape-utils/ray.tsx @@ -1,4 +1,4 @@ -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import vec from 'utils/vec' import { RayShape, ShapeType } from 'types' import { intersectCircleBounds } from 'utils/intersections' @@ -10,28 +10,20 @@ import { registerShapeUtils } from './register' const ray = registerShapeUtils({ boundsCache: new WeakMap([]), - create(props) { - return { - id: uniqueId(), - - type: ShapeType.Ray, - isGenerated: false, - name: 'Ray', - parentId: 'page1', - childIndex: 0, - point: [0, 0], - direction: [0, 1], - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, - ...props, - style: { - ...defaultStyle, - ...props.style, - isFilled: false, - }, - } + defaultProps: { + id: uniqueId(), + type: ShapeType.Ray, + isGenerated: false, + name: 'Ray', + parentId: 'page1', + childIndex: 0, + point: [0, 0], + direction: [0, 1], + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + style: defaultStyle, }, shouldRender(shape, prev) { diff --git a/state/shape-utils/rectangle.tsx b/state/shape-utils/rectangle.tsx index 9a4da308f..d45ac1617 100644 --- a/state/shape-utils/rectangle.tsx +++ b/state/shape-utils/rectangle.tsx @@ -1,36 +1,32 @@ -import { uniqueId } from 'utils' +import { uniqueId, getPerfectDashProps } from 'utils/utils' import vec from 'utils/vec' import { DashStyle, RectangleShape, ShapeType } from 'types' import { getSvgPathFromStroke, translateBounds, rng, shuffleArr } from 'utils' import { defaultStyle, getShapeStyle } from 'state/shape-styles' import getStroke from 'perfect-freehand' import { registerShapeUtils } from './register' -import { getPerfectDashProps } from 'utils/dashes' const pathCache = new WeakMap([]) const rectangle = registerShapeUtils({ boundsCache: new WeakMap([]), - create(props) { - return { - id: uniqueId(), + defaultProps: { + id: uniqueId(), - type: ShapeType.Rectangle, - isGenerated: false, - name: 'Rectangle', - parentId: 'page1', - childIndex: 0, - point: [0, 0], - size: [1, 1], - radius: 2, - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, - style: defaultStyle, - ...props, - } + type: ShapeType.Rectangle, + isGenerated: false, + name: 'Rectangle', + parentId: 'page1', + childIndex: 0, + point: [0, 0], + size: [1, 1], + radius: 2, + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + style: defaultStyle, }, shouldRender(shape, prev) { diff --git a/state/shape-utils/register.tsx b/state/shape-utils/register.tsx index 752663127..d32ba17a7 100644 --- a/state/shape-utils/register.tsx +++ b/state/shape-utils/register.tsx @@ -1,6 +1,6 @@ -import { Shape, ShapeUtility } from 'types' -import vec from 'utils/vec' +import React from 'react' import { + vec, pointInBounds, getBoundsCenter, getBoundsFromPoints, @@ -8,8 +8,7 @@ import { boundsCollidePolygon, boundsContainPolygon, } from 'utils' -import { uniqueId } from 'utils' -import React from 'react' +import { Shape, ShapeUtility } from 'types' function getDefaultShapeUtil(): ShapeUtility { return { @@ -22,19 +21,19 @@ function getDefaultShapeUtil(): ShapeUtility { isParent: false, isForeignObject: false, + defaultProps: {} as T, + create(props) { return { - id: uniqueId(), - isGenerated: false, - point: [0, 0], - name: 'Shape', - parentId: 'page1', - childIndex: 0, - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, + ...this.defaultProps, ...props, + style: { + ...this.defaultProps.style, + ...props.style, + isFilled: this.canStyleFill + ? props.style?.isFilled || this.defaultProps.style.isFilled + : false, + }, } as T }, diff --git a/state/shape-utils/text.tsx b/state/shape-utils/text.tsx index aace7f48a..4686ff022 100644 --- a/state/shape-utils/text.tsx +++ b/state/shape-utils/text.tsx @@ -1,4 +1,4 @@ -import { uniqueId, isMobile } from 'utils' +import { uniqueId, isMobile } from 'utils/utils' import vec from 'utils/vec' import { TextShape, ShapeType } from 'types' import { @@ -47,27 +47,23 @@ const text = registerShapeUtils({ isForeignObject: true, canChangeAspectRatio: false, canEdit: true, - boundsCache: new WeakMap([]), - create(props) { - return { - id: uniqueId(), - type: ShapeType.Text, - isGenerated: false, - name: 'Text', - parentId: 'page1', - childIndex: 0, - point: [0, 0], - rotation: 0, - isAspectRatioLocked: false, - isLocked: false, - isHidden: false, - style: defaultStyle, - text: '', - scale: 1, - ...props, - } + defaultProps: { + id: uniqueId(), + type: ShapeType.Text, + isGenerated: false, + name: 'Text', + parentId: 'page1', + childIndex: 0, + point: [0, 0], + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + style: defaultStyle, + text: '', + scale: 1, }, shouldRender(shape, prev) { diff --git a/state/state.ts b/state/state.ts index 37273d3de..24f3886f5 100644 --- a/state/state.ts +++ b/state/state.ts @@ -1,15 +1,16 @@ import { createSelectorHook, createState } from '@state-designer/react' import { updateFromCode } from './code/generate' import { createShape, getShapeUtils } from './shape-utils' -import vec from 'utils/vec' +import * as Sessions from './sessions' import inputs from './inputs' import history from './history' import storage from './storage' +import session from './session' import clipboard from './clipboard' -import * as Sessions from './sessions' import coopClient from './coop/client-liveblocks' import commands from './commands' import { + vec, getCommonBounds, rotateBounds, getBoundsCenter, @@ -18,7 +19,7 @@ import { pointInBounds, uniqueId, } from 'utils' -import tld from 'utils/tld' +import tld from '../utils/tld' import { Data, PointerInfo, @@ -36,7 +37,6 @@ import { SizeStyle, ColorStyle, } from 'types' -import session from './session' const initialData: Data = { isReadOnly: false, @@ -288,10 +288,15 @@ const state = createState({ unless: 'isInSession', do: ['loadDocumentFromJson', 'resetHistory'], }, + DESELECTED_ALL: { + unless: 'isInSession', + do: 'deselectAll', + to: 'selecting', + }, SELECTED_ALL: { unless: 'isInSession', - to: 'selecting', do: 'selectAll', + to: 'selecting', }, CHANGED_PAGE: { unless: 'isInSession', @@ -398,8 +403,15 @@ const state = createState({ notPointing: { onEnter: 'clearPointedId', on: { - CANCELLED: 'clearSelectedIds', - POINTED_CANVAS: { to: 'brushSelecting' }, + CANCELLED: { + if: 'hasCurrentParentShape', + do: ['selectCurrentParentId', 'raiseCurrentParentId'], + else: 'clearSelectedIds', + }, + POINTED_CANVAS: { + to: 'brushSelecting', + do: 'setCurrentParentIdToPage', + }, POINTED_BOUNDS: [ { if: 'isPressingMetaKey', @@ -477,7 +489,7 @@ const state = createState({ { unless: 'isPressingShiftKey', do: [ - 'setDrilledPointedId', + 'setCurrentParentId', 'clearSelectedIds', 'pushPointedIdToSelectedIds', ], @@ -1120,6 +1132,9 @@ const state = createState({ hasMultipleSelection(data) { return tld.getSelectedIds(data).size > 1 }, + hasCurrentParentShape(data) { + return data.currentParentId !== data.currentPageId + }, isToolLocked(data) { return data.settings.isToolLocked }, @@ -1180,6 +1195,14 @@ const state = createState({ data.currentPageId = newId + data.pointedId = null + data.hoveredId = null + data.editingId = null + data.currentPageId = 'page1' + data.currentParentId = 'page1' + data.currentCodeFileId = 'file0' + data.codeControls = {} + data.document.pages = { [newId]: { id: newId, @@ -1234,6 +1257,7 @@ const state = createState({ createShape(data, payload, type: ShapeType) { const shape = createShape(type, { + id: uniqueId(), parentId: data.currentPageId, point: vec.round(tld.screenToWorld(payload.point, data)), style: deepClone(data.currentStyle), @@ -1500,11 +1524,12 @@ const state = createState({ ) ) }, - clearInputs() { inputs.clear() }, - + deselectAll(data) { + tld.getSelectedIds(data).clear() + }, selectAll(data) { const selectedIds = tld.getSelectedIds(data) const page = tld.getPage(data) @@ -1525,10 +1550,24 @@ const state = createState({ data.pointedId = getPointedId(data, payload.target) data.currentParentId = getParentId(data, data.pointedId) }, - setDrilledPointedId(data, payload: PointerInfo) { + setCurrentParentId(data, payload: PointerInfo) { data.pointedId = getDrilledPointedId(data, payload.target) data.currentParentId = getParentId(data, data.pointedId) }, + raiseCurrentParentId(data) { + const currentParent = tld.getShape(data, data.currentParentId) + + data.currentParentId = + currentParent.parentId === data.currentPageId + ? data.currentPageId + : currentParent.parentId + }, + setCurrentParentIdToPage(data) { + data.currentParentId = data.currentPageId + }, + selectCurrentParentId(data) { + tld.setSelectedIds(data, [data.currentParentId]) + }, clearCurrentParentId(data) { data.currentParentId = data.currentPageId data.pointedId = undefined diff --git a/state/storage.ts b/state/storage.ts index 007918ba4..fec2faef2 100644 --- a/state/storage.ts +++ b/state/storage.ts @@ -1,7 +1,7 @@ import { Data, PageState, TLDocument } from 'types' import { decompress, compress, setToArray } from 'utils' import state from './state' -import { uniqueId } from 'utils' +import { uniqueId } from 'utils/utils' import * as idb from 'idb-keyval' const CURRENT_VERSION = 'code_slate_0.0.8' diff --git a/types.ts b/types.ts index 5c2ad14fe..42329b2e7 100644 --- a/types.ts +++ b/types.ts @@ -458,6 +458,9 @@ export type PropsOfType> = { export type Mutable = { -readonly [K in keyof T]: T[K] } export interface ShapeUtility { + // Default properties when creating a new shape + defaultProps: K + // A cache for the computed bounds of this kind of shape. boundsCache: WeakMap @@ -483,7 +486,7 @@ export interface ShapeUtility { isShy: boolean // Create a new shape. - create(props: Partial): K + create(this: ShapeUtility, props: Partial): K // Update a shape's styles applyStyles( diff --git a/utils/dashes.ts b/utils/dashes.ts deleted file mode 100644 index b39d71ab3..000000000 --- a/utils/dashes.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Get balanced dash-strokearray and dash-strokeoffset properties for a path of a given length. - * @param length The length of the path. - * @param strokeWidth The shape's stroke-width property. - * @param style The stroke's style: "dashed" or "dotted" (default "dashed"). - * @param snap An interval for dashes (e.g. 4 will produce arrays with 4, 8, 16, etc dashes). - */ -export function getPerfectDashProps( - length: number, - strokeWidth: number, - style: 'dashed' | 'dotted' = 'dashed', - snap = 1 -): { - strokeDasharray: string - strokeDashoffset: string -} { - let dashLength: number - let strokeDashoffset: string - let ratio: number - - if (style === 'dashed') { - dashLength = strokeWidth * 2 - ratio = 1 - strokeDashoffset = (dashLength / 2).toString() - } else { - dashLength = strokeWidth / 100 - ratio = 100 - strokeDashoffset = '0' - } - - let dashes = Math.floor(length / dashLength / (2 * ratio)) - dashes -= dashes % snap - if (dashes === 0) dashes = 1 - - const gapLength = (length - dashes * dashLength) / dashes - - return { - strokeDasharray: [dashLength, gapLength].join(' '), - strokeDashoffset, - } -} diff --git a/utils/index.ts b/utils/index.ts index 9c56149ef..175fccb94 100644 --- a/utils/index.ts +++ b/utils/index.ts @@ -1 +1,5 @@ +import vec from './vec' +import svg from './svg' export * from './utils' + +export { vec, svg } diff --git a/utils/tld.ts b/utils/tld.ts index c3f548e6d..bb72ec7fe 100644 --- a/utils/tld.ts +++ b/utils/tld.ts @@ -1,4 +1,4 @@ -import { clamp, deepClone, getCommonBounds, setToArray } from './utils' +import { clamp, deepClone, getCommonBounds, setToArray } from 'utils' import { getShapeUtils } from 'state/shape-utils' import vec from './vec' import { @@ -15,7 +15,7 @@ import { } from 'types' import { AssertionError } from 'assert' -export default class ProjectUtils { +export default class StateUtils { static getCameraZoom(zoom: number): number { return clamp(zoom, 0.1, 5) } diff --git a/utils/utils.ts b/utils/utils.ts index c31766989..edc9cd671 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -5,14 +5,9 @@ import vec from './vec' import _isMobile from 'ismobilejs' import { intersectPolygonBounds } from './intersections' -/* ----------- Numbers and Data Structures ---------- */ - -/** - * Get a unique string id. - */ -export function uniqueId(): string { - return uuid() -} +/* -------------------------------------------------- */ +/* Math & Geometry */ +/* -------------------------------------------------- */ /** * Linear interpolation betwen two numbers. @@ -119,71 +114,727 @@ export function rng(seed = ''): () => number { return next } +/* --------------- Circles and Angles --------------- */ + /** - * Shuffle the contents of an array. - * @param arr - * @param offset + * Get the outer of between a circle and a point. + * @param C The circle's center. + * @param r The circle's radius. + * @param P The point. + * @param side */ -export function shuffleArr(arr: T[], offset: number): T[] { - return arr.map((_, i) => arr[(i + offset) % arr.length]) +export function getCircleTangentToPoint( + C: number[], + r: number, + P: number[], + side: number +): number[] { + const B = vec.lrp(C, P, 0.5), + r1 = vec.dist(C, B), + delta = vec.sub(B, C), + d = vec.len(delta) + + if (!(d <= r + r1 && d >= Math.abs(r - r1))) { + return + } + + const a = (r * r - r1 * r1 + d * d) / (2.0 * d), + n = 1 / d, + p = vec.add(C, vec.mul(delta, a * n)), + h = Math.sqrt(r * r - a * a), + k = vec.mul(vec.per(delta), h * n) + + return side === 0 ? vec.add(p, k) : vec.sub(p, k) } /** - * Deep compare two arrays. + * Get outer tangents of two circles. + * @param x0 + * @param y0 + * @param r0 + * @param x1 + * @param y1 + * @param r1 + * @returns [lx0, ly0, lx1, ly1, rx0, ry0, rx1, ry1] + */ +export function getOuterTangentsOfCircles( + C0: number[], + r0: number, + C1: number[], + r1: number +): number[][] { + const a0 = vec.angle(C0, C1) + const d = vec.dist(C0, C1) + + // Circles are overlapping, no tangents + if (d < Math.abs(r1 - r0)) return + + const a1 = Math.acos((r0 - r1) / d), + t0 = a0 + a1, + t1 = a0 - a1 + + return [ + [C0[0] + r0 * Math.cos(t1), C0[1] + r0 * Math.sin(t1)], + [C1[0] + r1 * Math.cos(t1), C1[1] + r1 * Math.sin(t1)], + [C0[0] + r0 * Math.cos(t0), C0[1] + r0 * Math.sin(t0)], + [C1[0] + r1 * Math.cos(t0), C1[1] + r1 * Math.sin(t0)], + ] +} + +/** + * Get the closest point on the perimeter of a circle to a given point. + * @param C The circle's center. + * @param r The circle's radius. + * @param P The point. + */ +export function getClosestPointOnCircle( + C: number[], + r: number, + P: number[] +): number[] { + const v = vec.sub(C, P) + return vec.sub(C, vec.mul(vec.div(v, vec.len(v)), r)) +} + +function det( + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + g: number, + h: number, + i: number +): number { + return a * e * i + b * f * g + c * d * h - a * f * h - b * d * i - c * e * g +} + +/** + * Get a circle from three points. + * @param A + * @param B + * @param C + * @returns [x, y, r] + */ +export function circleFromThreePoints( + A: number[], + B: number[], + C: number[] +): number[] { + const a = det(A[0], A[1], 1, B[0], B[1], 1, C[0], C[1], 1) + + const bx = -det( + A[0] * A[0] + A[1] * A[1], + A[1], + 1, + B[0] * B[0] + B[1] * B[1], + B[1], + 1, + C[0] * C[0] + C[1] * C[1], + C[1], + 1 + ) + const by = det( + A[0] * A[0] + A[1] * A[1], + A[0], + 1, + B[0] * B[0] + B[1] * B[1], + B[0], + 1, + C[0] * C[0] + C[1] * C[1], + C[0], + 1 + ) + const c = -det( + A[0] * A[0] + A[1] * A[1], + A[0], + A[1], + B[0] * B[0] + B[1] * B[1], + B[0], + B[1], + C[0] * C[0] + C[1] * C[1], + C[0], + C[1] + ) + + const x = -bx / (2 * a) + const y = -by / (2 * a) + const r = Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a)) + + return [x, y, r] +} + +/** + * Find the approximate perimeter of an ellipse. + * @param rx + * @param ry + */ +export function perimeterOfEllipse(rx: number, ry: number): number { + const h = Math.pow(rx - ry, 2) / Math.pow(rx + ry, 2) + const p = Math.PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h))) + return p +} + +/** + * Get the short angle distance between two angles. + * @param a0 + * @param a1 + */ +export function shortAngleDist(a0: number, a1: number): number { + const max = Math.PI * 2 + const da = (a1 - a0) % max + return ((2 * da) % max) - da +} + +/** + * Get the long angle distance between two angles. + * @param a0 + * @param a1 + */ +export function longAngleDist(a0: number, a1: number): number { + return Math.PI * 2 - shortAngleDist(a0, a1) +} + +/** + * Interpolate an angle between two angles. + * @param a0 + * @param a1 + * @param t + */ +export function lerpAngles(a0: number, a1: number, t: number): number { + return a0 + shortAngleDist(a0, a1) * t +} + +/** + * Get the short distance between two angles. + * @param a0 + * @param a1 + */ +export function angleDelta(a0: number, a1: number): number { + return shortAngleDist(a0, a1) +} + +/** + * Get the "sweep" or short distance between two points on a circle's perimeter. + * @param C + * @param A + * @param B + */ +export function getSweep(C: number[], A: number[], B: number[]): number { + return angleDelta(vec.angle(C, A), vec.angle(C, B)) +} + +/** + * Rotate a point around a center. + * @param x The x-axis coordinate of the point. + * @param y The y-axis coordinate of the point. + * @param cx The x-axis coordinate of the point to rotate round. + * @param cy The y-axis coordinate of the point to rotate round. + * @param angle The distance (in radians) to rotate. + */ +export function rotatePoint(A: number[], B: number[], angle: number): number[] { + const s = Math.sin(angle) + const c = Math.cos(angle) + + const px = A[0] - B[0] + const py = A[1] - B[1] + + const nx = px * c - py * s + const ny = px * s + py * c + + return [nx + B[0], ny + B[1]] +} + +/** + * Clamp radians within 0 and 2PI + * @param r + */ +export function clampRadians(r: number): number { + return (Math.PI * 2 + r) % (Math.PI * 2) +} + +/** + * Clamp rotation to even segments. + * @param r + * @param segments + */ +export function clampToRotationToSegments(r: number, segments: number): number { + const seg = (Math.PI * 2) / segments + return Math.floor((clampRadians(r) + seg / 2) / seg) * seg +} + +/** + * Is angle c between angles a and b? * @param a * @param b + * @param c */ -export function deepCompareArrays(a: T[], b: T[]): boolean { - if (a?.length !== b?.length) return false - return deepCompare(a, b) +export function isAngleBetween(a: number, b: number, c: number): boolean { + if (c === a || c === b) return true + const PI2 = Math.PI * 2 + const AB = (b - a + PI2) % PI2 + const AC = (c - a + PI2) % PI2 + return AB <= Math.PI !== AC > AB } /** - * Deep compare any values. - * @param a - * @param b + * Convert degrees to radians. + * @param d */ -export function deepCompare(a: T, b: T): boolean { - return a === b || JSON.stringify(a) === JSON.stringify(b) +export function degreesToRadians(d: number): number { + return (d * Math.PI) / 180 } /** - * Find whether two arrays intersect. - * @param a - * @param b - * @param fn An optional function to apply to the items of a; will check if b includes the result. + * Convert radians to degrees. + * @param r */ -export function arrsIntersect( - a: T[], - b: K[], - fn?: (item: K) => T -): boolean -export function arrsIntersect(a: T[], b: T[]): boolean -export function arrsIntersect( - a: T[], - b: unknown[], - fn?: (item: unknown) => T -): boolean { - return a.some((item) => b.includes(fn ? fn(item) : item)) +export function radiansToDegrees(r: number): number { + return (r * 180) / Math.PI } /** - * Get the unique values from an array of strings or numbers. - * @param items + * Get the length of an arc between two points on a circle's perimeter. + * @param C + * @param r + * @param A + * @param B */ -export function uniqueArray(...items: T[]): T[] { - return Array.from(new Set(items).values()) +export function getArcLength( + C: number[], + r: number, + A: number[], + B: number[] +): number { + const sweep = getSweep(C, A, B) + return r * (2 * Math.PI) * (sweep / (2 * Math.PI)) } /** - * Convert a set to an array. - * @param set + * Get balanced dash-strokearray and dash-strokeoffset properties for a path of a given length. + * @param length The length of the path. + * @param strokeWidth The shape's stroke-width property. + * @param style The stroke's style: "dashed" or "dotted" (default "dashed"). + * @param snap An interval for dashes (e.g. 4 will produce arrays with 4, 8, 16, etc dashes). */ -export function setToArray(set: Set): T[] { - return Array.from(set.values()) +export function getPerfectDashProps( + length: number, + strokeWidth: number, + style: 'dashed' | 'dotted' = 'dashed', + snap = 1 +): { + strokeDasharray: string + strokeDashoffset: string +} { + let dashLength: number + let strokeDashoffset: string + let ratio: number + + if (style === 'dashed') { + dashLength = strokeWidth * 2 + ratio = 1 + strokeDashoffset = (dashLength / 2).toString() + } else { + dashLength = strokeWidth / 100 + ratio = 100 + strokeDashoffset = '0' + } + + let dashes = Math.floor(length / dashLength / (2 * ratio)) + dashes -= dashes % snap + if (dashes === 0) dashes = 1 + + const gapLength = (length - dashes * dashLength) / dashes + + return { + strokeDasharray: [dashLength, gapLength].join(' '), + strokeDashoffset, + } } -/* -------------------- Hit Tests ------------------- */ +/** + * Get a dash offset for an arc, based on its length. + * @param C + * @param r + * @param A + * @param B + * @param step + */ +export function getArcDashOffset( + C: number[], + r: number, + A: number[], + B: number[], + step: number +): number { + const del0 = getSweep(C, A, B) + const len0 = getArcLength(C, r, A, B) + const off0 = del0 < 0 ? len0 : 2 * Math.PI * C[2] - len0 + return -off0 / 2 + step +} + +/** + * Get a dash offset for an ellipse, based on its length. + * @param A + * @param step + */ +export function getEllipseDashOffset(A: number[], step: number): number { + const c = 2 * Math.PI * A[2] + return -c / 2 + -step +} + +/* --------------- Curves and Splines --------------- */ + +/** + * Get bezier curve segments that pass through an array of points. + * @param points + * @param tension + */ +export function getBezierCurveSegments( + points: number[][], + tension = 0.4 +): BezierCurveSegment[] { + const len = points.length, + cpoints: number[][] = [...points] + + if (len < 2) { + throw Error('Curve must have at least two points.') + } + + for (let i = 1; i < len - 1; i++) { + const p0 = points[i - 1], + p1 = points[i], + p2 = points[i + 1] + + const pdx = p2[0] - p0[0], + pdy = p2[1] - p0[1], + pd = Math.hypot(pdx, pdy), + nx = pdx / pd, // normalized x + ny = pdy / pd, // normalized y + dp = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]), // Distance to previous + dn = Math.hypot(p1[0] - p2[0], p1[1] - p2[1]) // Distance to next + + cpoints[i] = [ + // tangent start + p1[0] - nx * dp * tension, + p1[1] - ny * dp * tension, + // tangent end + p1[0] + nx * dn * tension, + p1[1] + ny * dn * tension, + // normal + nx, + ny, + ] + } + + // TODO: Reflect the nearest control points, not average them + const d0 = Math.hypot(points[0][0] + cpoints[1][0]) + cpoints[0][2] = (points[0][0] + cpoints[1][0]) / 2 + cpoints[0][3] = (points[0][1] + cpoints[1][1]) / 2 + cpoints[0][4] = (cpoints[1][0] - points[0][0]) / d0 + cpoints[0][5] = (cpoints[1][1] - points[0][1]) / d0 + + const d1 = Math.hypot(points[len - 1][1] + cpoints[len - 1][1]) + cpoints[len - 1][0] = (points[len - 1][0] + cpoints[len - 2][2]) / 2 + cpoints[len - 1][1] = (points[len - 1][1] + cpoints[len - 2][3]) / 2 + cpoints[len - 1][4] = (cpoints[len - 2][2] - points[len - 1][0]) / -d1 + cpoints[len - 1][5] = (cpoints[len - 2][3] - points[len - 1][1]) / -d1 + + const results: BezierCurveSegment[] = [] + + for (let i = 1; i < cpoints.length; i++) { + results.push({ + start: points[i - 1].slice(0, 2), + tangentStart: cpoints[i - 1].slice(2, 4), + normalStart: cpoints[i - 1].slice(4, 6), + pressureStart: 2 + ((i - 1) % 2 === 0 ? 1.5 : 0), + end: points[i].slice(0, 2), + tangentEnd: cpoints[i].slice(0, 2), + normalEnd: cpoints[i].slice(4, 6), + pressureEnd: 2 + (i % 2 === 0 ? 1.5 : 0), + }) + } + + return results +} + +/** + * Find a point along a curve segment, via pomax. + * @param t + * @param points [cpx1, cpy1, cpx2, cpy2, px, py][] + */ +export function computePointOnCurve(t: number, points: number[][]): number[] { + // shortcuts + if (t === 0) { + return points[0] + } + + const order = points.length - 1 + + if (t === 1) { + return points[order] + } + + const mt = 1 - t + let p = points // constant? + + if (order === 0) { + return points[0] + } // linear? + + if (order === 1) { + return [mt * p[0][0] + t * p[1][0], mt * p[0][1] + t * p[1][1]] + } // quadratic/cubic curve? + + if (order < 4) { + const mt2 = mt * mt, + t2 = t * t + + let a: number, + b: number, + c: number, + d = 0 + + if (order === 2) { + p = [p[0], p[1], p[2], [0, 0]] + a = mt2 + b = mt * t * 2 + c = t2 + } else if (order === 3) { + a = mt2 * mt + b = mt2 * t * 3 + c = mt * t2 * 3 + d = t * t2 + } + + return [ + a * p[0][0] + b * p[1][0] + c * p[2][0] + d * p[3][0], + a * p[0][1] + b * p[1][1] + c * p[2][1] + d * p[3][1], + ] + } // higher order curves: use de Casteljau's computation +} + +/** + * Evaluate a 2d cubic bezier at a point t on the x axis. + * @param tx + * @param x1 + * @param y1 + * @param x2 + * @param y2 + */ +export function cubicBezier( + tx: number, + x1: number, + y1: number, + x2: number, + y2: number +): number { + // Inspired by Don Lancaster's two articles + // http://www.tinaja.com/glib/cubemath.pdf + // http://www.tinaja.com/text/bezmath.html + + // Set start and end point + const x0 = 0, + y0 = 0, + x3 = 1, + y3 = 1, + // Convert the coordinates to equation space + A = x3 - 3 * x2 + 3 * x1 - x0, + B = 3 * x2 - 6 * x1 + 3 * x0, + C = 3 * x1 - 3 * x0, + D = x0, + E = y3 - 3 * y2 + 3 * y1 - y0, + F = 3 * y2 - 6 * y1 + 3 * y0, + G = 3 * y1 - 3 * y0, + H = y0, + // Variables for the loop below + iterations = 5 + + let i: number, + slope: number, + x: number, + t = tx + + // Loop through a few times to get a more accurate time value, according to the Newton-Raphson method + // http://en.wikipedia.org/wiki/Newton's_method + for (i = 0; i < iterations; i++) { + // The curve's x equation for the current time value + x = A * t * t * t + B * t * t + C * t + D + + // The slope we want is the inverse of the derivate of x + slope = 1 / (3 * A * t * t + 2 * B * t + C) + + // Get the next estimated time value, which will be more accurate than the one before + t -= (x - tx) * slope + t = t > 1 ? 1 : t < 0 ? 0 : t + } + + // Find the y value through the curve's y equation, with the now more accurate time value + return Math.abs(E * t * t * t + F * t * t + G * t * H) +} + +/** + * Get a bezier curve data for a spline that fits an array of points. + * @param points An array of points formatted as [x, y] + * @param k Tension + */ +export function getSpline( + pts: number[][], + k = 0.5 +): { + cp1x: number + cp1y: number + cp2x: number + cp2y: number + px: number + py: number +}[] { + let p0: number[] + let [p1, p2, p3] = pts + + const results: { + cp1x: number + cp1y: number + cp2x: number + cp2y: number + px: number + py: number + }[] = [] + + for (let i = 1, len = pts.length; i < len; i++) { + p0 = p1 + p1 = p2 + p2 = p3 + p3 = pts[i + 2] ? pts[i + 2] : p2 + + results.push({ + cp1x: p1[0] + ((p2[0] - p0[0]) / 6) * k, + cp1y: p1[1] + ((p2[1] - p0[1]) / 6) * k, + cp2x: p2[0] - ((p3[0] - p1[0]) / 6) * k, + cp2y: p2[1] - ((p3[1] - p1[1]) / 6) * k, + px: pts[i][0], + py: pts[i][1], + }) + } + + return results +} + +/** + * Get a bezier curve data for a spline that fits an array of points. + * @param pts + * @param tension + * @param isClosed + * @param numOfSegments + */ +export function getCurvePoints( + pts: number[][], + tension = 0.5, + isClosed = false, + numOfSegments = 3 +): number[][] { + const _pts = [...pts], + len = pts.length, + res: number[][] = [] // results + + let t1x: number, // tension vectors + t2x: number, + t1y: number, + t2y: number, + c1: number, // cardinal points + c2: number, + c3: number, + c4: number, + st: number, + st2: number, + st3: number + + // The algorithm require a previous and next point to the actual point array. + // Check if we will draw closed or open curve. + // If closed, copy end points to beginning and first points to end + // If open, duplicate first points to befinning, end points to end + if (isClosed) { + _pts.unshift(_pts[len - 1]) + _pts.push(_pts[0]) + } else { + //copy 1. point and insert at beginning + _pts.unshift(_pts[0]) + _pts.push(_pts[len - 1]) + // _pts.push(_pts[len - 1]) + } + + // For each point, calculate a segment + for (let i = 1; i < _pts.length - 2; i++) { + // Calculate points along segment and add to results + for (let t = 0; t <= numOfSegments; t++) { + // Step + st = t / numOfSegments + st2 = Math.pow(st, 2) + st3 = Math.pow(st, 3) + + // Cardinals + c1 = 2 * st3 - 3 * st2 + 1 + c2 = -(2 * st3) + 3 * st2 + c3 = st3 - 2 * st2 + st + c4 = st3 - st2 + + // Tension + t1x = (_pts[i + 1][0] - _pts[i - 1][0]) * tension + t2x = (_pts[i + 2][0] - _pts[i][0]) * tension + t1y = (_pts[i + 1][1] - _pts[i - 1][1]) * tension + t2y = (_pts[i + 2][1] - _pts[i][1]) * tension + + // Control points + res.push([ + c1 * _pts[i][0] + c2 * _pts[i + 1][0] + c3 * t1x + c4 * t2x, + c1 * _pts[i][1] + c2 * _pts[i + 1][1] + c3 * t1y + c4 * t2y, + ]) + } + } + + res.push(pts[pts.length - 1]) + + return res +} + +/** + * Simplify a line (using Ramer-Douglas-Peucker algorithm). + * @param points An array of points as [x, y, ...][] + * @param tolerance The minimum line distance (also called epsilon). + * @returns Simplified array as [x, y, ...][] + */ +export function simplify(points: number[][], tolerance = 1): number[][] { + const len = points.length, + a = points[0], + b = points[len - 1], + [x1, y1] = a, + [x2, y2] = b + + if (len > 2) { + let distance = 0 + let index = 0 + const max = Math.hypot(y2 - y1, x2 - x1) + + for (let i = 1; i < len - 1; i++) { + const [x0, y0] = points[i], + d = Math.abs((y2 - y1) * x0 - (x2 - x1) * y0 + x2 * y1 - y2 * x1) / max + + if (distance > d) continue + + distance = d + index = i + } + + if (distance > tolerance) { + const l0 = simplify(points.slice(0, index + 1), tolerance) + const l1 = simplify(points.slice(index + 1), tolerance) + return l0.concat(l1.slice(1)) + } + } + + return [a, b] +} /** * Get whether a point is inside of a circle. @@ -849,687 +1500,84 @@ export function getBoundsCenter(bounds: Bounds): number[] { return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2] } -/* --------------- Circles and Angles --------------- */ +/* -------------------------------------------------- */ +/* Lists and Collections */ +/* -------------------------------------------------- */ /** - * Get the outer of between a circle and a point. - * @param C The circle's center. - * @param r The circle's radius. - * @param P The point. - * @param side + * Get a unique string id. */ -export function getCircleTangentToPoint( - C: number[], - r: number, - P: number[], - side: number -): number[] { - const B = vec.lrp(C, P, 0.5), - r1 = vec.dist(C, B), - delta = vec.sub(B, C), - d = vec.len(delta) - - if (!(d <= r + r1 && d >= Math.abs(r - r1))) { - return - } - - const a = (r * r - r1 * r1 + d * d) / (2.0 * d), - n = 1 / d, - p = vec.add(C, vec.mul(delta, a * n)), - h = Math.sqrt(r * r - a * a), - k = vec.mul(vec.per(delta), h * n) - - return side === 0 ? vec.add(p, k) : vec.sub(p, k) +export function uniqueId(): string { + return uuid() } /** - * Get outer tangents of two circles. - * @param x0 - * @param y0 - * @param r0 - * @param x1 - * @param y1 - * @param r1 - * @returns [lx0, ly0, lx1, ly1, rx0, ry0, rx1, ry1] + * Shuffle the contents of an array. + * @param arr + * @param offset */ -export function getOuterTangentsOfCircles( - C0: number[], - r0: number, - C1: number[], - r1: number -): number[][] { - const a0 = vec.angle(C0, C1) - const d = vec.dist(C0, C1) - - // Circles are overlapping, no tangents - if (d < Math.abs(r1 - r0)) return - - const a1 = Math.acos((r0 - r1) / d), - t0 = a0 + a1, - t1 = a0 - a1 - - return [ - [C0[0] + r0 * Math.cos(t1), C0[1] + r0 * Math.sin(t1)], - [C1[0] + r1 * Math.cos(t1), C1[1] + r1 * Math.sin(t1)], - [C0[0] + r0 * Math.cos(t0), C0[1] + r0 * Math.sin(t0)], - [C1[0] + r1 * Math.cos(t0), C1[1] + r1 * Math.sin(t0)], - ] +export function shuffleArr(arr: T[], offset: number): T[] { + return arr.map((_, i) => arr[(i + offset) % arr.length]) } /** - * Get the closest point on the perimeter of a circle to a given point. - * @param C The circle's center. - * @param r The circle's radius. - * @param P The point. - */ -export function getClosestPointOnCircle( - C: number[], - r: number, - P: number[] -): number[] { - const v = vec.sub(C, P) - return vec.sub(C, vec.mul(vec.div(v, vec.len(v)), r)) -} - -function det( - a: number, - b: number, - c: number, - d: number, - e: number, - f: number, - g: number, - h: number, - i: number -): number { - return a * e * i + b * f * g + c * d * h - a * f * h - b * d * i - c * e * g -} - -/** - * Get a circle from three points. - * @param A - * @param B - * @param C - * @returns [x, y, r] - */ -export function circleFromThreePoints( - A: number[], - B: number[], - C: number[] -): number[] { - const a = det(A[0], A[1], 1, B[0], B[1], 1, C[0], C[1], 1) - - const bx = -det( - A[0] * A[0] + A[1] * A[1], - A[1], - 1, - B[0] * B[0] + B[1] * B[1], - B[1], - 1, - C[0] * C[0] + C[1] * C[1], - C[1], - 1 - ) - const by = det( - A[0] * A[0] + A[1] * A[1], - A[0], - 1, - B[0] * B[0] + B[1] * B[1], - B[0], - 1, - C[0] * C[0] + C[1] * C[1], - C[0], - 1 - ) - const c = -det( - A[0] * A[0] + A[1] * A[1], - A[0], - A[1], - B[0] * B[0] + B[1] * B[1], - B[0], - B[1], - C[0] * C[0] + C[1] * C[1], - C[0], - C[1] - ) - - const x = -bx / (2 * a) - const y = -by / (2 * a) - const r = Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a)) - - return [x, y, r] -} - -/** - * Find the approximate perimeter of an ellipse. - * @param rx - * @param ry - */ -export function perimeterOfEllipse(rx: number, ry: number): number { - const h = Math.pow(rx - ry, 2) / Math.pow(rx + ry, 2) - const p = Math.PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h))) - return p -} - -/** - * Get the short angle distance between two angles. - * @param a0 - * @param a1 - */ -export function shortAngleDist(a0: number, a1: number): number { - const max = Math.PI * 2 - const da = (a1 - a0) % max - return ((2 * da) % max) - da -} - -/** - * Get the long angle distance between two angles. - * @param a0 - * @param a1 - */ -export function longAngleDist(a0: number, a1: number): number { - return Math.PI * 2 - shortAngleDist(a0, a1) -} - -/** - * Interpolate an angle between two angles. - * @param a0 - * @param a1 - * @param t - */ -export function lerpAngles(a0: number, a1: number, t: number): number { - return a0 + shortAngleDist(a0, a1) * t -} - -/** - * Get the short distance between two angles. - * @param a0 - * @param a1 - */ -export function angleDelta(a0: number, a1: number): number { - return shortAngleDist(a0, a1) -} - -/** - * Get the "sweep" or short distance between two points on a circle's perimeter. - * @param C - * @param A - * @param B - */ -export function getSweep(C: number[], A: number[], B: number[]): number { - return angleDelta(vec.angle(C, A), vec.angle(C, B)) -} - -/** - * Rotate a point around a center. - * @param x The x-axis coordinate of the point. - * @param y The y-axis coordinate of the point. - * @param cx The x-axis coordinate of the point to rotate round. - * @param cy The y-axis coordinate of the point to rotate round. - * @param angle The distance (in radians) to rotate. - */ -export function rotatePoint(A: number[], B: number[], angle: number): number[] { - const s = Math.sin(angle) - const c = Math.cos(angle) - - const px = A[0] - B[0] - const py = A[1] - B[1] - - const nx = px * c - py * s - const ny = px * s + py * c - - return [nx + B[0], ny + B[1]] -} - -/** - * Clamp radians within 0 and 2PI - * @param r - */ -export function clampRadians(r: number): number { - return (Math.PI * 2 + r) % (Math.PI * 2) -} - -/** - * Clamp rotation to even segments. - * @param r - * @param segments - */ -export function clampToRotationToSegments(r: number, segments: number): number { - const seg = (Math.PI * 2) / segments - return Math.floor((clampRadians(r) + seg / 2) / seg) * seg -} - -/** - * Is angle c between angles a and b? + * Deep compare two arrays. * @param a * @param b - * @param c */ -export function isAngleBetween(a: number, b: number, c: number): boolean { - if (c === a || c === b) return true - const PI2 = Math.PI * 2 - const AB = (b - a + PI2) % PI2 - const AC = (c - a + PI2) % PI2 - return AB <= Math.PI !== AC > AB +export function deepCompareArrays(a: T[], b: T[]): boolean { + if (a?.length !== b?.length) return false + return deepCompare(a, b) } /** - * Convert degrees to radians. - * @param d + * Deep compare any values. + * @param a + * @param b */ -export function degreesToRadians(d: number): number { - return (d * Math.PI) / 180 +export function deepCompare(a: T, b: T): boolean { + return a === b || JSON.stringify(a) === JSON.stringify(b) } /** - * Convert radians to degrees. - * @param r + * Find whether two arrays intersect. + * @param a + * @param b + * @param fn An optional function to apply to the items of a; will check if b includes the result. */ -export function radiansToDegrees(r: number): number { - return (r * 180) / Math.PI +export function arrsIntersect( + a: T[], + b: K[], + fn?: (item: K) => T +): boolean +export function arrsIntersect(a: T[], b: T[]): boolean +export function arrsIntersect( + a: T[], + b: unknown[], + fn?: (item: unknown) => T +): boolean { + return a.some((item) => b.includes(fn ? fn(item) : item)) } /** - * Get the length of an arc between two points on a circle's perimeter. - * @param C - * @param r - * @param A - * @param B + * Get the unique values from an array of strings or numbers. + * @param items */ -export function getArcLength( - C: number[], - r: number, - A: number[], - B: number[] -): number { - const sweep = getSweep(C, A, B) - return r * (2 * Math.PI) * (sweep / (2 * Math.PI)) +export function uniqueArray(...items: T[]): T[] { + return Array.from(new Set(items).values()) } /** - * Get a dash offset for an arc, based on its length. - * @param C - * @param r - * @param A - * @param B - * @param step + * Convert a set to an array. + * @param set */ -export function getArcDashOffset( - C: number[], - r: number, - A: number[], - B: number[], - step: number -): number { - const del0 = getSweep(C, A, B) - const len0 = getArcLength(C, r, A, B) - const off0 = del0 < 0 ? len0 : 2 * Math.PI * C[2] - len0 - return -off0 / 2 + step +export function setToArray(set: Set): T[] { + return Array.from(set.values()) } -/** - * Get a dash offset for an ellipse, based on its length. - * @param A - * @param step - */ -export function getEllipseDashOffset(A: number[], step: number): number { - const c = 2 * Math.PI * A[2] - return -c / 2 + -step -} - -/* --------------- Curves and Splines --------------- */ - -/** - * Get bezier curve segments that pass through an array of points. - * @param points - * @param tension - */ -export function getBezierCurveSegments( - points: number[][], - tension = 0.4 -): BezierCurveSegment[] { - const len = points.length, - cpoints: number[][] = [...points] - - if (len < 2) { - throw Error('Curve must have at least two points.') - } - - for (let i = 1; i < len - 1; i++) { - const p0 = points[i - 1], - p1 = points[i], - p2 = points[i + 1] - - const pdx = p2[0] - p0[0], - pdy = p2[1] - p0[1], - pd = Math.hypot(pdx, pdy), - nx = pdx / pd, // normalized x - ny = pdy / pd, // normalized y - dp = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]), // Distance to previous - dn = Math.hypot(p1[0] - p2[0], p1[1] - p2[1]) // Distance to next - - cpoints[i] = [ - // tangent start - p1[0] - nx * dp * tension, - p1[1] - ny * dp * tension, - // tangent end - p1[0] + nx * dn * tension, - p1[1] + ny * dn * tension, - // normal - nx, - ny, - ] - } - - // TODO: Reflect the nearest control points, not average them - const d0 = Math.hypot(points[0][0] + cpoints[1][0]) - cpoints[0][2] = (points[0][0] + cpoints[1][0]) / 2 - cpoints[0][3] = (points[0][1] + cpoints[1][1]) / 2 - cpoints[0][4] = (cpoints[1][0] - points[0][0]) / d0 - cpoints[0][5] = (cpoints[1][1] - points[0][1]) / d0 - - const d1 = Math.hypot(points[len - 1][1] + cpoints[len - 1][1]) - cpoints[len - 1][0] = (points[len - 1][0] + cpoints[len - 2][2]) / 2 - cpoints[len - 1][1] = (points[len - 1][1] + cpoints[len - 2][3]) / 2 - cpoints[len - 1][4] = (cpoints[len - 2][2] - points[len - 1][0]) / -d1 - cpoints[len - 1][5] = (cpoints[len - 2][3] - points[len - 1][1]) / -d1 - - const results: BezierCurveSegment[] = [] - - for (let i = 1; i < cpoints.length; i++) { - results.push({ - start: points[i - 1].slice(0, 2), - tangentStart: cpoints[i - 1].slice(2, 4), - normalStart: cpoints[i - 1].slice(4, 6), - pressureStart: 2 + ((i - 1) % 2 === 0 ? 1.5 : 0), - end: points[i].slice(0, 2), - tangentEnd: cpoints[i].slice(0, 2), - normalEnd: cpoints[i].slice(4, 6), - pressureEnd: 2 + (i % 2 === 0 ? 1.5 : 0), - }) - } - - return results -} - -/** - * Find a point along a curve segment, via pomax. - * @param t - * @param points [cpx1, cpy1, cpx2, cpy2, px, py][] - */ -export function computePointOnCurve(t: number, points: number[][]): number[] { - // shortcuts - if (t === 0) { - return points[0] - } - - const order = points.length - 1 - - if (t === 1) { - return points[order] - } - - const mt = 1 - t - let p = points // constant? - - if (order === 0) { - return points[0] - } // linear? - - if (order === 1) { - return [mt * p[0][0] + t * p[1][0], mt * p[0][1] + t * p[1][1]] - } // quadratic/cubic curve? - - if (order < 4) { - const mt2 = mt * mt, - t2 = t * t - - let a: number, - b: number, - c: number, - d = 0 - - if (order === 2) { - p = [p[0], p[1], p[2], [0, 0]] - a = mt2 - b = mt * t * 2 - c = t2 - } else if (order === 3) { - a = mt2 * mt - b = mt2 * t * 3 - c = mt * t2 * 3 - d = t * t2 - } - - return [ - a * p[0][0] + b * p[1][0] + c * p[2][0] + d * p[3][0], - a * p[0][1] + b * p[1][1] + c * p[2][1] + d * p[3][1], - ] - } // higher order curves: use de Casteljau's computation -} - -/** - * Evaluate a 2d cubic bezier at a point t on the x axis. - * @param tx - * @param x1 - * @param y1 - * @param x2 - * @param y2 - */ -export function cubicBezier( - tx: number, - x1: number, - y1: number, - x2: number, - y2: number -): number { - // Inspired by Don Lancaster's two articles - // http://www.tinaja.com/glib/cubemath.pdf - // http://www.tinaja.com/text/bezmath.html - - // Set start and end point - const x0 = 0, - y0 = 0, - x3 = 1, - y3 = 1, - // Convert the coordinates to equation space - A = x3 - 3 * x2 + 3 * x1 - x0, - B = 3 * x2 - 6 * x1 + 3 * x0, - C = 3 * x1 - 3 * x0, - D = x0, - E = y3 - 3 * y2 + 3 * y1 - y0, - F = 3 * y2 - 6 * y1 + 3 * y0, - G = 3 * y1 - 3 * y0, - H = y0, - // Variables for the loop below - iterations = 5 - - let i: number, - slope: number, - x: number, - t = tx - - // Loop through a few times to get a more accurate time value, according to the Newton-Raphson method - // http://en.wikipedia.org/wiki/Newton's_method - for (i = 0; i < iterations; i++) { - // The curve's x equation for the current time value - x = A * t * t * t + B * t * t + C * t + D - - // The slope we want is the inverse of the derivate of x - slope = 1 / (3 * A * t * t + 2 * B * t + C) - - // Get the next estimated time value, which will be more accurate than the one before - t -= (x - tx) * slope - t = t > 1 ? 1 : t < 0 ? 0 : t - } - - // Find the y value through the curve's y equation, with the now more accurate time value - return Math.abs(E * t * t * t + F * t * t + G * t * H) -} - -/** - * Get a bezier curve data for a spline that fits an array of points. - * @param points An array of points formatted as [x, y] - * @param k Tension - */ -export function getSpline( - pts: number[][], - k = 0.5 -): { - cp1x: number - cp1y: number - cp2x: number - cp2y: number - px: number - py: number -}[] { - let p0: number[] - let [p1, p2, p3] = pts - - const results: { - cp1x: number - cp1y: number - cp2x: number - cp2y: number - px: number - py: number - }[] = [] - - for (let i = 1, len = pts.length; i < len; i++) { - p0 = p1 - p1 = p2 - p2 = p3 - p3 = pts[i + 2] ? pts[i + 2] : p2 - - results.push({ - cp1x: p1[0] + ((p2[0] - p0[0]) / 6) * k, - cp1y: p1[1] + ((p2[1] - p0[1]) / 6) * k, - cp2x: p2[0] - ((p3[0] - p1[0]) / 6) * k, - cp2y: p2[1] - ((p3[1] - p1[1]) / 6) * k, - px: pts[i][0], - py: pts[i][1], - }) - } - - return results -} - -/** - * Get a bezier curve data for a spline that fits an array of points. - * @param pts - * @param tension - * @param isClosed - * @param numOfSegments - */ -export function getCurvePoints( - pts: number[][], - tension = 0.5, - isClosed = false, - numOfSegments = 3 -): number[][] { - const _pts = [...pts], - len = pts.length, - res: number[][] = [] // results - - let t1x: number, // tension vectors - t2x: number, - t1y: number, - t2y: number, - c1: number, // cardinal points - c2: number, - c3: number, - c4: number, - st: number, - st2: number, - st3: number - - // The algorithm require a previous and next point to the actual point array. - // Check if we will draw closed or open curve. - // If closed, copy end points to beginning and first points to end - // If open, duplicate first points to befinning, end points to end - if (isClosed) { - _pts.unshift(_pts[len - 1]) - _pts.push(_pts[0]) - } else { - //copy 1. point and insert at beginning - _pts.unshift(_pts[0]) - _pts.push(_pts[len - 1]) - // _pts.push(_pts[len - 1]) - } - - // For each point, calculate a segment - for (let i = 1; i < _pts.length - 2; i++) { - // Calculate points along segment and add to results - for (let t = 0; t <= numOfSegments; t++) { - // Step - st = t / numOfSegments - st2 = Math.pow(st, 2) - st3 = Math.pow(st, 3) - - // Cardinals - c1 = 2 * st3 - 3 * st2 + 1 - c2 = -(2 * st3) + 3 * st2 - c3 = st3 - 2 * st2 + st - c4 = st3 - st2 - - // Tension - t1x = (_pts[i + 1][0] - _pts[i - 1][0]) * tension - t2x = (_pts[i + 2][0] - _pts[i][0]) * tension - t1y = (_pts[i + 1][1] - _pts[i - 1][1]) * tension - t2y = (_pts[i + 2][1] - _pts[i][1]) * tension - - // Control points - res.push([ - c1 * _pts[i][0] + c2 * _pts[i + 1][0] + c3 * t1x + c4 * t2x, - c1 * _pts[i][1] + c2 * _pts[i + 1][1] + c3 * t1y + c4 * t2y, - ]) - } - } - - res.push(pts[pts.length - 1]) - - return res -} - -/** - * Simplify a line (using Ramer-Douglas-Peucker algorithm). - * @param points An array of points as [x, y, ...][] - * @param tolerance The minimum line distance (also called epsilon). - * @returns Simplified array as [x, y, ...][] - */ -export function simplify(points: number[][], tolerance = 1): number[][] { - const len = points.length, - a = points[0], - b = points[len - 1], - [x1, y1] = a, - [x2, y2] = b - - if (len > 2) { - let distance = 0 - let index = 0 - const max = Math.hypot(y2 - y1, x2 - x1) - - for (let i = 1; i < len - 1; i++) { - const [x0, y0] = points[i], - d = Math.abs((y2 - y1) * x0 - (x2 - x1) * y0 + x2 * y1 - y2 * x1) / max - - if (distance > d) continue - - distance = d - index = i - } - - if (distance > tolerance) { - const l0 = simplify(points.slice(0, index + 1), tolerance) - const l1 = simplify(points.slice(index + 1), tolerance) - return l0.concat(l1.slice(1)) - } - } - - return [a, b] -} - -/* ----------------- Browser and DOM ---------------- */ +/* -------------------------------------------------- */ +/* Browser and DOM */ +/* -------------------------------------------------- */ /** * Find whether the current display is a touch display.