Merge pull request #68 from tldraw/next

Big rebuild! Last features (e.g. filesystem saving) will be added as regular issues.
This commit is contained in:
Steve Ruiz 2021-09-04 16:46:38 +01:00 committed by GitHub
commit 010a09ff19
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
568 changed files with 52871 additions and 42819 deletions

View file

@ -1,3 +0,0 @@
**/node_modules/*
**/out/*
**/.next/*

15
.eslintrc.js Normal file
View file

@ -0,0 +1,15 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
overrides: [
{
// enable the rule specifically for TypeScript files
files: ['*.ts', '*.tsx'],
rules: {
'@typescript-eslint/explicit-module-boundary-types': [0],
},
},
],
}

View file

@ -1,49 +0,0 @@
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
// Uncomment the following lines to enable eslint-config-prettier
// Is not enabled right now to avoid issues with the Next.js repo
// "prettier",
],
"env": {
"es6": true,
"browser": true,
"jest": true,
"node": true
},
"settings": {
"react": {
"version": "detect"
}
},
"ignorePatterns": "**/*.js",
"rules": {
"react/react-in-jsx-scope": 0,
"react/display-name": 0,
"react/prop-types": 0,
"@typescript-eslint/no-extra-semi": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/explicit-member-accessibility": 0,
"@typescript-eslint/indent": 0,
"@typescript-eslint/member-delimiter-style": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/no-use-before-define": 0,
"@typescript-eslint/no-unused-vars": [
2,
{
"argsIgnorePattern": "^_"
}
],
"no-console": [
2,
{
"allow": ["warn", "error"]
}
]
}
}

2
.gitattributes vendored
View file

@ -1,2 +0,0 @@
# Auto detect text files and perform LF normalization
* text=auto

View file

@ -5,9 +5,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
# install modules
- name: Install modules
run: yarn
# unit tests
# build
- name: Build
run: yarn build:packages
# run unit tests
- name: Jest Annotations & Coverage
uses: mattallty/jest-github-action@v1.0.3
env:

43
.gitignore vendored
View file

@ -1,37 +1,10 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
node_modules/
build/
lib/
dist/
docs/
.idea/*
# dependencies
/dist
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
cypress/integration/examples
coverage
*.log

17
.npmignore Normal file
View file

@ -0,0 +1,17 @@
/.github/
/.vscode/
/node_modules/
/build/
/tmp/
.idea/*
/docs/
coverage
*.log
.gitlab-ci.yml
package-lock.json
/*.tgz
/tmp*
/mnt/
/package/

View file

@ -1,6 +0,0 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}

View file

@ -1,7 +1,7 @@
{
"createComment": {
"scope": "typescript,typescriptreact",
"prefix": "/**",
"prefix": "ccc",
"body": [
"/**",
" * ${1:description}",
@ -10,8 +10,7 @@
" *",
" *```ts",
" * ${2:example}",
" *```",
" */"
" *```"
],
"description": "comment"
}

18
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "tsc",
"type": "shell",
"command": "./node_modules/.bin/tsc",
"args": ["--noEmit"],
"presentation": {
"reveal": "never",
"echo": false,
"focus": false,
"panel": "dedicated"
},
"problemMatcher": "$tsc-watch"
}
]
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Chris Hager
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

367
README.md
View file

@ -15,10 +15,19 @@ To support this project (and gain access to the project while it is in developme
## Documentation
...
In progress! Check the README files in [packages/core](packages/core/README.md) and [packages/tldraw](packages/tldraw/README.md).
## Examples
- [@tldraw/core example](https://codesandbox.io/s/tldraw-core-example-88c74)
- [@tldraw/tldraw example](https://codesandbox.io/s/tldraw-example-n539u)
## Local Development
### The tldraw packages
To work on the packages (@tldraw/core or @tldraw/tldraw), you'll want to run the (extremely fast) dev server.
1. Download or clone the repository.
```bash
@ -34,357 +43,19 @@ To support this project (and gain access to the project while it is in developme
3. Start the development server.
```bash
yarn dev
yarn start
```
4. Open the local site at `https://localhost:3000`.
4. Open the local site at `https://localhost:5000`.
This project is a [Next.js](https://nextjs.org/) project. If you've worked with Next.js before, the tldraw code-base and setup instructions should all be very familiar.
### The tldraw app
## How it works
To work on the app itself (that embeds @tldraw/tldraw), run the Next.js app. This won't directly respond to changes to packages, so for concurrent package dev work be sure to use the package dev server instead. (This is being worked on.)
The app's state is a very large state machine located in `state/state.ts`. The machine is organized as a tree of state notes, such as `selecting` and `pinching`.
1. Start the development server.
```
root
├── loading
└── ready
├── selecting
│ ├── notPointing
│ ├── pointingBounds
│ ├── translatingSelection
│ └── ...
├── usingTool
├── pinching
└── ...
```
```bash
yarn start:www
```
### State Nodes
Nodes may be active or inactive. The root node is always active. Depending on what's happened in the app, different branches of the state tree may be active, while other branches may be inactive.
```ts
pinching: {
onExit: { secretlyDo: 'updateZoomCSS' },
initial: 'selectPinching',
states: {
selectPinching: {
on: {
STOPPED_PINCHING: { to: 'selecting' },
},
},
toolPinching: {
on: {
STOPPED_PINCHING: { to: 'usingTool.previous' },
},
},
},
},
```
State nodes are both a way to describe the state (e.g., "the pinching state is active") and a way of organizing events. Each node has a set of events (`on`). When the state receives an event, it will execute the event handlers on each of the machine's _active_ states where the event is present.
### Event Handlers
Event handlers contain references to event handler functions: `actions`, `conditions`, `results`, and `async`s. These are defined at the bottom of the state machine's configuration.
An event handler may be a single action:
```ts
on: {
MOVED_POINTER: 'updateRotateSession',
}
```
Or it may be an array of actions:
```ts
on: {
MOVED_TO_PAGE: ['moveSelectionToPage', 'zoomCameraToSelectionActual'],
}
```
Or it may be an object with conditions under `if` or `unless` and actions under `do`:
```ts
on: {
SAVED_CODE: {
unless: 'isReadOnly',
do: 'saveCode',
}
}
```
An event handler may also contain transitions under `to`:
```ts
on: {
STOPPED_PINCHING: { to: 'selecting' },
},
```
As well as nested event handlers under control flow, `then` and `else`.
```ts
on: {
STOPPED_POINTING: {
if: 'isPressingShiftKey',
then: {
if: 'isPointedShapeSelected',
do: 'pullPointedIdFromSelectedIds',
},
else: {
if: 'isPointingShape',
do: [
'clearSelectedIds',
'setPointedId',
'pushPointedIdToSelectedIds',
],
},
},
}
```
And finally, an event handler may have arrays of event handler objects.
```ts
on: {
STOPPED_POINTING: [
'completeSession',
{
if: 'isToolLocked',
to: 'dot.creating',
else: {
to: 'selecting'
},
},
],
}
```
### Event Handler Functions
The configuration's event handlers work by calling event handler functions. While each event handler function does a different job, all event handler functions receive the _same_ three parameters:
1. The machine's current `data` draft
2. The payload sent by the event that has caused the function to run
3. The most recent result returned by a `result` function
> Note: The `payload` and `result` parameters must be typed manually inline.
```ts
eventHandlerFn(data, payload: { id: string }, result: Shape) {}
```
Results may return any value.
```ts
pageById(data, payload: { id: string }) {
return data.document.pages[payload.id]
}
```
Conditions must return `true` or `false`.
```ts
pageIsCurrentPage(data, payload, result: Page) {
return data.currentPageId === result.id
}
```
Actions may mutate the `data` draft.
```ts
setCurrentPageId(data, payload, result: Page) {
data.currentPageId = result.id
}
```
In a state's event handlers, event handler functions are referred to by name.
```ts
on: {
SOME_EVENT: {
get: "pageById"
unless: "pageIsCurrentPage",
do: "setCurrentPageId"
}
}
```
Asyncs are asynchronous functions. They work like results, but resolve data instead.
```ts
async getCurrentUser(data) {
return fetch(`https://tldraw/api/users/${data.currentUserId}`)
}
```
These are used in asynchronous event handlers:
```ts
loadingUser: {
async: {
await: "getCurrentUser",
onResolve: { to: "user" },
onReject: { to: "error" },
}
}
```
### State Updates
The state will update each time it:
1. receives an event...
2. that causes it to run an event handler...
3. that passes its conditions...
4. and that contains either an action or a transition
Such updates are batched: while a single event may cause several event handlers to run, the state will update only once provided that at least one of the event handlers caused an action or transition to occur.
When a state updates, it will cause any subscribed components to update via hooks.
### Subscribing to State
To use the state's data reactively, we use the `useSelector` hook.
```tsx
import state, { useSelector } from 'state'
function SomeComponent() {
const pointingId = useSelector((s) => s.data.pointingId)
return <div>The pointing id is {pointingId}</div>
}
```
Each time the state updates, the hook will check whether the data returned by the selector function matches its previous data. If the answer is false (ie if the data is new) the hook will update and the new data will become its previous data.
The hook may also accept a second parameter, a comparison function. If the selector function returns anything other than a primitive, we will often use a comparison function to correctly find changes.
```tsx
import state, { useSelector } from 'state'
import { deepCompareArrays } from 'utils'
function SomeComponent() {
const selectedIds = useSelector(
(s) => tld.getSelectedShapes(s.data).map((shape) => shape.id),
deepCompareArrays
)
return <div>The selected ids are {selectedIds.toString()}</div>
}
```
### Events
Events are sent from the user interface to the state.
```ts
import state from 'state'
state.send('SELECTED_DRAW_TOOL')
```
Events may also include payloads of data.
```ts
state.send('ALIGNED', { type: AlignType.Right })
```
The payload will become the second parameter of any event handler function that runs as a result of the event.
Note that with very few exceptions, we send events to the state regardless of whether the state can handle the event. Whether the event should have an effect—and what that effect should be—these questions are left entirely to the state machine.
> Note: You can send an event to the state from anywhere in the app: even from components that are not subscribed to the state. See the components/style-panel files for examples.
### Commands and History
The app uses a command pattern to keep track of what has happened in the app, and to support an undo and redo stack. Each command includes a `do` method and an `undo` method. When the command is created, it will run its `do` method. If it is "undone", it will run its `undo` method. If the command is "redone", it will run its `do` method again.
```ts
export default function nudgeCommand(data: Data, delta: number[]): void {
const initialShapes = tld.getSelectedShapeSnapshot(data, () => null)
history.execute(
data,
new Command({
name: 'nudge_shapes',
category: 'canvas',
do(data) {
tld.mutateShapes(
data,
initialShapes.map((shape) => shape.id),
(shape, utils) => {
utils.setProperty(shape, 'point', vec.add(shape.point, delta))
}
)
},
undo(data) {
tld.mutateShapes(
data,
initialShapes.map((shape) => shape.id),
(shape, utils) => {
utils.setProperty(shape, 'point', vec.sub(shape.point, delta))
}
)
},
})
)
}
```
Undos are not done programatically. It's the responsibility of a command to ensure that any mutations made in its `do` method are correctly reversed in its `undo` method.
> Note: All mutations to a shape must be done through a shape's utils (the structure returned by `getShapeUtils`). Currently, many commands do this directly: however we're currently working on a more robust API for this, with built-in support for side effects, such as shown with `mutateShapes` above.
### Sessions
Not every change to the app's state needs to be put into the undo / redo stack. Sessions are a way of managing the data associated with certain states that lie _between_ commands, such as when a user is dragging a shape to a new position.
Sessions are managed by the SessionManager (`state/session`). It guarantees that only one session is active at a time and allows other parts of the app's state to access information about the current session.
A session's life cycle is accessed via four methods, `begin`, `update`, `cancel` and `complete`. Different sessions will implement these methods in different ways.
A session begins when constructed.
```ts
session.begin(
new Sessions.TranslateSession(
data,
tld.screenToWorld(inputs.pointer.origin, data)
)
)
```
Next, the session receives updates. Note that we're passing in the `data` draft from an action. The session will make any necessary changes to the draft.
```ts
session.update<Sessions.TranslateSession>(
data,
tld.screenToWorld(payload.point, data),
payload.shiftKey,
payload.altKey
)
```
> Note: To get proper typing in `session.update`, you must provide the generic type of the session you're updating.
When a session completes, the session calls a method. This way, a user is able to travel back through the undo stack, visit only discrete commands (like deleting a shape) and those commands that marked the end of a session.
```ts
session.complete(data)
```
A session may also be cancelled.
```ts
session.cancel(data)
```
When cancelled, it is the responsibility of the session to restore the state to exactly how it was when the session began, reversing any changes that were made to the state during the session.
For this reason, many sessions begin by taking a snapshot of the current draft.
> Because the draft is a JavaScript Proxy, you must deep clone any parts of the draft that you want to include in a snapshot. (Direct references will fail as the underlying Proxy will have expired.) While the memory size of a snapshot is not usually a concern, this deep-cloning process is thread-blocking, so try to snapshot only the parts of the `data` draft that you need.
2. Open the local site at `https://localhost:3000`.

View file

@ -1,239 +0,0 @@
# Testing Guide
Writing tests for tldraw? Thank you! This guide will get you started.
- [Getting Started](#getting-started)
- [How to Test](#how-to-test)
- [What to Test](#what-to-test)
- [I Found a Bug!](#i-found-a-bug)
- [TestUtils](#test-utils)
- [Conclusion and Tips](#conclusion-and-tips)
## Getting Started
This project uses Jest for its unit tests.
- To run the test suite, run `yarn test` in your terminal.
- To start the test watcher, run `yarn test:watch`.
- To update the test snapshots, run `yarn test:update`.
Tests live inside of the `__tests__` folder.
To create a new test, create a file named `myTest.test.ts` inside of the `__tests__` folder.
## How to Test
In tldraw, we write our tests against application's _state_.
Remember that in tldraw, user interactions send _events_ to the app's _state_, where they produce a change (or not) depending on the state's configuration and current status. To test a feature, we "manually" send those same events to the state machine and then check whether the events produced the change we expected.
To test a feature, we'll need to:
- learn how the features works in tldraw
- identify the events involved
- identify the outcome of the events
- reproduce the events in our test
- test the outcome
### Example
Let's say we want to test the "create a page" feature of the app.
We'd start by creating a new file named `create-page.test.ts`. Here's some boilerplace to get us started.
```ts
// __tests__/create-page.test.ts
import TestState from '../test-utils'
const tt = new TestState()
it('creates a new page', () => {})
```
In the code above, we import our `TestState` class, create a new instance for this test file, then have a unit test that will assert something about our app's behavior.
In the app's UI, we can find a button labelled "create page".
```tsx
// page-panel.tsx
<DropdownMenuButton onSelect={() => state.send('CREATED_PAGE')} />
```
Because we're only testing the state machine, we don't have to worry about how the `DropdownMenuButton` component works, or whether `onSelect` is implemented correctly. Instead, our only concern is the call to `state.send`, where we "send" the `CREATED_PAGE` event to the app's central state machine.
Back in our test, we can send that the `CREATED_PAGE` event ourselves and check whether it's produced the correct outcome.
```ts
// __tests__/create-page.test.ts
import TestState from '../test-utils'
const tt = new TestState()
it('creates a new page', () => {
const pageCountBefore = Object.keys(tt.state.data.document.pages).length
tt.state.send('CREATED_PAGE')
const pageCountAfter = Object.keys(tt.state.data.document.pages).length
expect(pageCountAfter).toEqual(pageCountBefore + 1)
})
```
If we run our tests (with `yarn test`) or if we're already in watch mode (`yarn test:watch`) then our tests should update. If it worked, hooray! Now try to make it fail and see what that looks like, too.
## What to Test
While a test like "create a page" is pretty self-explanatory, most features are at least a little complex.
To _fully_ test a feature, we would need to:
- test the entire outcome
- testing every circumstance under which the outcome could be different
Let's take another look at the `CREATED_PAGE` event.
If we search for the event in the state machine itself, we can find where and how event is being handled.
```ts
// state/state.ts
ready: {
on: {
// ...
CREATED_PAGE: {
unless: ['isReadOnly', 'isInSession'],
do: 'createPage',
}
}
}
```
Here's where we can see what exactly we need to test. The event can tell us a few things:
- It should only run when the "ready" state is active
- It never run when the app is in read only mode
- It should never run when the app is in a session (like drawing or rotating)
These are all things that we could test. For example:
```ts
// __tests__/create-page.test.ts
import TestState from '../test-utils'
const tt = new TestState()
it('does not create a new page in read only mode', () => {
tt.state.send('TOGGLED_READ_ONLY')
expect(tt.state.data.isReadOnly).toBe(true)
const pageCountBefore = Object.keys(tt.state.data.document.pages).length
tt.state.send('CREATED_PAGE')
const pageCountAfter = Object.keys(tt.state.data.document.pages).length
expect(pageCountAfter).toEqual(pageCountBefore)
})
```
> Note that we're using a different event, `TOGGLED_READ_ONLY`, in order to get the state into the correct condition to make our test. When using events like this, it's a good idea to assert that the state is how you expect it to be before you make your "real" test. Here that means testing that the state's `data.isReadOnly` boolean is `true` before we test the `CREATED_PAGE` event.
We can also look at the `createPage` action.
```ts
// state/state.ts
createPage(data) {
commands.createPage(data, true)
},
```
If we follow this call, we'll find the `createPage` command (`state/commands/create-page.ts`). This command is more complex, but it gives us more to test:
- did we correctly iterate the numbers in the new page's name?
- did we get the correct child index for the new page?
- did we save the current page to local storage?
- did we save the new page to local storage?
- did we add the new page to the document?
- did we add the new page state to the document?
- did we go to the new page?
- when we undo the command, will we remove the new page / page state?
- when we redo the command, will we put the new page / page state back?
To _fully_ test a feature, we'll need to write tests that cover all of these.
### Todo Tests
...but while full test coverage is a goal, it's not always within reach. If you're not able to test everything about a feature, it's a good idea to write "placeholders" for the tests that need to be written.
```ts
describe('when creating a new page...', () => {
it('sets the correct child index for the new page', () => {
// TODO
})
it('sets the correct name for the new page', () => {
// TODO
})
it('saves the document to local storage', () => {
// TODO
})
})
```
### Snapshots
An even better way to improve coverage when dealing with complex tests is to write "snapshot" tests.
```ts
describe('when creating a new page...', () => {
it('updates the document', () => {
tt.state.send('CREATED_PAGE')
expect(tt.state.data).toMatchSnapshot()
})
})
```
While snapshot tests don't assert specific things about a feature's implementation, they will at least help flag changes in other parts of the app that might break the feature. For example, if we accidentally made the app start in read only mode, then the snapshot outcome of `CREATED_PAGE` would be different—and the test would fail.
## I Found a Bug!
As you write your tests, chances are you'll find some part of the application that just doesn't work the way it should. If it's your own code, then go ahead and make your fix. If the bug is in code that someone else has written, and if the fix seems complicated, then consider reaching out to the author on [Discord](https://discord.gg/a3H98DGSXS) or on the Github issue for help.
## TestUtils
While you can test every feature in tldraw by sending events to the state, the `TestUtils` class is designed to make certain things easier. By convention, I'll refer to an instance of the `TestUtils` class as `tt`.
```ts
import TestState from '../test-utils'
const tt = new TestState()
```
The `TestUtils` instance wraps an instance of the app's state machine (`tt.state`). It also exposes the state's data as `tt.data`, as well as the state's helper methods (`tt.send`, `tt.isIn`, etc.)
- `tt.resetDocumentState` will clear the document and reset the app state.
- `tt.createShape` will create a new shape on the page.
- `tt.clickShape` will click a the indicated shape
Check the `test-utils.ts` file for the rest of the API. Feel free to add your own methods if you have a reason for doing so.
## Conclusion and Tips
To wrap up, thanks again for writing tests for tldraw. Quality in creative software is extremely important: nothing's worse than losing work to a bug, but even lesser terrors—getting kicked out of creative flow by unexpected behavior, or having to accomodate an accidental quirk—can make an app unusable.
To sum up what we've covered:
- Do a bit of digging into a feature's events and their outcome(s)
- Test the app's state machine (view `TestUtils`), not the React view
- Use the `TestUtils` class for complex events like clicking and dragging
- Write "todo" tests for the things you can't get to
- Ask original authors if you find a complex bug
- Ask for help on [Discord](https://discord.gg/a3H98DGSXS)
Thanks!
-Steve (@steveruizok)

View file

@ -1,326 +0,0 @@
{
"isReadOnly": false,
"settings": {
"fontSize": 13,
"isDarkMode": false,
"isCodeOpen": false,
"isDebugMode": false,
"isDebugOpen": false,
"isStyleOpen": false,
"isToolLocked": false,
"isPenLocked": false,
"nudgeDistanceLarge": 10,
"nudgeDistanceSmall": 1
},
"currentStyle": {
"size": "Medium",
"color": "Black",
"dash": "Draw",
"isFilled": false
},
"activeTool": "select",
"editingId": null,
"boundsRotation": 0,
"currentPageId": "page1",
"currentParentId": "page1",
"currentCodeFileId": "file0",
"codeControls": {},
"document": {
"id": "TESTING",
"name": "My Document",
"pages": {
"page1": {
"id": "page1",
"type": "page",
"name": "Page 1",
"childIndex": 0,
"shapes": {
"e43559cb-6f41-4ae4-9c49-158ed1ad2f72": {
"id": "e43559cb-6f41-4ae4-9c49-158ed1ad2f72",
"type": "rectangle",
"name": "Rectangle",
"parentId": "page1",
"childIndex": 3,
"point": [171.47, 288.63],
"size": [176.22, 192.26],
"radius": 2,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"13448777-d8f5-46cd-8a70-a4259211902e": {
"id": "13448777-d8f5-46cd-8a70-a4259211902e",
"type": "rectangle",
"name": "Rectangle",
"parentId": "page1",
"childIndex": 4,
"point": [511.7, 404.19],
"size": [181.08999999999992, 150.40999999999997],
"radius": 2,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"75010635-8dfb-48ea-9250-719e50e58f02": {
"id": "75010635-8dfb-48ea-9250-719e50e58f02",
"type": "rectangle",
"name": "Rectangle",
"parentId": "page1",
"childIndex": 5,
"point": [384.09, 378.45],
"size": [95.20999999999992, 91.1799999999999],
"radius": 2,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"c892d665-3311-4e25-a0bf-c4632d777f7e": {
"id": "c892d665-3311-4e25-a0bf-c4632d777f7e",
"type": "ellipse",
"name": "Ellipse",
"parentId": "page1",
"childIndex": 6,
"point": [162.45, 679.23],
"radiusX": 102.99999999999997,
"radiusY": 102.99999999999994,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"51641de1-9787-41b8-afcc-2c85fd1b24c7": {
"id": "51641de1-9787-41b8-afcc-2c85fd1b24c7",
"type": "ellipse",
"name": "Ellipse",
"parentId": "page1",
"childIndex": 7,
"point": [517.18, 783.54],
"radiusX": 102.99999999999997,
"radiusY": 102.99999999999994,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"e08c415e-3db3-4d3b-878e-28ce693ec1b0": {
"id": "e08c415e-3db3-4d3b-878e-28ce693ec1b0",
"type": "ellipse",
"name": "Ellipse",
"parentId": "page1",
"childIndex": 8,
"point": [398.99, 810.79],
"radiusX": 45.484999999999985,
"radiusY": 45.48499999999996,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"fee77127-e779-4576-882b-b1bf7c7e132f": {
"id": "fee77127-e779-4576-882b-b1bf7c7e132f",
"type": "arrow",
"name": "Arrow",
"parentId": "page1",
"childIndex": 9,
"point": [252.85, 1057.5],
"rotation": 0,
"bend": 0,
"handles": {
"start": {
"id": "start",
"index": 0,
"point": [0, 0]
},
"end": {
"id": "end",
"index": 1,
"point": [0.09000000000000341, 208]
},
"bend": {
"id": "bend",
"index": 2,
"point": [0.045000000000001705, 104]
}
},
"decorations": {
"start": null,
"middle": null,
"end": "Arrow"
},
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"2d842ace-ebc5-4e83-acdf-de29352e5e62": {
"id": "2d842ace-ebc5-4e83-acdf-de29352e5e62",
"type": "arrow",
"name": "Arrow",
"parentId": "page1",
"childIndex": 10,
"point": [616.9, 1124.3],
"rotation": 0,
"bend": 0,
"handles": {
"start": {
"id": "start",
"index": 0,
"point": [0, 0]
},
"end": {
"id": "end",
"index": 1,
"point": [2.4500000000000455, 185.20000000000005]
},
"bend": {
"id": "bend",
"index": 2,
"point": [1.2250000000000227, 92.60000000000002]
}
},
"decorations": {
"start": null,
"middle": null,
"end": "Arrow"
},
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"b8e4e2c5-c662-4587-bf80-b9820ad8ad7f": {
"id": "b8e4e2c5-c662-4587-bf80-b9820ad8ad7f",
"type": "arrow",
"name": "Arrow",
"parentId": "page1",
"childIndex": 11,
"point": [425.18, 1143.2],
"rotation": 0,
"bend": 0,
"handles": {
"start": {
"id": "start",
"index": 0,
"point": [0, 0]
},
"end": {
"id": "end",
"index": 1,
"point": [1.8500000000000227, 95.70000000000005]
},
"bend": {
"id": "bend",
"index": 2,
"point": [0.9250000000000114, 47.85000000000002]
}
},
"decorations": {
"start": null,
"middle": null,
"end": "Arrow"
},
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"38e9e750-16c2-4476-93ab-21aeb5f8858f": {
"id": "38e9e750-16c2-4476-93ab-21aeb5f8858f",
"type": "text",
"name": "Text",
"parentId": "page1",
"childIndex": 12,
"point": [207.16, 1422.4],
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
},
"text": "Hello",
"scale": 1
},
"5ba998df-c036-447a-9b88-d96c71394f52": {
"id": "5ba998df-c036-447a-9b88-d96c71394f52",
"type": "text",
"name": "Text",
"parentId": "page1",
"childIndex": 13,
"point": [389.57, 1496.5],
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
},
"text": "Hello",
"scale": 1
},
"3c688979-b190-4270-915b-7d8dd22a2bb7": {
"id": "3c688979-b190-4270-915b-7d8dd22a2bb7",
"type": "text",
"name": "Text",
"parentId": "page1",
"childIndex": 14,
"point": [564.06, 1558.1],
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
},
"text": "Hello",
"scale": 1
}
}
}
},
"code": {
"file0": {
"id": "file0",
"name": "index.ts",
"code": "\nconst draw = new Draw({\n points: [\n ...Utils.getPointsBetween([0, 0], [20, 50]),\n ...Utils.getPointsBetween([20, 50], [100, 20], 3),\n ...Utils.getPointsBetween([100, 20], [100, 100], 10),\n [100, 100],\n ],\n})\n\nconst rectangle = new Rectangle({\n point: [200, 0],\n style: {\n color: ColorStyle.Blue,\n },\n})\n\nconst ellipse = new Ellipse({\n point: [400, 0],\n})\n\nconst arrow = new Arrow({\n start: [600, 0],\n end: [700, 100],\n})\n\nconst radius = 1000\nconst count = 100\nconst center = [350, 50]\n\nfor (let i = 0; i < count; i++) {\n const point = Vec.rotWith(\n Vec.add(center, [radius, 0]),\n center,\n (Math.PI * 2 * i) / count\n )\n\n const dot = new Dot({\n point,\n })\n}\n "
}
}
},
"pageStates": {
"page1": {
"id": "page1",
"camera": {
"point": [0, -145],
"zoom": 1
},
"selectedIds": {}
}
}
}

View file

@ -1,298 +0,0 @@
{
"document": {
"id": "TESTING",
"name": "My Document",
"pages": {
"page1": {
"id": "page1",
"type": "page",
"name": "Page 1",
"childIndex": 0,
"shapes": {
"e43559cb-6f41-4ae4-9c49-158ed1ad2f72": {
"id": "e43559cb-6f41-4ae4-9c49-158ed1ad2f72",
"type": "rectangle",
"name": "Rectangle",
"parentId": "page1",
"childIndex": 3,
"point": [100, 100],
"size": [100, 100],
"radius": 2,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"13448777-d8f5-46cd-8a70-a4259211902e": {
"id": "13448777-d8f5-46cd-8a70-a4259211902e",
"type": "rectangle",
"name": "Rectangle",
"parentId": "page1",
"childIndex": 4,
"point": [500, 400],
"size": [200, 200],
"radius": 2,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"75010635-8dfb-48ea-9250-719e50e58f02": {
"id": "75010635-8dfb-48ea-9250-719e50e58f02",
"type": "rectangle",
"name": "Rectangle",
"parentId": "page1",
"childIndex": 5,
"point": [384.09, 378.45],
"size": [95.20999999999992, 91.1799999999999],
"radius": 2,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"c892d665-3311-4e25-a0bf-c4632d777f7e": {
"id": "c892d665-3311-4e25-a0bf-c4632d777f7e",
"type": "ellipse",
"name": "Ellipse",
"parentId": "page1",
"childIndex": 6,
"point": [162.45, 679.23],
"radiusX": 102.99999999999997,
"radiusY": 102.99999999999994,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"51641de1-9787-41b8-afcc-2c85fd1b24c7": {
"id": "51641de1-9787-41b8-afcc-2c85fd1b24c7",
"type": "ellipse",
"name": "Ellipse",
"parentId": "page1",
"childIndex": 7,
"point": [517.18, 783.54],
"radiusX": 102.99999999999997,
"radiusY": 102.99999999999994,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"e08c415e-3db3-4d3b-878e-28ce693ec1b0": {
"id": "e08c415e-3db3-4d3b-878e-28ce693ec1b0",
"type": "ellipse",
"name": "Ellipse",
"parentId": "page1",
"childIndex": 8,
"point": [398.99, 810.79],
"radiusX": 45.484999999999985,
"radiusY": 45.48499999999996,
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"fee77127-e779-4576-882b-b1bf7c7e132f": {
"id": "fee77127-e779-4576-882b-b1bf7c7e132f",
"type": "arrow",
"name": "Arrow",
"parentId": "page1",
"childIndex": 9,
"point": [252.85, 1057.5],
"rotation": 0,
"bend": 0,
"handles": {
"start": {
"id": "start",
"index": 0,
"point": [0, 0]
},
"end": {
"id": "end",
"index": 1,
"point": [0.09000000000000341, 208]
},
"bend": {
"id": "bend",
"index": 2,
"point": [0.045000000000001705, 104]
}
},
"decorations": {
"start": null,
"middle": null,
"end": "Arrow"
},
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"2d842ace-ebc5-4e83-acdf-de29352e5e62": {
"id": "2d842ace-ebc5-4e83-acdf-de29352e5e62",
"type": "arrow",
"name": "Arrow",
"parentId": "page1",
"childIndex": 10,
"point": [616.9, 1124.3],
"rotation": 0,
"bend": 0,
"handles": {
"start": {
"id": "start",
"index": 0,
"point": [0, 0]
},
"end": {
"id": "end",
"index": 1,
"point": [2.4500000000000455, 185.20000000000005]
},
"bend": {
"id": "bend",
"index": 2,
"point": [1.2250000000000227, 92.60000000000002]
}
},
"decorations": {
"start": null,
"middle": null,
"end": "Arrow"
},
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"b8e4e2c5-c662-4587-bf80-b9820ad8ad7f": {
"id": "b8e4e2c5-c662-4587-bf80-b9820ad8ad7f",
"type": "arrow",
"name": "Arrow",
"parentId": "page1",
"childIndex": 11,
"point": [425.18, 1143.2],
"rotation": 0,
"bend": 0,
"handles": {
"start": {
"id": "start",
"index": 0,
"point": [0, 0]
},
"end": {
"id": "end",
"index": 1,
"point": [1.8500000000000227, 95.70000000000005]
},
"bend": {
"id": "bend",
"index": 2,
"point": [0.9250000000000114, 47.85000000000002]
}
},
"decorations": {
"start": null,
"middle": null,
"end": "Arrow"
},
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
}
},
"38e9e750-16c2-4476-93ab-21aeb5f8858f": {
"id": "38e9e750-16c2-4476-93ab-21aeb5f8858f",
"type": "text",
"name": "Text",
"parentId": "page1",
"childIndex": 12,
"point": [207.16, 1422.4],
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
},
"text": "Hello",
"scale": 1
},
"5ba998df-c036-447a-9b88-d96c71394f52": {
"id": "5ba998df-c036-447a-9b88-d96c71394f52",
"type": "text",
"name": "Text",
"parentId": "page1",
"childIndex": 13,
"point": [389.57, 1496.5],
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
},
"text": "Hello",
"scale": 1
},
"3c688979-b190-4270-915b-7d8dd22a2bb7": {
"id": "3c688979-b190-4270-915b-7d8dd22a2bb7",
"type": "text",
"name": "Text",
"parentId": "page1",
"childIndex": 14,
"point": [564.06, 1558.1],
"rotation": 0,
"style": {
"color": "Black",
"size": "Medium",
"isFilled": false,
"dash": "Draw"
},
"text": "Hello",
"scale": 1
}
}
}
},
"code": {
"file0": {
"id": "file0",
"name": "index.ts",
"code": "\nconst draw = new Draw({\n points: [\n ...Utils.getPointsBetween([0, 0], [20, 50]),\n ...Utils.getPointsBetween([20, 50], [100, 20], 3),\n ...Utils.getPointsBetween([100, 20], [100, 100], 10),\n [100, 100],\n ],\n})\n\nconst rectangle = new Rectangle({\n point: [200, 0],\n style: {\n color: ColorStyle.Blue,\n },\n})\n\nconst ellipse = new Ellipse({\n point: [400, 0],\n})\n\nconst arrow = new Arrow({\n start: [600, 0],\n end: [700, 100],\n})\n\nconst radius = 1000\nconst count = 100\nconst center = [350, 50]\n\nfor (let i = 0; i < count; i++) {\n const point = Vec.rotWith(\n Vec.add(center, [radius, 0]),\n center,\n (Math.PI * 2 * i) / count\n )\n\n const dot = new Dot({\n point,\n })\n}\n "
}
}
},
"pageState": {
"id": "page1",
"camera": {
"point": [0, -145],
"zoom": 1
},
"selectedIds": {}
}
}

View file

@ -1,280 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`selection creates a code control: generated code controls from code 1`] = `
Object {
"test-number-control": Object {
"id": "test-number-control",
"label": "x",
"step": 1,
"type": "number",
"value": 0,
},
}
`;
exports[`selection generates a draw shape: generated draw from code 1`] = `
Array [
Object {
"childIndex": 1,
"id": "test-draw",
"isGenerated": true,
"name": "Test draw",
"parentId": "page1",
"point": Array [
0,
0,
],
"points": Array [
Array [
100,
100,
],
Array [
200,
200,
],
Array [
300,
300,
],
],
"rotation": 0,
"style": Object {
"color": "Red",
"dash": "Dotted",
"isFilled": false,
"size": "Medium",
},
"type": "draw",
},
]
`;
exports[`selection generates a rectangle shape: generated rectangle from code 1`] = `
Array [
Object {
"childIndex": 1,
"id": "test-rectangle",
"isGenerated": true,
"name": "Test Rectangle",
"parentId": "page1",
"point": Array [
100,
100,
],
"radius": 2,
"rotation": 0,
"size": Array [
200,
200,
],
"style": Object {
"color": "Red",
"dash": "Dotted",
"isFilled": false,
"size": "Medium",
},
"type": "rectangle",
},
]
`;
exports[`selection generates a text shape: generated draw from code 1`] = `
Array [
Object {
"childIndex": 1,
"id": "test-text",
"isGenerated": true,
"name": "Test text",
"parentId": "page1",
"point": Array [
100,
100,
],
"rotation": 0,
"scale": 1,
"style": Object {
"color": "Red",
"dash": "Dotted",
"isFilled": false,
"size": "Large",
},
"text": "Hello world!",
"type": "text",
},
]
`;
exports[`selection generates an arrow shape: generated draw from code 1`] = `
Array [
Object {
"bend": 0,
"childIndex": 1,
"decorations": Object {
"end": "Arrow",
"middle": null,
"start": null,
},
"handles": Object {
"bend": Object {
"id": "bend",
"index": 2,
"point": Array [
50,
50,
],
},
"end": Object {
"id": "end",
"index": 1,
"point": Array [
100,
100,
],
},
"start": Object {
"id": "start",
"index": 0,
"point": Array [
0,
0,
],
},
},
"id": "test-draw",
"isGenerated": true,
"name": "Test draw",
"parentId": "page1",
"point": Array [
0,
0,
],
"points": Array [
Array [
100,
100,
],
Array [
200,
200,
],
Array [
300,
300,
],
],
"rotation": 0,
"style": Object {
"color": "Red",
"dash": "Dotted",
"isFilled": false,
"size": "Medium",
},
"type": "arrow",
},
]
`;
exports[`selection generates an ellipse shape: generated ellipse from code 1`] = `
Array [
Object {
"childIndex": 1,
"id": "test-ellipse",
"isGenerated": true,
"name": "Test ellipse",
"parentId": "page1",
"point": Array [
100,
100,
],
"radiusX": 100,
"radiusY": 200,
"rotation": 0,
"style": Object {
"color": "Red",
"dash": "Dotted",
"isFilled": false,
"size": "Medium",
},
"type": "ellipse",
},
]
`;
exports[`selection generates shapes: generated rectangle from code 1`] = `
Array [
Object {
"childIndex": 1,
"id": "test-rectangle",
"isGenerated": true,
"name": "Test Rectangle",
"parentId": "page1",
"point": Array [
100,
100,
],
"radius": 2,
"rotation": 0,
"size": Array [
200,
200,
],
"style": Object {
"color": "Red",
"dash": "Dotted",
"isFilled": false,
"size": "Medium",
},
"type": "rectangle",
},
]
`;
exports[`selection updates a code control: data in state after changing control 1`] = `
Object {
"test-number-control": Object {
"id": "test-number-control",
"label": "x",
"step": 1,
"type": "number",
"value": 100,
},
"test-vector-control": Object {
"id": "test-vector-control",
"isNormalized": false,
"label": "size",
"type": "vector",
"value": Array [
0,
0,
],
},
}
`;
exports[`selection updates a code control: rectangle in state after changing code control 1`] = `
Object {
"childIndex": 1,
"id": "test-rectangle",
"isGenerated": true,
"name": "Test Rectangle",
"parentId": "page1",
"point": Array [
0,
100,
],
"radius": 2,
"rotation": 0,
"size": Array [
0,
0,
],
"style": Object {
"color": "Red",
"dash": "Dotted",
"isFilled": false,
"size": "Medium",
},
"type": "rectangle",
}
`;

View file

@ -1,57 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ellipse dash props renders dashed props on a circle correctly: large dashed circle dash props 1`] = `
Object {
"strokeDasharray": "16 17.333333333333332",
"strokeDashoffset": "8",
}
`;
exports[`ellipse dash props renders dashed props on a circle correctly: large dashed ellipse dash props 1`] = `
Object {
"strokeDasharray": "16 17.333333333333332",
"strokeDashoffset": "8",
}
`;
exports[`ellipse dash props renders dashed props on a circle correctly: small dashed circle dash props 1`] = `
Object {
"strokeDasharray": "8 8.666666666666666",
"strokeDashoffset": "4",
}
`;
exports[`ellipse dash props renders dashed props on a circle correctly: small dashed ellipse dash props 1`] = `
Object {
"strokeDasharray": "8 8.666666666666666",
"strokeDashoffset": "4",
}
`;
exports[`ellipse dash props renders dotted props on a circle correctly: large dotted circle dash props 1`] = `
Object {
"strokeDasharray": "0.08 16.586666666666666",
"strokeDashoffset": "0",
}
`;
exports[`ellipse dash props renders dotted props on a circle correctly: large dotted ellipse dash props 1`] = `
Object {
"strokeDasharray": "0.08 16.586666666666666",
"strokeDashoffset": "0",
}
`;
exports[`ellipse dash props renders dotted props on a circle correctly: small dotted circle dash props 1`] = `
Object {
"strokeDasharray": "0.04 8.293333333333333",
"strokeDashoffset": "0",
}
`;
exports[`ellipse dash props renders dotted props on a circle correctly: small dotted ellipse dash props 1`] = `
Object {
"strokeDasharray": "0.04 8.293333333333333",
"strokeDashoffset": "0",
}
`;

View file

@ -1,809 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`project loads file from json: data after mount from file 1`] = `
Object {
"code": Object {
"file0": Object {
"code": "
const draw = new Draw({
points: [
...Utils.getPointsBetween([0, 0], [20, 50]),
...Utils.getPointsBetween([20, 50], [100, 20], 3),
...Utils.getPointsBetween([100, 20], [100, 100], 10),
[100, 100],
],
})
const rectangle = new Rectangle({
point: [200, 0],
style: {
color: ColorStyle.Blue,
},
})
const ellipse = new Ellipse({
point: [400, 0],
})
const arrow = new Arrow({
start: [600, 0],
end: [700, 100],
})
const radius = 1000
const count = 100
const center = [350, 50]
for (let i = 0; i < count; i++) {
const point = Vec.rotWith(
Vec.add(center, [radius, 0]),
center,
(Math.PI * 2 * i) / count
)
const dot = new Dot({
point,
})
}
",
"id": "file0",
"name": "index.ts",
},
},
"id": "TESTING",
"name": "My Document",
"pages": Object {
"page1": Object {
"childIndex": 0,
"id": "page1",
"name": "Page 1",
"shapes": Object {
"13448777-d8f5-46cd-8a70-a4259211902e": Object {
"childIndex": 4,
"id": "13448777-d8f5-46cd-8a70-a4259211902e",
"name": "Rectangle",
"parentId": "page1",
"point": Array [
500,
400,
],
"radius": 2,
"rotation": 0,
"size": Array [
200,
200,
],
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "rectangle",
},
"2d842ace-ebc5-4e83-acdf-de29352e5e62": Object {
"bend": 0,
"childIndex": 10,
"decorations": Object {
"end": "Arrow",
"middle": null,
"start": null,
},
"handles": Object {
"bend": Object {
"id": "bend",
"index": 2,
"point": Array [
1.2250000000000227,
92.60000000000002,
],
},
"end": Object {
"id": "end",
"index": 1,
"point": Array [
2.4500000000000455,
185.20000000000005,
],
},
"start": Object {
"id": "start",
"index": 0,
"point": Array [
0,
0,
],
},
},
"id": "2d842ace-ebc5-4e83-acdf-de29352e5e62",
"name": "Arrow",
"parentId": "page1",
"point": Array [
616.9,
1124.3,
],
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "arrow",
},
"38e9e750-16c2-4476-93ab-21aeb5f8858f": Object {
"childIndex": 12,
"id": "38e9e750-16c2-4476-93ab-21aeb5f8858f",
"name": "Text",
"parentId": "page1",
"point": Array [
207.16,
1422.4,
],
"rotation": 0,
"scale": 1,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"text": "Hello",
"type": "text",
},
"3c688979-b190-4270-915b-7d8dd22a2bb7": Object {
"childIndex": 14,
"id": "3c688979-b190-4270-915b-7d8dd22a2bb7",
"name": "Text",
"parentId": "page1",
"point": Array [
564.06,
1558.1,
],
"rotation": 0,
"scale": 1,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"text": "Hello",
"type": "text",
},
"51641de1-9787-41b8-afcc-2c85fd1b24c7": Object {
"childIndex": 7,
"id": "51641de1-9787-41b8-afcc-2c85fd1b24c7",
"name": "Ellipse",
"parentId": "page1",
"point": Array [
517.18,
783.54,
],
"radiusX": 102.99999999999997,
"radiusY": 102.99999999999994,
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "ellipse",
},
"5ba998df-c036-447a-9b88-d96c71394f52": Object {
"childIndex": 13,
"id": "5ba998df-c036-447a-9b88-d96c71394f52",
"name": "Text",
"parentId": "page1",
"point": Array [
389.57,
1496.5,
],
"rotation": 0,
"scale": 1,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"text": "Hello",
"type": "text",
},
"75010635-8dfb-48ea-9250-719e50e58f02": Object {
"childIndex": 5,
"id": "75010635-8dfb-48ea-9250-719e50e58f02",
"name": "Rectangle",
"parentId": "page1",
"point": Array [
384.09,
378.45,
],
"radius": 2,
"rotation": 0,
"size": Array [
95.20999999999992,
91.1799999999999,
],
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "rectangle",
},
"b8e4e2c5-c662-4587-bf80-b9820ad8ad7f": Object {
"bend": 0,
"childIndex": 11,
"decorations": Object {
"end": "Arrow",
"middle": null,
"start": null,
},
"handles": Object {
"bend": Object {
"id": "bend",
"index": 2,
"point": Array [
0.9250000000000114,
47.85000000000002,
],
},
"end": Object {
"id": "end",
"index": 1,
"point": Array [
1.8500000000000227,
95.70000000000005,
],
},
"start": Object {
"id": "start",
"index": 0,
"point": Array [
0,
0,
],
},
},
"id": "b8e4e2c5-c662-4587-bf80-b9820ad8ad7f",
"name": "Arrow",
"parentId": "page1",
"point": Array [
425.18,
1143.2,
],
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "arrow",
},
"c892d665-3311-4e25-a0bf-c4632d777f7e": Object {
"childIndex": 6,
"id": "c892d665-3311-4e25-a0bf-c4632d777f7e",
"name": "Ellipse",
"parentId": "page1",
"point": Array [
162.45,
679.23,
],
"radiusX": 102.99999999999997,
"radiusY": 102.99999999999994,
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "ellipse",
},
"e08c415e-3db3-4d3b-878e-28ce693ec1b0": Object {
"childIndex": 8,
"id": "e08c415e-3db3-4d3b-878e-28ce693ec1b0",
"name": "Ellipse",
"parentId": "page1",
"point": Array [
398.99,
810.79,
],
"radiusX": 45.484999999999985,
"radiusY": 45.48499999999996,
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "ellipse",
},
"e43559cb-6f41-4ae4-9c49-158ed1ad2f72": Object {
"childIndex": 3,
"id": "e43559cb-6f41-4ae4-9c49-158ed1ad2f72",
"name": "Rectangle",
"parentId": "page1",
"point": Array [
100,
100,
],
"radius": 2,
"rotation": 0,
"size": Array [
100,
100,
],
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "rectangle",
},
"fee77127-e779-4576-882b-b1bf7c7e132f": Object {
"bend": 0,
"childIndex": 9,
"decorations": Object {
"end": "Arrow",
"middle": null,
"start": null,
},
"handles": Object {
"bend": Object {
"id": "bend",
"index": 2,
"point": Array [
0.045000000000001705,
104,
],
},
"end": Object {
"id": "end",
"index": 1,
"point": Array [
0.09000000000000341,
208,
],
},
"start": Object {
"id": "start",
"index": 0,
"point": Array [
0,
0,
],
},
},
"id": "fee77127-e779-4576-882b-b1bf7c7e132f",
"name": "Arrow",
"parentId": "page1",
"point": Array [
252.85,
1057.5,
],
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "arrow",
},
},
"type": "page",
},
},
}
`;
exports[`restoring project remounts the state after mutating the current state: data after re-mount from file 1`] = `
Object {
"code": Object {
"file0": Object {
"code": "
const draw = new Draw({
points: [
...Utils.getPointsBetween([0, 0], [20, 50]),
...Utils.getPointsBetween([20, 50], [100, 20], 3),
...Utils.getPointsBetween([100, 20], [100, 100], 10),
[100, 100],
],
})
const rectangle = new Rectangle({
point: [200, 0],
style: {
color: ColorStyle.Blue,
},
})
const ellipse = new Ellipse({
point: [400, 0],
})
const arrow = new Arrow({
start: [600, 0],
end: [700, 100],
})
const radius = 1000
const count = 100
const center = [350, 50]
for (let i = 0; i < count; i++) {
const point = Vec.rotWith(
Vec.add(center, [radius, 0]),
center,
(Math.PI * 2 * i) / count
)
const dot = new Dot({
point,
})
}
",
"id": "file0",
"name": "index.ts",
},
},
"id": "TESTING",
"name": "My Document",
"pages": Object {
"page1": Object {
"childIndex": 0,
"id": "page1",
"name": "Page 1",
"shapes": Object {
"13448777-d8f5-46cd-8a70-a4259211902e": Object {
"childIndex": 4,
"id": "13448777-d8f5-46cd-8a70-a4259211902e",
"name": "Rectangle",
"parentId": "page1",
"point": Array [
500,
400,
],
"radius": 2,
"rotation": 0,
"size": Array [
200,
200,
],
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "rectangle",
},
"2d842ace-ebc5-4e83-acdf-de29352e5e62": Object {
"bend": 0,
"childIndex": 10,
"decorations": Object {
"end": "Arrow",
"middle": null,
"start": null,
},
"handles": Object {
"bend": Object {
"id": "bend",
"index": 2,
"point": Array [
1.2250000000000227,
92.60000000000002,
],
},
"end": Object {
"id": "end",
"index": 1,
"point": Array [
2.4500000000000455,
185.20000000000005,
],
},
"start": Object {
"id": "start",
"index": 0,
"point": Array [
0,
0,
],
},
},
"id": "2d842ace-ebc5-4e83-acdf-de29352e5e62",
"name": "Arrow",
"parentId": "page1",
"point": Array [
616.9,
1124.3,
],
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "arrow",
},
"38e9e750-16c2-4476-93ab-21aeb5f8858f": Object {
"childIndex": 12,
"id": "38e9e750-16c2-4476-93ab-21aeb5f8858f",
"name": "Text",
"parentId": "page1",
"point": Array [
207.16,
1422.4,
],
"rotation": 0,
"scale": 1,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"text": "Hello",
"type": "text",
},
"3c688979-b190-4270-915b-7d8dd22a2bb7": Object {
"childIndex": 14,
"id": "3c688979-b190-4270-915b-7d8dd22a2bb7",
"name": "Text",
"parentId": "page1",
"point": Array [
564.06,
1558.1,
],
"rotation": 0,
"scale": 1,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"text": "Hello",
"type": "text",
},
"51641de1-9787-41b8-afcc-2c85fd1b24c7": Object {
"childIndex": 7,
"id": "51641de1-9787-41b8-afcc-2c85fd1b24c7",
"name": "Ellipse",
"parentId": "page1",
"point": Array [
517.18,
783.54,
],
"radiusX": 102.99999999999997,
"radiusY": 102.99999999999994,
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "ellipse",
},
"5ba998df-c036-447a-9b88-d96c71394f52": Object {
"childIndex": 13,
"id": "5ba998df-c036-447a-9b88-d96c71394f52",
"name": "Text",
"parentId": "page1",
"point": Array [
389.57,
1496.5,
],
"rotation": 0,
"scale": 1,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"text": "Hello",
"type": "text",
},
"75010635-8dfb-48ea-9250-719e50e58f02": Object {
"childIndex": 5,
"id": "75010635-8dfb-48ea-9250-719e50e58f02",
"name": "Rectangle",
"parentId": "page1",
"point": Array [
384.09,
378.45,
],
"radius": 2,
"rotation": 0,
"size": Array [
95.20999999999992,
91.1799999999999,
],
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "rectangle",
},
"b8e4e2c5-c662-4587-bf80-b9820ad8ad7f": Object {
"bend": 0,
"childIndex": 11,
"decorations": Object {
"end": "Arrow",
"middle": null,
"start": null,
},
"handles": Object {
"bend": Object {
"id": "bend",
"index": 2,
"point": Array [
0.9250000000000114,
47.85000000000002,
],
},
"end": Object {
"id": "end",
"index": 1,
"point": Array [
1.8500000000000227,
95.70000000000005,
],
},
"start": Object {
"id": "start",
"index": 0,
"point": Array [
0,
0,
],
},
},
"id": "b8e4e2c5-c662-4587-bf80-b9820ad8ad7f",
"name": "Arrow",
"parentId": "page1",
"point": Array [
425.18,
1143.2,
],
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "arrow",
},
"c892d665-3311-4e25-a0bf-c4632d777f7e": Object {
"childIndex": 6,
"id": "c892d665-3311-4e25-a0bf-c4632d777f7e",
"name": "Ellipse",
"parentId": "page1",
"point": Array [
162.45,
679.23,
],
"radiusX": 102.99999999999997,
"radiusY": 102.99999999999994,
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "ellipse",
},
"e08c415e-3db3-4d3b-878e-28ce693ec1b0": Object {
"childIndex": 8,
"id": "e08c415e-3db3-4d3b-878e-28ce693ec1b0",
"name": "Ellipse",
"parentId": "page1",
"point": Array [
398.99,
810.79,
],
"radiusX": 45.484999999999985,
"radiusY": 45.48499999999996,
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "ellipse",
},
"e43559cb-6f41-4ae4-9c49-158ed1ad2f72": Object {
"childIndex": 3,
"id": "e43559cb-6f41-4ae4-9c49-158ed1ad2f72",
"name": "Rectangle",
"parentId": "page1",
"point": Array [
100,
100,
],
"radius": 2,
"rotation": 0,
"size": Array [
100,
100,
],
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "rectangle",
},
"fee77127-e779-4576-882b-b1bf7c7e132f": Object {
"bend": 0,
"childIndex": 9,
"decorations": Object {
"end": "Arrow",
"middle": null,
"start": null,
},
"handles": Object {
"bend": Object {
"id": "bend",
"index": 2,
"point": Array [
0.045000000000001705,
104,
],
},
"end": Object {
"id": "end",
"index": 1,
"point": Array [
0.09000000000000341,
208,
],
},
"start": Object {
"id": "start",
"index": 0,
"point": Array [
0,
0,
],
},
},
"id": "fee77127-e779-4576-882b-b1bf7c7e132f",
"name": "Arrow",
"parentId": "page1",
"point": Array [
252.85,
1057.5,
],
"rotation": 0,
"style": Object {
"color": "Black",
"dash": "Draw",
"isFilled": false,
"size": "Medium",
},
"type": "arrow",
},
},
"type": "page",
},
},
}
`;

View file

@ -1,72 +0,0 @@
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)
)
)
})
})

View file

@ -1,232 +0,0 @@
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(tt.getSortedPageShapeIds()).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(tt.getSortedPageShapeIds()).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(tt.getSortedPageShapeIds()).toStrictEqual(['4', '1', '2', '3'])
})
it('moves a shape backward', () => {
tt.clickShape('3').send('MOVED', {
type: MoveType.Backward,
})
expect(tt.getSortedPageShapeIds()).toStrictEqual(['4', '1', '3', '2'])
})
it('moves a shape at first index backward', () => {
tt.clickShape('4').send('MOVED', {
type: MoveType.Backward,
})
expect(tt.getSortedPageShapeIds()).toStrictEqual(['4', '1', '3', '2'])
})
it('moves two adjacent siblings backward', () => {
tt.clickShape('3').clickShape('2', { shiftKey: true }).send('MOVED', {
type: MoveType.Backward,
})
expect(tt.getSortedPageShapeIds()).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(tt.getSortedPageShapeIds()).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(tt.getSortedPageShapeIds()).toStrictEqual(['3', '4', '1', '2'])
})
it('moves a shape forward', () => {
tt.clickShape('4').send('MOVED', {
type: MoveType.Forward,
})
expect(tt.getSortedPageShapeIds()).toStrictEqual(['3', '1', '4', '2'])
})
it('moves a shape forward at the top index', () => {
tt.clickShape('2').send('MOVED', {
type: MoveType.Forward,
})
expect(tt.getSortedPageShapeIds()).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(tt.getSortedPageShapeIds()).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(tt.getSortedPageShapeIds()).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(tt.getSortedPageShapeIds()).toStrictEqual(['2', '4', '3', '1'])
})
it('moves a shape to front', () => {
tt.deselectAll().clickShape('2').send('MOVED', {
type: MoveType.ToFront,
})
expect(tt.getSortedPageShapeIds()).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(tt.getSortedPageShapeIds()).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(tt.getSortedPageShapeIds()).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(tt.getSortedPageShapeIds()).toStrictEqual(['2', '1', '4', '3'])
})
})

View file

@ -1,284 +0,0 @@
import { generateFromCode } from 'state/code/generate'
import * as json from './__mocks__/document.json'
import TestState from './test-utils'
jest.useRealTimers()
const tt = new TestState()
tt.resetDocumentState()
.send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
.send('CLEARED_PAGE')
.save()
describe('selection', () => {
it('opens and closes the code panel', () => {
expect(tt.data.settings.isCodeOpen).toBe(false)
tt.send('TOGGLED_CODE_PANEL_OPEN')
expect(tt.data.settings.isCodeOpen).toBe(true)
tt.send('TOGGLED_CODE_PANEL_OPEN')
expect(tt.data.settings.isCodeOpen).toBe(false)
})
it('saves changes to code', () => {
expect(tt.getSortedPageShapeIds().length).toBe(0)
const code = `// hello world!`
tt.send('SAVED_CODE', { code })
expect(tt.data.document.code[tt.data.currentCodeFileId].code).toBe(code)
})
it('generates shapes', async () => {
const code = `
const rectangle = new Rectangle({
id: "test-rectangle",
name: 'Test Rectangle',
point: [100, 100],
size: [200, 200],
style: {
size: SizeStyle.Medium,
color: ColorStyle.Red,
dash: DashStyle.Dotted,
},
})
`
const { controls, shapes } = await generateFromCode(tt.data, code)
tt.send('GENERATED_FROM_CODE', { controls, shapes })
expect(tt.getShapes()).toMatchSnapshot('generated rectangle from code')
})
it('creates a code control', async () => {
const code = `
new NumberControl({
id: "test-number-control",
label: "x"
})
`
const { controls, shapes } = await generateFromCode(tt.data, code)
tt.send('GENERATED_FROM_CODE', { controls, shapes })
expect(tt.data.codeControls).toMatchSnapshot(
'generated code controls from code'
)
})
it('updates a code control', async () => {
const code = `
new NumberControl({
id: "test-number-control",
label: "x"
})
new VectorControl({
id: "test-vector-control",
label: "size"
})
const rectangle = new Rectangle({
id: "test-rectangle",
name: 'Test Rectangle',
point: [controls.x, 100],
size: controls.size,
style: {
size: SizeStyle.Medium,
color: ColorStyle.Red,
dash: DashStyle.Dotted,
},
})
`
const { controls, shapes } = await generateFromCode(tt.data, code)
tt.send('GENERATED_FROM_CODE', { controls, shapes })
tt.send('CHANGED_CODE_CONTROL', { 'test-number-control': 100 })
expect(tt.data.codeControls).toMatchSnapshot(
'data in state after changing control'
)
expect(tt.getShape('test-rectangle')).toMatchSnapshot(
'rectangle in state after changing code control'
)
})
/* -------------------- Readonly -------------------- */
it('does not saves changes to code when readonly', () => {
tt.send('CLEARED_PAGE')
expect(tt.getShapes().length).toBe(0)
const code = `// hello world!`
tt.send('SAVED_CODE', { code })
.send('TOGGLED_READ_ONLY')
.send('SAVED_CODE', { code: '' })
expect(tt.data.document.code[tt.data.currentCodeFileId].code).toBe(code)
tt.send('TOGGLED_READ_ONLY').send('SAVED_CODE', { code: '' })
expect(tt.data.document.code[tt.data.currentCodeFileId].code).toBe('')
})
/* --------------------- Methods -------------------- */
it('moves shape to front', async () => {
null
})
it('moves shape forward', async () => {
null
})
it('moves shape backward', async () => {
null
})
it('moves shape to back', async () => {
null
})
it('rotates a shape', async () => {
null
})
it('rotates a shape by a delta', async () => {
null
})
it('translates a shape', async () => {
null
})
it('translates a shape by a delta', async () => {
null
})
/* --------------------- Shapes --------------------- */
it('generates a rectangle shape', async () => {
tt.send('CLEARED_PAGE')
const code = `
const rectangle = new Rectangle({
id: "test-rectangle",
name: 'Test Rectangle',
point: [100, 100],
size: [200, 200],
style: {
size: SizeStyle.Medium,
color: ColorStyle.Red,
dash: DashStyle.Dotted,
},
})
`
const { controls, shapes } = await generateFromCode(tt.data, code)
tt.send('GENERATED_FROM_CODE', { controls, shapes })
expect(tt.getShapes()).toMatchSnapshot('generated rectangle from code')
})
it('changes a rectangle size', async () => {
null
})
it('generates an ellipse shape', async () => {
tt.send('CLEARED_PAGE')
const code = `
const ellipse = new Ellipse({
id: 'test-ellipse',
name: 'Test ellipse',
point: [100, 100],
radiusX: 100,
radiusY: 200,
style: {
size: SizeStyle.Medium,
color: ColorStyle.Red,
dash: DashStyle.Dotted,
},
})
`
const { controls, shapes } = await generateFromCode(tt.data, code)
tt.send('GENERATED_FROM_CODE', { controls, shapes })
expect(tt.getShapes()).toMatchSnapshot('generated ellipse from code')
})
it('generates a draw shape', async () => {
tt.send('CLEARED_PAGE')
const code = `
const ellipse = new Draw({
id: 'test-draw',
name: 'Test draw',
points: [[100, 100], [200,200], [300,300]],
style: {
size: SizeStyle.Medium,
color: ColorStyle.Red,
dash: DashStyle.Dotted,
},
})
`
const { controls, shapes } = await generateFromCode(tt.data, code)
tt.send('GENERATED_FROM_CODE', { controls, shapes })
expect(tt.getShapes()).toMatchSnapshot('generated draw from code')
})
it('generates an arrow shape', async () => {
tt.send('CLEARED_PAGE')
const code = `
const draw = new Arrow({
id: 'test-draw',
name: 'Test draw',
points: [[100, 100], [200,200], [300,300]],
style: {
size: SizeStyle.Medium,
color: ColorStyle.Red,
dash: DashStyle.Dotted,
},
})
`
const { controls, shapes } = await generateFromCode(tt.data, code)
tt.send('GENERATED_FROM_CODE', { controls, shapes })
expect(tt.getShapes()).toMatchSnapshot('generated draw from code')
})
it('generates a text shape', async () => {
tt.send('CLEARED_PAGE')
const code = `
const text = new Text({
id: 'test-text',
name: 'Test text',
point: [100, 100],
text: 'Hello world!',
style: {
size: SizeStyle.Large,
color: ColorStyle.Red,
dash: DashStyle.Dotted,
},
})
`
const { controls, shapes } = await generateFromCode(tt.data, code)
tt.send('GENERATED_FROM_CODE', { controls, shapes })
expect(tt.getShapes()).toMatchSnapshot('generated draw from code')
})
})

View file

@ -1,852 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`transform command snapshot tests shift-transforms corners 1`] = `
Object {
"height": 593.73,
"maxX": 700,
"maxY": 600,
"minX": -12.476,
"minY": 6.27,
"width": 712.476,
}
`;
exports[`transform command snapshot tests shift-transforms corners 2`] = `
Object {
"rect1": Object {
"point": Array [
-12.476,
6.27,
],
"size": Array [
118.75,
118.75,
],
},
"rect2": Object {
"point": Array [
462.51,
362.51,
],
"size": Array [
237.49,
237.49,
],
},
}
`;
exports[`transform command snapshot tests shift-transforms corners 3`] = `
Object {
"height": 531.6320000000001,
"maxX": 737.9599999999999,
"maxY": 600,
"minX": 100,
"minY": 68.368,
"width": 637.9599999999999,
}
`;
exports[`transform command snapshot tests shift-transforms corners 4`] = `
Object {
"rect1": Object {
"point": Array [
100,
68.368,
],
"size": Array [
106.33,
106.33,
],
},
"rect2": Object {
"point": Array [
525.31,
387.35,
],
"size": Array [
212.65,
212.65,
],
},
}
`;
exports[`transform command snapshot tests shift-transforms corners 5`] = `
Object {
"height": 533.62,
"maxX": 740.3499999999999,
"maxY": 633.62,
"minX": 100,
"minY": 100,
"width": 640.3499999999999,
}
`;
exports[`transform command snapshot tests shift-transforms corners 6`] = `
Object {
"rect1": Object {
"point": Array [
100,
100,
],
"size": Array [
106.72,
106.72,
],
},
"rect2": Object {
"point": Array [
526.9,
420.17,
],
"size": Array [
213.45,
213.45,
],
},
}
`;
exports[`transform command snapshot tests shift-transforms corners 7`] = `
Object {
"height": 490.52,
"maxX": 700,
"maxY": 590.52,
"minX": 111.38,
"minY": 100,
"width": 588.62,
}
`;
exports[`transform command snapshot tests shift-transforms corners 8`] = `
Object {
"rect1": Object {
"point": Array [
111.38,
100,
],
"size": Array [
98.104,
98.104,
],
},
"rect2": Object {
"point": Array [
503.79,
394.31,
],
"size": Array [
196.21,
196.21,
],
},
}
`;
exports[`transform command snapshot tests shift-transforms edges 1`] = `
Object {
"height": 593.73,
"maxX": 756.24,
"maxY": 600,
"minX": 43.762,
"minY": 6.27,
"width": 712.4780000000001,
}
`;
exports[`transform command snapshot tests shift-transforms edges 2`] = `
Object {
"rect1": Object {
"point": Array [
43.762,
6.27,
],
"size": Array [
118.75,
118.75,
],
},
"rect2": Object {
"point": Array [
518.75,
362.51,
],
"size": Array [
237.49,
237.49,
],
},
}
`;
exports[`transform command snapshot tests shift-transforms edges 3`] = `
Object {
"height": 531.6260000000001,
"maxX": 737.9599999999999,
"maxY": 615.8100000000001,
"minX": 100,
"minY": 84.184,
"width": 637.9599999999999,
}
`;
exports[`transform command snapshot tests shift-transforms edges 4`] = `
Object {
"rect1": Object {
"point": Array [
100,
84.184,
],
"size": Array [
106.33,
106.33,
],
},
"rect2": Object {
"point": Array [
525.31,
403.16,
],
"size": Array [
212.65,
212.65,
],
},
}
`;
exports[`transform command snapshot tests shift-transforms edges 5`] = `
Object {
"height": 411.35,
"maxX": 646.81,
"maxY": 511.35,
"minX": 153.19,
"minY": 100,
"width": 493.61999999999995,
}
`;
exports[`transform command snapshot tests shift-transforms edges 6`] = `
Object {
"rect1": Object {
"point": Array [
153.19,
100,
],
"size": Array [
82.269,
82.269,
],
},
"rect2": Object {
"point": Array [
482.27,
346.81,
],
"size": Array [
164.54,
164.54,
],
},
}
`;
exports[`transform command snapshot tests shift-transforms edges 7`] = `
Object {
"height": 490.52,
"maxX": 700,
"maxY": 595.26,
"minX": 111.38,
"minY": 104.74,
"width": 588.62,
}
`;
exports[`transform command snapshot tests shift-transforms edges 8`] = `
Object {
"rect1": Object {
"point": Array [
111.38,
104.74,
],
"size": Array [
98.104,
98.104,
],
},
"rect2": Object {
"point": Array [
503.79,
399.05,
],
"size": Array [
196.21,
196.21,
],
},
}
`;
exports[`transform command snapshot tests transforms corners 1`] = `
Object {
"height": 593.73,
"maxX": 700,
"maxY": 600,
"minX": 27.892,
"minY": 6.27,
"width": 672.108,
}
`;
exports[`transform command snapshot tests transforms corners 2`] = `
Object {
"rect1": Object {
"point": Array [
27.892,
6.27,
],
"size": Array [
112.02,
118.75,
],
},
"rect2": Object {
"point": Array [
475.96,
362.51,
],
"size": Array [
224.04,
237.49,
],
},
}
`;
exports[`transform command snapshot tests transforms corners 3`] = `
Object {
"height": 490.17,
"maxX": 737.9599999999999,
"maxY": 600,
"minX": 100,
"minY": 109.83,
"width": 637.9599999999999,
}
`;
exports[`transform command snapshot tests transforms corners 4`] = `
Object {
"rect1": Object {
"point": Array [
100,
109.83,
],
"size": Array [
106.33,
98.034,
],
},
"rect2": Object {
"point": Array [
525.31,
403.93,
],
"size": Array [
212.65,
196.07,
],
},
}
`;
exports[`transform command snapshot tests transforms corners 5`] = `
Object {
"height": 411.35,
"maxX": 740.3499999999999,
"maxY": 511.35,
"minX": 100,
"minY": 100,
"width": 640.3499999999999,
}
`;
exports[`transform command snapshot tests transforms corners 6`] = `
Object {
"rect1": Object {
"point": Array [
100,
100,
],
"size": Array [
106.72,
82.269,
],
},
"rect2": Object {
"point": Array [
526.9,
346.81,
],
"size": Array [
213.45,
164.54,
],
},
}
`;
exports[`transform command snapshot tests transforms corners 7`] = `
Object {
"height": 437.4,
"maxX": 700,
"maxY": 537.4,
"minX": 111.38,
"minY": 100,
"width": 588.62,
}
`;
exports[`transform command snapshot tests transforms corners 8`] = `
Object {
"rect1": Object {
"point": Array [
111.38,
100,
],
"size": Array [
98.104,
87.479,
],
},
"rect2": Object {
"point": Array [
503.79,
362.44,
],
"size": Array [
196.21,
174.96,
],
},
}
`;
exports[`transform command snapshot tests transforms edges 1`] = `
Object {
"height": 593.73,
"maxX": 700,
"maxY": 600,
"minX": 100,
"minY": 6.27,
"width": 600,
}
`;
exports[`transform command snapshot tests transforms edges 2`] = `
Object {
"rect1": Object {
"point": Array [
100,
6.27,
],
"size": Array [
100,
118.75,
],
},
"rect2": Object {
"point": Array [
500,
362.51,
],
"size": Array [
200,
237.49,
],
},
}
`;
exports[`transform command snapshot tests transforms edges 3`] = `
Object {
"height": 500,
"maxX": 737.9599999999999,
"maxY": 600,
"minX": 100,
"minY": 100,
"width": 637.9599999999999,
}
`;
exports[`transform command snapshot tests transforms edges 4`] = `
Object {
"rect1": Object {
"point": Array [
100,
100,
],
"size": Array [
106.33,
100,
],
},
"rect2": Object {
"point": Array [
525.31,
400,
],
"size": Array [
212.65,
200,
],
},
}
`;
exports[`transform command snapshot tests transforms edges 5`] = `
Object {
"height": 411.35,
"maxX": 700,
"maxY": 511.35,
"minX": 100,
"minY": 100,
"width": 600,
}
`;
exports[`transform command snapshot tests transforms edges 6`] = `
Object {
"rect1": Object {
"point": Array [
100,
100,
],
"size": Array [
100,
82.269,
],
},
"rect2": Object {
"point": Array [
500,
346.81,
],
"size": Array [
200,
164.54,
],
},
}
`;
exports[`transform command snapshot tests transforms edges 7`] = `
Object {
"height": 500,
"maxX": 700,
"maxY": 600,
"minX": 111.38,
"minY": 100,
"width": 588.62,
}
`;
exports[`transform command snapshot tests transforms edges 8`] = `
Object {
"rect1": Object {
"point": Array [
111.38,
100,
],
"size": Array [
98.104,
100,
],
},
"rect2": Object {
"point": Array [
503.79,
400,
],
"size": Array [
196.21,
200,
],
},
}
`;
exports[`transform command transforms from the bottom edge 1`] = `
Object {
"rect1": Object {
"point": Array [
100,
100,
],
"size": Array [
100,
120,
],
},
"rect2": Object {
"point": Array [
500,
460,
],
"size": Array [
200,
240,
],
},
}
`;
exports[`transform command transforms from the bottom-left corner 1`] = `
Object {
"rect1": Object {
"point": Array [
200,
100,
],
"size": Array [
83.333,
120,
],
},
"rect2": Object {
"point": Array [
533.33,
460,
],
"size": Array [
166.67,
240,
],
},
}
`;
exports[`transform command transforms from the bottom-right corner 1`] = `
Object {
"rect1": Object {
"point": Array [
100,
100,
],
"size": Array [
116.67,
120,
],
},
"rect2": Object {
"point": Array [
566.67,
460,
],
"size": Array [
233.33,
240,
],
},
}
`;
exports[`transform command transforms from the left edge 1`] = `
Object {
"rect1": Object {
"point": Array [
200,
100,
],
"size": Array [
83.333,
100,
],
},
"rect2": Object {
"point": Array [
533.33,
400,
],
"size": Array [
166.67,
200,
],
},
}
`;
exports[`transform command transforms from the right edge 1`] = `
Object {
"rect1": Object {
"point": Array [
100,
100,
],
"size": Array [
116.67,
100,
],
},
"rect2": Object {
"point": Array [
566.67,
400,
],
"size": Array [
233.33,
200,
],
},
}
`;
exports[`transform command transforms from the top edge 1`] = `
Object {
"rect1": Object {
"point": Array [
100,
200,
],
"size": Array [
100,
80,
],
},
"rect2": Object {
"point": Array [
500,
440,
],
"size": Array [
200,
160,
],
},
}
`;
exports[`transform command transforms from the top-left corner 1`] = `
Object {
"rect1": Object {
"point": Array [
200,
200,
],
"size": Array [
83.333,
80,
],
},
"rect2": Object {
"point": Array [
533.33,
440,
],
"size": Array [
166.67,
160,
],
},
}
`;
exports[`transform command transforms from the top-right corner 1`] = `
Object {
"rect1": Object {
"point": Array [
100,
200,
],
"size": Array [
116.67,
80,
],
},
"rect2": Object {
"point": Array [
566.67,
440,
],
"size": Array [
233.33,
160,
],
},
}
`;
exports[`transform command when transforming from the bottom-right corner does command 1`] = `
Object {
"rect1": Object {
"point": Array [
100,
100,
],
"size": Array [
116.67,
120,
],
},
"rect2": Object {
"point": Array [
566.67,
460,
],
"size": Array [
233.33,
240,
],
},
}
`;
exports[`transform command when transforming from the bottom-right corner re-does command 1`] = `
Object {
"rect1": Object {
"point": Array [
100,
100,
],
"size": Array [
116.67,
120,
],
},
"rect2": Object {
"point": Array [
566.67,
460,
],
"size": Array [
233.33,
240,
],
},
}
`;
exports[`transform command when transforming from the bottom-right corner un-does command 1`] = `
Object {
"rect1": Object {
"point": Array [
100,
100,
],
"size": Array [
100,
100,
],
},
"rect2": Object {
"point": Array [
500,
400,
],
"size": Array [
200,
200,
],
},
}
`;

View file

@ -1,40 +0,0 @@
import TestState from '../test-utils'
describe('align command', () => {
const tt = new TestState()
tt.resetDocumentState()
describe('when one item is selected', () => {
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})
describe('when multiple items are selected', () => {
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})
})

View file

@ -1,21 +0,0 @@
import TestState from '../test-utils'
describe('change page command', () => {
const tt = new TestState()
tt.resetDocumentState()
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})

View file

@ -1,38 +0,0 @@
import TestState from '../test-utils'
describe('create page command', () => {
const tt = new TestState()
tt.resetDocumentState().save()
describe('creates a page', () => {
it('does command', () => {
expect(Object.keys(tt.data.document.pages).length).toBe(1)
tt.send('CREATED_PAGE')
expect(Object.keys(tt.data.document.pages).length).toBe(2)
})
it('changes to the new page', () => {
tt.restore().send('CREATED_PAGE')
const pageId = Object.keys(tt.data.document.pages)[1]
expect(tt.data.currentPageId).toBe(pageId)
})
it('un-does command', () => {
tt.restore().send('CREATED_PAGE').undo()
expect(Object.keys(tt.data.document.pages).length).toBe(1)
const pageId = Object.keys(tt.data.document.pages)[0]
expect(tt.data.currentPageId).toBe(pageId)
})
it('re-does command', () => {
tt.restore().send('CREATED_PAGE').undo().redo()
expect(Object.keys(tt.data.document.pages).length).toBe(2)
const pageId = Object.keys(tt.data.document.pages)[1]
expect(tt.data.currentPageId).toBe(pageId)
})
})
})

View file

@ -1,78 +0,0 @@
import TestState from '../test-utils'
describe('delete page command', () => {
const tt = new TestState()
tt.resetDocumentState().save()
it('does command', () => {
tt.reset().restore().send('CREATED_PAGE')
expect(Object.keys(tt.data.document.pages).length).toBe(2)
const pageId = Object.keys(tt.data.document.pages)[1]
tt.send('DELETED_PAGE', { id: pageId })
expect(Object.keys(tt.data.document.pages).length).toBe(1)
const firstPageId = Object.keys(tt.data.document.pages)[0]
expect(tt.data.currentPageId).toBe(firstPageId)
})
it('un-does command', () => {
tt.reset().restore().send('CREATED_PAGE')
expect(Object.keys(tt.data.document.pages).length).toBe(2)
const pageId = Object.keys(tt.data.document.pages)[1]
tt.send('DELETED_PAGE', { id: pageId }).undo()
expect(Object.keys(tt.data.document.pages).length).toBe(2)
expect(tt.data.currentPageId).toBe(pageId)
})
it('re-does command', () => {
tt.reset().restore().send('CREATED_PAGE')
expect(Object.keys(tt.data.document.pages).length).toBe(2)
const pageId = Object.keys(tt.data.document.pages)[1]
tt.send('DELETED_PAGE', { id: pageId }).undo().redo()
expect(Object.keys(tt.data.document.pages).length).toBe(1)
const firstPageId = Object.keys(tt.data.document.pages)[0]
expect(tt.data.currentPageId).toBe(firstPageId)
})
describe('when first page is selected', () => {
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})
describe('when project only has one page', () => {
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})
})

View file

@ -1,40 +0,0 @@
import TestState from '../test-utils'
describe('delete-selected command', () => {
const tt = new TestState()
tt.resetDocumentState()
describe('when one item is selected', () => {
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})
describe('when multiple items are selected', () => {
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})
})

View file

@ -1,120 +0,0 @@
import { ShapeType } from 'types'
import TestState, { rectangleId, arrowId } from '../test-utils'
describe('delete command', () => {
const tt = new TestState()
describe('deleting single shapes', () => {
it('deletes a shape and undoes the delete', () => {
tt.deselectAll().clickShape(rectangleId).pressDelete()
expect(tt.idsAreSelected([])).toBe(true)
expect(tt.getShape(rectangleId)).toBe(undefined)
tt.undo()
expect(tt.getShape(rectangleId)).toBeTruthy()
expect(tt.idsAreSelected([rectangleId])).toBe(true)
tt.redo()
expect(tt.getShape(rectangleId)).toBe(undefined)
})
})
describe('deleting and restoring grouped shapes', () => {
it('creates a group', () => {
tt.reset()
.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
.send('GROUPED')
const group = tt.getOnlySelectedShape()
// Should select the group
expect(tt.assertShapeProps(group, { type: ShapeType.Group })).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)
})
})

View file

@ -1,37 +0,0 @@
import TestState from '../test-utils'
describe('distribute command', () => {
const tt = new TestState()
tt.resetDocumentState()
describe('when one item is selected', () => {
it('does not change anything', () => {
// TODO
null
})
})
describe('when two items are selected', () => {
it('does not change anything', () => {
// TODO
null
})
})
describe('when three or more items are selected', () => {
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})
})

View file

@ -1,21 +0,0 @@
import TestState from '../test-utils'
describe('draw command', () => {
const tt = new TestState()
tt.resetDocumentState()
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})

View file

@ -1,109 +0,0 @@
import { ShapeType } from 'types'
import TestState from '../test-utils'
describe('duplicate page command', () => {
const tt = new TestState()
tt.resetDocumentState()
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 1,
},
'rect1'
)
.save()
describe('duplicates a page', () => {
it('does, undoes, and redoes command', () => {
tt.reset().restore()
expect(Object.keys(tt.data.document.pages).length).toBe(1)
const pageId = Object.keys(tt.data.document.pages)[0]
expect(tt.getShape('rect1').parentId).toBe(pageId)
tt.send('DUPLICATED_PAGE', { id: pageId })
expect(Object.keys(tt.data.document.pages).length).toBe(2)
const newPageId = Object.keys(tt.data.document.pages)[1]
expect(tt.data.currentPageId).toBe(newPageId)
expect(tt.getShape('rect1').parentId).toBe(newPageId)
tt.undo()
expect(Object.keys(tt.data.document.pages).length).toBe(1)
expect(tt.data.currentPageId).toBe(Object.keys(tt.data.document.pages)[0])
expect(tt.getShape('rect1').parentId).toBe(pageId)
tt.redo()
expect(Object.keys(tt.data.document.pages).length).toBe(2)
expect(tt.data.currentPageId).toBe(Object.keys(tt.data.document.pages)[1])
expect(tt.getShape('rect1').parentId).toBe(newPageId)
})
})
describe('duplicates a page other than the current page', () => {
tt.restore()
.reset()
.send('CREATED_PAGE')
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 1,
},
'rect2'
)
.send('CHANGED_PAGE', { id: 'page1' })
const firstPageId = Object.keys(tt.data.document.pages)[0]
// We should be back on the first page
expect(tt.data.currentPageId).toBe(firstPageId)
// But we should have two pages
expect(Object.keys(tt.data.document.pages).length).toBe(2)
const secondPageId = Object.keys(tt.data.document.pages)[1]
// Now we duplicate the second page
tt.send('DUPLICATED_PAGE', { id: secondPageId })
// We should now have three pages
expect(Object.keys(tt.data.document.pages).length).toBe(3)
// The third page should also have a shape named rect2
const thirdPageId = Object.keys(tt.data.document.pages)[2]
// We should have changed pages to the third page
expect(tt.data.currentPageId).toBe(thirdPageId)
// And it should be the parent of the third page
expect(tt.getShape('rect2').parentId).toBe(thirdPageId)
tt.undo()
// We should still be on the first page, but we should
// have only two pages; the third page should be deleted
expect(Object.keys(tt.data.document.pages).length).toBe(2)
expect(tt.data.document.pages[thirdPageId]).toBe(undefined)
expect(tt.data.currentPageId).toBe(firstPageId)
tt.redo()
// We should be back on the third page
expect(Object.keys(tt.data.document.pages).length).toBe(3)
expect(tt.data.document.pages[thirdPageId]).toBeTruthy()
expect(tt.data.currentPageId).toBe(Object.keys(tt.data.document.pages)[2])
expect(tt.getShape('rect2').parentId).toBe(thirdPageId)
})
})

View file

@ -1,116 +0,0 @@
import { ShapeType } from 'types'
import TestState from '../test-utils'
describe('duplicate command', () => {
const tt = new TestState()
tt.resetDocumentState()
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
},
'rectangleShape'
)
.createShape(
{
type: ShapeType.Ellipse,
point: [150, 150],
radiusX: 50,
radiusY: 50,
},
'ellipseShape'
)
.save()
describe('when one item is selected', () => {
it('does, undoes and redoes command', () => {
tt.restore()
const shapesBeforeDuplication = tt.getSortedPageShapeIds()
tt.clickShape('rectangleShape').send('DUPLICATED')
const shapesAfterDuplication = tt.getSortedPageShapeIds()
const duplicatedShapeId = tt.selectedIds[0]
const duplicatedShape = tt.getShape(duplicatedShapeId)
expect(shapesAfterDuplication.length).toEqual(
shapesBeforeDuplication.length + 1
)
expect(
tt.assertShapeProps(duplicatedShape, {
type: ShapeType.Rectangle,
size: [100, 100],
})
)
tt.undo()
const shapesAfterUndo = tt.getSortedPageShapeIds()
expect(shapesAfterUndo.length).toEqual(shapesBeforeDuplication.length)
expect(tt.getShape(duplicatedShapeId)).toBe(undefined)
expect(tt.idsAreSelected(['rectangleShape'])).toBe(true)
tt.redo()
expect(tt.getShape(duplicatedShapeId)).toBeTruthy()
expect(tt.idsAreSelected([duplicatedShapeId])).toBe(true)
})
})
describe('when multiple items are selected', () => {
it('does, undoes and redoes command', () => {
tt.restore()
const shapesBeforeDuplication = tt.getSortedPageShapeIds()
tt.clickShape('rectangleShape')
.clickShape('ellipseShape', { shiftKey: true })
.send('DUPLICATED')
const shapesAfterDuplication = tt.getSortedPageShapeIds()
const duplicatedShapesIds = tt.selectedIds
const [duplicatedRectangle, duplicatedEllipse] = duplicatedShapesIds.map(
(shapeId) => tt.getShape(shapeId)
)
expect(shapesAfterDuplication.length).toEqual(
shapesBeforeDuplication.length * 2
)
expect(
tt.assertShapeProps(duplicatedRectangle, {
type: ShapeType.Rectangle,
size: [100, 100],
})
)
expect(
tt.assertShapeProps(duplicatedEllipse, {
type: ShapeType.Ellipse,
radiusX: 50,
radiusY: 50,
})
)
tt.undo()
const shapesAfterUndo = tt.getSortedPageShapeIds()
expect(shapesAfterUndo.length).toEqual(shapesBeforeDuplication.length)
duplicatedShapesIds.forEach((shapeId) => {
expect(tt.getShape(shapeId)).toBe(undefined)
})
expect(tt.idsAreSelected(['rectangleShape', 'ellipseShape'])).toBe(true)
tt.redo()
duplicatedShapesIds.forEach((shapeId) => {
expect(tt.getShape(shapeId)).toBeTruthy()
})
expect(tt.idsAreSelected(duplicatedShapesIds)).toBe(true)
})
})
})

View file

@ -1,21 +0,0 @@
import TestState from '../test-utils'
describe('edit command', () => {
const tt = new TestState()
tt.resetDocumentState()
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})

View file

@ -1,21 +0,0 @@
import TestState from '../test-utils'
describe('generate command', () => {
const tt = new TestState()
tt.resetDocumentState()
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})

View file

@ -1,151 +0,0 @@
import { ShapeType } from 'types'
import TestState from '../test-utils'
describe('group command', () => {
const tt = new TestState()
tt.resetDocumentState()
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 1,
isLocked: false,
isHidden: false,
isAspectRatioLocked: false,
},
'rect1'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [400, 0],
size: [100, 100],
childIndex: 2,
isHidden: false,
isLocked: false,
isAspectRatioLocked: false,
},
'rect2'
)
.save()
// it('deletes the group if it has only one child', () => {
// tt.restore()
// .clickShape('rect1')
// .clickShape('rect2', { shiftKey: true })
// .send('GROUPED')
// const groupId = tt.getShape('rect1').parentId
// expect(groupId === tt.data.currentPageId).toBe(false)
// tt.doubleClickShape('rect1')
// tt.send('DELETED')
// expect(tt.getShape(groupId)).toBe(undefined)
// expect(tt.getShape('rect2')).toBeTruthy()
// })
it('deletes the group if all children are deleted', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
const groupId = tt.getShape('rect1').parentId
expect(groupId === tt.data.currentPageId).toBe(false)
tt.doubleClickShape('rect1').clickShape('rect2', { shiftKey: true })
tt.send('DELETED')
expect(tt.getShape(groupId)).toBe(undefined)
})
it('creates a group', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
const groupId = tt.getShape('rect1').parentId
expect(groupId === tt.data.currentPageId).toBe(false)
})
it('selects the group on single click', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
.clickShape('rect1')
const groupId = tt.getShape('rect1').parentId
expect(tt.selectedIds).toEqual([groupId])
})
it('selects the item on double click', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
.doubleClickShape('rect1')
const groupId = tt.getShape('rect1').parentId
expect(tt.data.currentParentId).toBe(groupId)
expect(tt.selectedIds).toEqual(['rect1'])
})
it('resets currentPageId when clicking the canvas', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
.doubleClickShape('rect1')
.clickCanvas()
.clickShape('rect1')
const groupId = tt.getShape('rect1').parentId
expect(tt.data.currentParentId).toBe(tt.data.currentPageId)
expect(tt.selectedIds).toEqual([groupId])
})
it('creates a group and undoes and redoes', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.send('GROUPED')
const groupId = tt.getShape('rect1').parentId
expect(groupId === tt.data.currentPageId).toBe(false)
tt.undo()
expect(tt.getShape('rect1').parentId === tt.data.currentPageId).toBe(true)
expect(tt.getShape(groupId)).toBe(undefined)
tt.redo()
expect(tt.getShape('rect1').parentId === tt.data.currentPageId).toBe(false)
expect(tt.getShape(groupId)).toBeTruthy()
})
it('groups shapes with different parents', () => {
// TODO
null
})
it('does not group a parent group shape and its child', () => {
// TODO
null
})
})

View file

@ -1,40 +0,0 @@
import TestState from '../test-utils'
describe('move-to-page command', () => {
const tt = new TestState()
tt.resetDocumentState()
describe('when one item is selected', () => {
it('does not change anything', () => {
// TODO
null
})
})
describe('when multiple items are selected', () => {
it('does command', () => {
// TODO
null
})
it('un-does command', () => {
// TODO
null
})
it('re-does command', () => {
// TODO
null
})
})
it('reparents children of groups to page', () => {
// TODO
null
})
it('correctly preserves moved groups', () => {
// TODO
null
})
})

View file

@ -1,55 +0,0 @@
import TestState from '../test-utils'
describe('rename page command', () => {
const tt = new TestState()
tt.resetDocumentState().save()
describe('renames a page', () => {
it('does, undoes, and redoes command', () => {
tt.restore().reset().send('CREATED_PAGE')
const pageId = Object.keys(tt.data.document.pages)[1]
expect(tt.data.document.pages[pageId].name).toBe('Page 2')
tt.send('RENAMED_PAGE', { id: pageId, name: 'My First Page' })
expect(tt.data.document.pages[pageId].name).toBe('My First Page')
tt.undo()
expect(tt.data.document.pages[pageId].name).toBe('Page 2')
tt.redo()
expect(tt.data.document.pages[pageId].name).toBe('My First Page')
})
})
describe('renames a page other than the current page', () => {
tt.restore()
.reset()
.send('CREATED_PAGE')
.send('CHANGED_PAGE', { id: 'page1' })
expect(Object.keys(tt.data.document.pages).length).toBe(2)
expect(tt.data.currentPageId).toBe('page1')
const secondPageId = Object.keys(tt.data.document.pages)[1]
expect(tt.data.document.pages[secondPageId].name).toBe('Page 2')
tt.send('RENAMED_PAGE', { id: secondPageId, name: 'My Second Page' })
expect(tt.data.document.pages[secondPageId].name).toBe('My Second Page')
tt.undo()
expect(tt.data.document.pages[secondPageId].name).toBe('Page 2')
tt.redo()
expect(tt.data.document.pages[secondPageId].name).toBe('My Second Page')
})
})

View file

@ -1,237 +0,0 @@
import { ShapeType } from 'types'
import TestState from '../test-utils'
describe('toggle command', () => {
const tt = new TestState()
tt.resetDocumentState()
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 1,
isLocked: false,
isHidden: false,
isAspectRatioLocked: false,
},
'rect1'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [400, 0],
size: [100, 100],
childIndex: 2,
isHidden: false,
isLocked: false,
isAspectRatioLocked: false,
},
'rect2'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [800, 0],
size: [100, 100],
childIndex: 3,
isHidden: true,
isLocked: true,
isAspectRatioLocked: true,
},
'rect3'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [1000, 0],
size: [100, 100],
childIndex: 4,
isHidden: true,
isLocked: true,
isAspectRatioLocked: true,
},
'rect4'
)
.save()
describe('toggles properties on single shape', () => {
it('does command', () => {
tt.restore().clickShape('rect1')
tt.send('TOGGLED_SHAPE_LOCK')
expect(tt.getShape('rect1').isLocked).toBe(true)
tt.send('TOGGLED_SHAPE_LOCK')
expect(tt.getShape('rect1').isLocked).toBe(false)
})
it('undoes and redoes command', () => {
// Restore the saved data state, with the initial selection
tt.restore().clickShape('rect1')
tt.send('TOGGLED_SHAPE_LOCK')
tt.send('UNDO')
expect(tt.getShape('rect1').isLocked).toBe(false)
tt.send('REDO')
expect(tt.getShape('rect1').isLocked).toBe(true)
})
})
describe('toggles properties on shapes with matching properties, starting from false', () => {
it('does command', () => {
// Restore the saved data state, with the initial selection
tt.restore().clickShape('rect1').clickShape('rect2', { shiftKey: true })
tt.send('TOGGLED_SHAPE_LOCK')
expect(tt.getShape('rect1').isLocked).toBe(true)
expect(tt.getShape('rect2').isLocked).toBe(true)
tt.send('TOGGLED_SHAPE_LOCK')
expect(tt.getShape('rect1').isLocked).toBe(false)
expect(tt.getShape('rect2').isLocked).toBe(false)
tt.send('TOGGLED_SHAPE_LOCK')
expect(tt.getShape('rect1').isLocked).toBe(true)
expect(tt.getShape('rect2').isLocked).toBe(true)
})
it('undoes and redoes command', () => {
// Restore the saved data state, with the initial selection
tt.restore().clickShape('rect1').clickShape('rect2', { shiftKey: true })
tt.send('TOGGLED_SHAPE_LOCK').undo()
expect(tt.getShape('rect1').isLocked).toBe(false)
expect(tt.getShape('rect2').isLocked).toBe(false)
tt.redo()
expect(tt.getShape('rect1').isLocked).toBe(true)
expect(tt.getShape('rect2').isLocked).toBe(true)
})
})
describe('toggles properties on shapes with matching properties, starting from true', () => {
it('does command', () => {
// Restore the saved data state, with the initial selection
tt.restore().clickShape('rect3').clickShape('rect4', { shiftKey: true })
tt.send('TOGGLED_SHAPE_LOCK')
expect(tt.getShape('rect3').isLocked).toBe(false)
expect(tt.getShape('rect4').isLocked).toBe(false)
tt.send('TOGGLED_SHAPE_LOCK')
expect(tt.getShape('rect3').isLocked).toBe(true)
expect(tt.getShape('rect4').isLocked).toBe(true)
})
it('undoes and redoes command', () => {
// Restore the saved data state, with the initial selection
tt.restore().clickShape('rect3').clickShape('rect4', { shiftKey: true })
tt.send('TOGGLED_SHAPE_LOCK').undo()
expect(tt.getShape('rect3').isLocked).toBe(true)
expect(tt.getShape('rect4').isLocked).toBe(true)
tt.redo()
expect(tt.getShape('rect3').isLocked).toBe(false)
expect(tt.getShape('rect4').isLocked).toBe(false)
})
})
describe('toggles properties on shapes with different properties', () => {
it('does command', () => {
// Restore the saved data state, with the initial selection
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.clickShape('rect3', { shiftKey: true })
tt.send('TOGGLED_SHAPE_LOCK')
expect(tt.getShape('rect1').isLocked).toBe(true)
expect(tt.getShape('rect2').isLocked).toBe(true)
expect(tt.getShape('rect3').isLocked).toBe(true)
tt.send('TOGGLED_SHAPE_LOCK')
expect(tt.getShape('rect1').isLocked).toBe(false)
expect(tt.getShape('rect2').isLocked).toBe(false)
expect(tt.getShape('rect3').isLocked).toBe(false)
})
it('undoes and redoes command', () => {
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.clickShape('rect3', { shiftKey: true })
tt.send('TOGGLED_SHAPE_LOCK').undo()
expect(tt.getShape('rect1').isLocked).toBe(false)
expect(tt.getShape('rect2').isLocked).toBe(false)
expect(tt.getShape('rect3').isLocked).toBe(true)
tt.redo()
expect(tt.getShape('rect1').isLocked).toBe(true)
expect(tt.getShape('rect2').isLocked).toBe(true)
expect(tt.getShape('rect3').isLocked).toBe(true)
})
})
describe('catch all for other toggle props', () => {
const eventPropertyPairs = {
TOGGLED_SHAPE_LOCK: 'isLocked',
TOGGLED_SHAPE_HIDE: 'isHidden',
TOGGLED_SHAPE_ASPECT_LOCK: 'isAspectRatioLocked',
}
it('toggles all event property pairs', () => {
Object.entries(eventPropertyPairs).forEach(([eventName, propName]) => {
// Restore the saved data state, with the initial selection
tt.restore()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.clickShape('rect3', { shiftKey: true })
tt.send(eventName)
expect(tt.getShape('rect1')[propName]).toBe(true)
expect(tt.getShape('rect2')[propName]).toBe(true)
expect(tt.getShape('rect3')[propName]).toBe(true)
tt.undo()
expect(tt.getShape('rect1')[propName]).toBe(false)
expect(tt.getShape('rect2')[propName]).toBe(false)
expect(tt.getShape('rect3')[propName]).toBe(true)
tt.redo()
expect(tt.getShape('rect1')[propName]).toBe(true)
expect(tt.getShape('rect2')[propName]).toBe(true)
expect(tt.getShape('rect3')[propName]).toBe(true)
tt.send(eventName)
expect(tt.getShape('rect1')[propName]).toBe(false)
expect(tt.getShape('rect2')[propName]).toBe(false)
expect(tt.getShape('rect3')[propName]).toBe(false)
})
})
})
})

View file

@ -1,347 +0,0 @@
import { Corner, Edge, RectangleShape, ShapeType } from 'types'
import { rng } from 'utils'
import TestState from '../test-utils'
describe('transform command', () => {
const tt = new TestState()
tt.resetDocumentState()
.createShape(
{
type: ShapeType.Rectangle,
point: [100, 100],
size: [100, 100],
childIndex: 1,
},
'rect1'
)
.createShape(
{
type: ShapeType.Rectangle,
point: [500, 400],
size: [200, 200],
childIndex: 2,
},
'rect2'
)
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.save()
function getSnapInfo() {
return {
rect1: {
point: tt.getShape<RectangleShape>('rect1').point,
size: tt.getShape<RectangleShape>('rect1').size,
},
rect2: {
point: tt.getShape<RectangleShape>('rect2').point,
size: tt.getShape<RectangleShape>('rect2').size,
},
}
}
it('sets up initial bounds', () => {
expect(tt.selectedIds).toEqual(['rect1', 'rect2'])
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 100,
minY: 100,
maxX: 700,
maxY: 600,
width: 600,
height: 500,
})
})
describe('when transforming from the bottom-right corner', () => {
it('does command', () => {
// Restore the saved data state, with the initial selection
tt.restore()
// Move the bounds handle
tt.startClickingBoundsHandle(Corner.BottomRight)
.movePointerBy([100, 100])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 100,
minY: 100,
maxX: 800,
maxY: 700,
width: 700,
height: 600,
})
expect(getSnapInfo()).toMatchSnapshot()
})
it('un-does command', () => {
// Repeat the same actions, but add an undo at the end
tt.restore()
.startClickingBoundsHandle(Corner.BottomRight)
.movePointerBy([100, 100])
.stopClickingBounds()
.undo()
// Expect the bounds to be the initial bounds
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 100,
minY: 100,
maxX: 700,
maxY: 600,
width: 600,
height: 500,
})
expect(getSnapInfo()).toMatchSnapshot()
})
it('re-does command', () => {
// Repeat the same actions but add an undo and a redo at the end
tt.restore()
.startClickingBoundsHandle(Corner.BottomRight)
.movePointerBy([100, 100])
.stopClickingBounds()
.undo()
.redo()
// Expect the bounds to be the transformed bounds
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 100,
minY: 100,
maxX: 800,
maxY: 700,
width: 700,
height: 600,
})
expect(getSnapInfo()).toMatchSnapshot()
})
})
// From here on, let's assume that the undo and redos work as expected,
// so let's only test the command's execution.
it('transforms from the top edge', () => {
tt.restore()
.startClickingBoundsHandle(Edge.Top)
.movePointerBy([100, 100])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 100,
minY: 200,
maxX: 700,
maxY: 600,
width: 600,
height: 400,
})
expect(getSnapInfo()).toMatchSnapshot()
})
it('transforms from the right edge', () => {
tt.restore()
.startClickingBoundsHandle(Edge.Right)
.movePointerBy([100, 100])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 100,
minY: 100,
maxX: 800,
maxY: 600,
width: 700,
height: 500,
})
expect(getSnapInfo()).toMatchSnapshot()
})
it('transforms from the bottom edge', () => {
tt.restore()
.startClickingBoundsHandle(Edge.Bottom)
.movePointerBy([100, 100])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 100,
minY: 100,
maxX: 700,
maxY: 700,
width: 600,
height: 600,
})
expect(getSnapInfo()).toMatchSnapshot()
})
it('transforms from the left edge', () => {
tt.restore()
.startClickingBoundsHandle(Edge.Left)
.movePointerBy([100, 100])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 200,
minY: 100,
maxX: 700,
maxY: 600,
width: 500,
height: 500,
})
expect(getSnapInfo()).toMatchSnapshot()
})
it('transforms from the top-left corner', () => {
tt.restore()
.startClickingBoundsHandle(Corner.TopLeft)
.movePointerBy([100, 100])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 200,
minY: 200,
maxX: 700,
maxY: 600,
width: 500,
height: 400,
})
expect(getSnapInfo()).toMatchSnapshot()
})
it('transforms from the top-right corner', () => {
tt.restore()
.startClickingBoundsHandle(Corner.TopRight)
.movePointerBy([100, 100])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 100,
minY: 200,
maxX: 800,
maxY: 600,
width: 700,
height: 400,
})
expect(getSnapInfo()).toMatchSnapshot()
})
it('transforms from the bottom-right corner', () => {
tt.restore()
.startClickingBoundsHandle(Corner.BottomRight)
.movePointerBy([100, 100])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 100,
minY: 100,
maxX: 800,
maxY: 700,
width: 700,
height: 600,
})
expect(getSnapInfo()).toMatchSnapshot()
})
it('transforms from the bottom-left corner', () => {
tt.restore()
.startClickingBoundsHandle(Corner.BottomLeft)
.movePointerBy([100, 100])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchObject({
minX: 200,
minY: 100,
maxX: 700,
maxY: 700,
width: 500,
height: 600,
})
expect(getSnapInfo()).toMatchSnapshot()
})
describe('snapshot tests', () => {
it('transforms corners', () => {
const getRandom = rng('transform-tests-random-number-generator')
for (const corner of Object.values(Corner)) {
tt.restore()
.startClickingBoundsHandle(corner)
.movePointerBy([getRandom() * 200, getRandom() * 200])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchSnapshot()
expect(getSnapInfo()).toMatchSnapshot()
}
})
it('transforms edges', () => {
const getRandom = rng('transform-tests-random-number-generator')
for (const edge of Object.values(Edge)) {
tt.restore()
.startClickingBoundsHandle(edge)
.movePointerBy([getRandom() * 200, getRandom() * 200])
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchSnapshot()
expect(getSnapInfo()).toMatchSnapshot()
}
})
it('shift-transforms corners', () => {
const getRandom = rng('transform-tests-random-number-generator')
for (const corner of Object.values(Corner)) {
tt.restore()
.startClickingBoundsHandle(corner)
.movePointerBy([getRandom() * 200, getRandom() * 200], {
shiftKey: true,
})
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchSnapshot()
expect(getSnapInfo()).toMatchSnapshot()
}
})
it('shift-transforms edges', () => {
const getRandom = rng('transform-tests-random-number-generator')
for (const edge of Object.values(Edge)) {
tt.restore()
.startClickingBoundsHandle(edge)
.movePointerBy([getRandom() * 200, getRandom() * 200], {
shiftKey: true,
})
.stopClickingBounds()
// Ensure the bounds have been transformed
expect(tt.state.values.selectedBounds).toMatchSnapshot()
expect(getSnapInfo()).toMatchSnapshot()
}
})
})
})

View file

@ -1,36 +0,0 @@
import TestState from '../test-utils'
describe('translate command', () => {
const tt = new TestState()
tt.resetDocumentState()
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
})
})

View file

@ -1,49 +0,0 @@
import state from 'state'
import coopState from 'state/coop/coop-state'
import * as json from './__mocks__/document.json'
state.reset()
state
.send('MOUNTED')
.send('MOUNTED_SHAPES')
.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
})
})

View file

@ -1,31 +0,0 @@
import state from 'state'
import * as json from './__mocks__/document.json'
state.reset()
state
.send('MOUNTED')
.send('MOUNTED_SHAPES')
.send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
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
})
})

View file

@ -1,34 +0,0 @@
import { DashStyle } from 'types'
import { getPerfectDashProps } from 'utils'
describe('ellipse dash props', () => {
it('renders dashed props on a circle correctly', () => {
expect(getPerfectDashProps(100, 4, DashStyle.Dashed)).toMatchSnapshot(
'small dashed circle dash props'
)
expect(getPerfectDashProps(100, 4, DashStyle.Dashed)).toMatchSnapshot(
'small dashed ellipse dash props'
)
expect(getPerfectDashProps(200, 8, DashStyle.Dashed)).toMatchSnapshot(
'large dashed circle dash props'
)
expect(getPerfectDashProps(200, 8, DashStyle.Dashed)).toMatchSnapshot(
'large dashed ellipse dash props'
)
})
it('renders dotted props on a circle correctly', () => {
expect(getPerfectDashProps(100, 4, DashStyle.Dotted)).toMatchSnapshot(
'small dotted circle dash props'
)
expect(getPerfectDashProps(100, 4, DashStyle.Dotted)).toMatchSnapshot(
'small dotted ellipse dash props'
)
expect(getPerfectDashProps(200, 8, DashStyle.Dotted)).toMatchSnapshot(
'large dotted circle dash props'
)
expect(getPerfectDashProps(200, 8, DashStyle.Dotted)).toMatchSnapshot(
'large dotted ellipse dash props'
)
})
})

View file

@ -1,61 +0,0 @@
import TestState from './test-utils'
describe('lock command', () => {
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
})
})

View file

@ -1,44 +0,0 @@
import state from 'state'
import * as json from './__mocks__/document.json'
describe('project', () => {
state.reset()
state.enableLog(true)
it('mounts the state', () => {
state.send('MOUNTED').send('MOUNTED_SHAPES')
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('MOUNTED_SHAPES')
.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('MOUNTED_SHAPES')
.send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
expect(state.data.document).toMatchSnapshot('data after re-mount from file')
})
})

View file

@ -1,81 +0,0 @@
import TestState, { rectangleId, arrowId } from './test-utils'
describe('selection', () => {
const tt = new TestState()
it('selects a shape', () => {
tt.deselectAll().clickShape(rectangleId)
expect(tt.idsAreSelected([rectangleId])).toBe(true)
})
it('selects and deselects a shape', () => {
tt.deselectAll().clickShape(rectangleId).clickCanvas()
expect(tt.idsAreSelected([])).toBe(true)
})
it('selects multiple shapes', () => {
tt.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
expect(tt.idsAreSelected([rectangleId, arrowId])).toBe(true)
})
it('shift-selects to deselect shapes', () => {
tt.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
.clickShape(rectangleId, { shiftKey: true })
expect(tt.idsAreSelected([arrowId])).toBe(true)
})
it('single-selects shape in selection on click', () => {
tt.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
.clickShape(arrowId)
expect(tt.idsAreSelected([arrowId])).toBe(true)
})
it('single-selects shape in selection on pointerup only', () => {
tt.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
expect(tt.idsAreSelected([rectangleId, arrowId])).toBe(true)
tt.startClick(arrowId)
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', () => {
tt.deselectAll()
.clickShape(rectangleId)
.clickShape(arrowId, { shiftKey: true })
.startClick(rectangleId, { shiftKey: true })
.stopClick(rectangleId)
expect(tt.idsAreSelected([rectangleId])).toBe(true)
})
it('does not select on meta-click', () => {
tt.deselectAll().clickShape(rectangleId, { ctrlKey: 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)
})
})

View file

@ -1,122 +0,0 @@
import { ArrowShape, ShapeType } from 'types'
import TestState from '../test-utils'
describe('arrow shape', () => {
const tt = new TestState()
tt.resetDocumentState().save()
describe('creating arrows', () => {
it('creates shape', () => {
tt.reset().restore().send('SELECTED_ARROW_TOOL')
expect(tt.state.isIn('arrow.creating')).toBe(true)
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
const id = tt.getSortedPageShapeIds()[0]
const shape = tt.getShape<ArrowShape>(id)
tt.assertShapeType(id, ShapeType.Arrow)
expect(shape.handles.start.point).toEqual([0, 0])
expect(shape.handles.bend.point).toEqual([50.5, 50.5])
expect(shape.handles.end.point).toEqual([101, 101])
})
it('creates shapes when pointing a shape', () => {
tt.reset().restore().send('SELECTED_ARROW_TOOL').send('TOGGLED_TOOL_LOCK')
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
tt.startClick('canvas').movePointerBy([-200, 100]).stopClick('canvas')
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
})
it('creates shapes when shape locked', () => {
tt.reset()
.restore()
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 1,
},
'rect1'
)
.send('SELECTED_ARROW_TOOL')
tt.startClick('rect1').movePointerBy([100, 100]).stopClick('canvas')
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
})
it('cancels shape while creating', () => {
// TODO
null
})
})
it('moves shape', () => {
// TODO
null
})
it('rotates shape', () => {
// TODO
null
})
it('rotates shape in a group', () => {
// TODO
null
})
it('measures shape bounds', () => {
// TODO
null
})
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', () => {
// TODO
null
})
})

View file

@ -1,101 +0,0 @@
import { ShapeType } from 'types'
import TestState from '../test-utils'
describe('draw shape', () => {
const tt = new TestState()
tt.resetDocumentState().save()
describe('creating draws', () => {
it('creates shape', () => {
tt.reset().restore().send('SELECTED_DRAW_TOOL')
expect(tt.state.isIn('draw.creating')).toBe(true)
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
const id = tt.getSortedPageShapeIds()[0]
tt.assertShapeType(id, ShapeType.Draw)
})
it('creates shapes when pointing a shape', () => {
tt.reset().restore().send('SELECTED_DRAW_TOOL').send('TOGGLED_TOOL_LOCK')
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
tt.startClick('canvas').movePointerBy([-200, 100]).stopClick('canvas')
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
})
it('creates shapes when shape locked', () => {
tt.reset()
.restore()
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 1,
},
'rect1'
)
.send('SELECTED_DRAW_TOOL')
tt.startClick('rect1').movePointerBy([100, 100]).stopClick('canvas')
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
})
it('cancels shape while creating', () => {
// TODO
null
})
})
it('moves shape', () => {
// TODO
null
})
it('rotates shape', () => {
// TODO
null
})
it('rotates shape in a group', () => {
// TODO
null
})
it('measures shape bounds', () => {
// TODO
null
})
it('measures shape rotated bounds', () => {
// TODO
null
})
it('transforms single shape', () => {
// TODO
null
})
it('transforms in a group', () => {
// TODO
null
})
/* -------------------- Specific -------------------- */
it('closes the shape when the start and end points are near enough', () => {
// TODO
null
})
it('remains closed after resizing up', () => {
// TODO
null
})
})

View file

@ -1,104 +0,0 @@
import { ShapeType } from 'types'
import TestState from '../test-utils'
describe('ellipse shape', () => {
const tt = new TestState()
tt.resetDocumentState().save()
describe('creating ellipses', () => {
it('creates shape', () => {
tt.reset().restore().send('SELECTED_ELLIPSE_TOOL')
expect(tt.state.isIn('ellipse.creating')).toBe(true)
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
const id = tt.getSortedPageShapeIds()[0]
tt.assertShapeType(id, ShapeType.Ellipse)
})
it('creates shapes when pointing a shape', () => {
tt.reset()
.restore()
.send('SELECTED_ELLIPSE_TOOL')
.send('TOGGLED_TOOL_LOCK')
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
tt.startClick('canvas').movePointerBy([-200, 100]).stopClick('canvas')
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
})
it('creates shapes when shape locked', () => {
tt.reset()
.restore()
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 1,
},
'rect1'
)
.send('SELECTED_ELLIPSE_TOOL')
tt.startClick('rect1').movePointerBy([100, 100]).stopClick('canvas')
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
})
it('cancels shape while creating', () => {
// TODO
null
})
})
it('moves shape', () => {
// TODO
null
})
it('rotates shape', () => {
// TODO
null
})
it('rotates shape in a group', () => {
// TODO
null
})
it('measures shape bounds', () => {
// TODO
null
})
it('measures shape rotated bounds', () => {
// TODO
null
})
it('transforms single shape', () => {
// TODO
null
})
it('transforms in a group', () => {
// TODO
null
})
/* -------------------- Specific -------------------- */
it('creates aspect-ratio-locked shape with shift key', () => {
// TODO
null
})
it('resizes aspect-ratio-locked shape with shift key', () => {
// TODO
null
})
})

View file

@ -1,104 +0,0 @@
import { ShapeType } from 'types'
import TestState from '../test-utils'
describe('rectangle shape', () => {
const tt = new TestState()
tt.resetDocumentState().save()
describe('creating rectangles', () => {
it('creates shape', () => {
tt.reset().restore().send('SELECTED_RECTANGLE_TOOL')
expect(tt.state.isIn('rectangle.creating')).toBe(true)
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
const id = tt.getSortedPageShapeIds()[0]
tt.assertShapeType(id, ShapeType.Rectangle)
})
it('creates shapes when pointing a shape', () => {
tt.reset()
.restore()
.send('SELECTED_RECTANGLE_TOOL')
.send('TOGGLED_TOOL_LOCK')
tt.startClick('canvas').movePointerBy([100, 100]).stopClick('canvas')
tt.startClick('canvas').movePointerBy([-200, 100]).stopClick('canvas')
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
})
it('creates shapes when shape locked', () => {
tt.reset()
.restore()
.createShape(
{
type: ShapeType.Rectangle,
point: [0, 0],
size: [100, 100],
childIndex: 1,
},
'rect1'
)
.send('SELECTED_RECTANGLE_TOOL')
tt.startClick('rect1').movePointerBy([100, 100]).stopClick('canvas')
expect(tt.getSortedPageShapeIds()).toHaveLength(2)
})
it('cancels shape while creating', () => {
// TODO
null
})
})
it('moves shape', () => {
// TODO
null
})
it('rotates shape', () => {
// TODO
null
})
it('rotates shape in a group', () => {
// TODO
null
})
it('measures shape bounds', () => {
// TODO
null
})
it('measures shape rotated bounds', () => {
// TODO
null
})
it('transforms single shape', () => {
// TODO
null
})
it('transforms in a group', () => {
// TODO
null
})
/* -------------------- Specific -------------------- */
it('creates aspect-ratio-locked shape with shift key', () => {
// TODO
null
})
it('resizes aspect-ratio-locked shape with shift key', () => {
// TODO
null
})
})

View file

@ -1,79 +0,0 @@
import TestState from '../test-utils'
describe('arrow shape', () => {
const tt = new TestState()
tt.resetDocumentState()
it('creates shape', () => {
tt.send('SELECTED_TEXT_TOOL')
expect(tt.state.isIn('text.creating')).toBe(true)
const id = tt.getSortedPageShapeIds()[0]
tt.clickCanvas()
expect(tt.state.isIn('editingShape')).toBe(true)
tt.send('EDITED_SHAPE', {
id,
change: { text: 'Hello world' },
})
tt.send('BLURRED_EDITING_SHAPE', { id: id })
expect(tt.state.isIn('selecting')).toBe(true)
})
it('cancels shape while creating', () => {
// TODO
null
})
it('moves shape', () => {
// TODO
null
})
it('rotates shape', () => {
// TODO
null
})
it('rotates shape in a group', () => {
// TODO
null
})
it('measures shape bounds', () => {
// TODO
null
})
it('measures shape rotated bounds', () => {
// TODO
null
})
it('transforms single shape', () => {
// TODO
null
})
it('transforms in a group', () => {
// TODO
null
})
/* -------------------- Specific -------------------- */
it('scales', () => {
// TODO
null
})
it('selects different text on tap while editing', () => {
// TODO
null
})
})

View file

@ -1,31 +0,0 @@
import state from 'state'
import * as json from './__mocks__/document.json'
state.reset()
state
.send('MOUNTED')
.send('MOUNTED_SHAPES')
.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
})
})

View file

@ -1,836 +0,0 @@
import _state from 'state'
import tld from 'utils/tld'
import inputs from 'state/inputs'
import { createShape, getShapeUtils } from 'state/shape-utils'
import { Corner, Data, Edge, Shape, ShapeType, ShapeUtility } from 'types'
import { deepClone, deepCompareArrays, uniqueId, vec } from 'utils'
import * as mockDocument from './__mocks__/document.json'
type State = typeof _state
export const rectangleId = 'e43559cb-6f41-4ae4-9c49-158ed1ad2f72'
export const arrowId = 'fee77127-e779-4576-882b-b1bf7c7e132f'
interface PointerOptions {
id?: number
x?: number
y?: number
shiftKey?: boolean
altKey?: boolean
ctrlKey?: boolean
}
class TestState {
_state: State
snapshot: Data
constructor() {
this._state = _state
this.state.send('TOGGLED_TEST_MODE')
this.snapshot = deepClone(this.state.data)
this.reset()
}
/**
* Get the underlying state-designer state.
*
* ### Example
*
*```ts
* tt.state
*```
*/
get state(): State {
return this._state
}
/**
* Get the state's current data.
*
* ### Example
*
*```ts
* tt.data
*```
*/
get data(): Readonly<Data> {
return this.state.data
}
/* -------- Reimplemenation of State Methods -------- */
/**
* 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
}
/**
* Check whether a state node is active. If multiple names are provided, then the method will return true only if ALL of the provided state nodes are active.
*
* ### Example
*
*```ts
* tt.isIn("ready") // true
* tt.isIn("ready", "selecting") // true
* tt.isInAny("ready", "notReady") // false
*```
*/
isIn(...ids: string[]): boolean {
return this.state.isIn(...ids)
}
/**
* Check whether a state node is active. If multiple names are provided, then the method will return true if ANY of the provided state nodes are active.
*
* ### Example
*
*```ts
* tt.isIn("ready") // true
* tt.isIn("ready", "selecting") // true
* tt.isInAny("ready", "notReady") // true
*```
*/
isInAny(...ids: string[]): boolean {
return this.state.isInAny(...ids)
}
/**
* Check whether the state can handle a certain event (and optionally payload). The method will return true if the event is handled by one or more currently active state nodes and if the event will pass its conditions (if present) in at least one of those handlers.
*
* ### Example
*
*```ts
* example
*```
*/
can(eventName: string, payload?: unknown): boolean {
return this.state.can(eventName, payload)
}
/* -------------------- Specific -------------------- */
/**
* Save a snapshot of the state's current data.
*
* ### Example
*
*```ts
* tt.save()
* tt.restore()
*```
*/
save(): TestState {
this.snapshot = deepClone(this.data)
return this
}
/**
* Restore the state's saved data.
*
* ### Example
*
*```ts
* tt.save()
* tt.restore()
*```
*/
restore(): TestState {
this.state.forceData(this.snapshot)
return this
}
/**
* Reset the test state.
*
* ### Example
*
*```ts
* tt.reset()
*```
*/
reset(): TestState {
this.state.reset()
this.state
.send('UNMOUNTED')
.send('MOUNTED', { roomId: 'TESTING' })
.send('MOUNTED_SHAPES')
.send('LOADED_FROM_FILE', { json: JSON.stringify(mockDocument) })
return this
}
/**
* Reset the document state. Will remove all shapes and extra pages.
*
* ### Example
*
*```ts
* tt.resetDocumentState()
*```
*/
resetDocumentState(): TestState {
this.state.send('RESET_DOCUMENT_STATE').send('TOGGLED_TEST_MODE')
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<Shape>, id = uniqueId()): TestState {
const shape = createShape(props.type, props)
getShapeUtils(shape)
.setProperty(shape, 'id', id)
.setProperty(shape, 'parentId', this.data.currentPageId)
this.data.document.pages[this.data.currentPageId].shapes[shape.id] = shape
return this
}
/**
* Click a shape.
*
* ### Example
*
*```ts
* tt.clickShape("myShapeId")
*```
*/
clickShape(id: string, options: PointerOptions = {}): TestState {
const shape = tld.getShape(this.data, id)
const [x, y] = shape ? vec.add(shape.point, [1, 1]) : [0, 0]
this.state
.send(
'POINTED_SHAPE',
inputs.pointerDown(TestState.point({ x, y, ...options }), id)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(TestState.point({ x, y, ...options }), id)
)
return this
}
/**
* Start a click (but do not stop it).
*
* ### Example
*
*```ts
* tt.startClick("myShapeId")
*```
*/
startClick(id: string, options: PointerOptions = {}): TestState {
const shape = tld.getShape(this.data, id)
const [x, y] = shape ? vec.add(shape.point, [1, 1]) : [0, 0]
if (id === 'canvas') {
this.state.send(
'POINTED_CANVAS',
inputs.pointerDown(TestState.point({ x, y, ...options }), id)
)
return this
}
this.state.send(
'POINTED_SHAPE',
inputs.pointerDown(TestState.point({ x, y, ...options }), id)
)
return this
}
/**
* Stop a click (after starting it).
*
* ### Example
*
*```ts
* tt.stopClick("myShapeId")
*```
*/
stopClick(id: string, options: PointerOptions = {}): TestState {
const shape = tld.getShape(this.data, id)
const [x, y] = shape ? vec.add(shape.point, [1, 1]) : [0, 0]
this.state.send(
'STOPPED_POINTING',
inputs.pointerUp(TestState.point({ x, y, ...options }), id)
)
return this
}
/**
* Double click a shape.
*
* ### Example
*
*```ts
* tt.clickShape("myShapeId")
*```
*/
doubleClickShape(id: string, options: PointerOptions = {}): TestState {
const shape = tld.getShape(this.data, id)
const [x, y] = shape ? vec.add(shape.point, [1, 1]) : [0, 0]
this.state
.send(
'DOUBLE_POINTED_SHAPE',
inputs.pointerDown(TestState.point({ x, y, ...options }), id)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(TestState.point({ x, y, ...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
}
/**
* Start clicking bounds.
*
* ### Example
*
*```ts
* tt.startClickingBounds()
*```
*/
startClickingBounds(options: PointerOptions = {}): TestState {
this.state.send(
'POINTED_BOUNDS',
inputs.pointerDown(TestState.point(options), 'bounds')
)
return this
}
/**
* Stop clicking the bounding box.
*
* ### Example
*
*```ts
* tt.stopClickingBounds()
*```
*/
stopClickingBounds(options: PointerOptions = {}): TestState {
this.state.send(
'STOPPED_POINTING',
inputs.pointerUp(TestState.point(options), 'bounds')
)
return this
}
/**
* Start clicking a bounds handle.
*
* ### Example
*
*```ts
* tt.startClickingBoundsHandle(Edge.Top)
*```
*/
startClickingBoundsHandle(
handle: Corner | Edge | 'center',
options: PointerOptions = {}
): TestState {
this.state.send(
'POINTED_BOUNDS_HANDLE',
inputs.pointerDown(TestState.point(options), handle)
)
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<PointerOptions, 'x' | 'y'> = {}
): 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<PointerOptions, 'x' | 'y'> = {}
): 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<PointerOptions, 'x' | 'y'> = {}
): 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<PointerOptions, 'x' | 'y'> = {}
): 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<PointerOptions, 'x' | 'y'> = {}
): 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
}
/**
* Select all shapes.
*
* ### Example
*
*```ts
* tt.deselectAll()
*```
*/
selectAll(): TestState {
this.state.send('SELECTED_ALL')
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
}
/**
* 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
}
/* ---------------- Getting Data Out ---------------- */
/**
* Get a shape by its id. Note: the shape must be in the current page.
*
* ### Example
*
*```ts
* tt.getShape("myShapeId")
*```
*/
getShape<T extends Shape>(id: string): T {
return tld.getShape(this.data, id) as T
}
/**
* Get the current selected ids.
*
* ### Example
*
*```ts
* example
*```
*/
get selectedIds(): string[] {
return tld.getSelectedIds(this.data)
}
/**
* Get shapes for the current page.
*
* ### Example
*
*```ts
* tt.getShapes()
*```
*/
getShapes(): Shape[] {
return Object.values(
this.data.document.pages[this.data.currentPageId].shapes
)
}
/**
* Get ids of the page's children sorted by their child index.
*
* ### Example
*
*```ts
* tt.getSortedPageShapes()
*```
*/
getSortedPageShapeIds(): string[] {
return this.getShapes()
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
}
/**
* 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
}
/**
* 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.length === ids.length : true) &&
ids.every((id) => selectedIds.includes(id))
)
}
/**
* 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
}
/**
* 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 ${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<T extends Shape>(
shape: T,
props: { [K in keyof Partial<T>]: 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
}
/**
* Get a shape and test it.
*
* ### Example
*
*```ts
* tt.testShape("myShapeId", (myShape, utils) => expect(utils(myShape).getBounds()).toMatchSnapshot() )
*```
*/
testShape<T extends Shape>(
id: string,
fn: (shape: T, shapeUtils: ShapeUtility<T>) => boolean
): boolean {
const shape = this.getShape<T>(id)
return fn(shape, shape && getShapeUtils(shape))
}
/**
* 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
}
}
export default TestState

View file

@ -1,38 +0,0 @@
import TestState from './test-utils'
const TOOLS = [
'draw',
'rectangle',
'ellipse',
'arrow',
'text',
'line',
'ray',
'dot',
]
describe('when selecting tools', () => {
const tt = new TestState()
TOOLS.forEach((tool) => {
it(`selects ${tool} tool`, () => {
tt.reset().send(`SELECTED_${tool.toUpperCase()}_TOOL`)
expect(tt.data.activeTool).toBe(tool)
expect(tt.state.isIn(tool)).toBe(true)
})
TOOLS.forEach((otherTool) => {
if (otherTool === tool) return
it(`selects ${tool} tool from ${otherTool} tool`, () => {
tt.reset()
.send(`SELECTED_${tool.toUpperCase()}_TOOL`)
.send(`SELECTED_${otherTool.toUpperCase()}_TOOL`)
expect(tt.data.activeTool).toBe(otherTool)
expect(tt.state.isIn(otherTool)).toBe(true)
})
})
})
})

View file

@ -1,3 +0,0 @@
module.exports = {
presets: ['next/babel'],
}

View file

@ -1,78 +0,0 @@
import * as React from 'react'
import { Edge, Corner } from 'types'
import { useSelector } from 'state'
import { getBoundsCenter, isMobile } from 'utils'
import tld from 'utils/tld'
import CenterHandle from './center-handle'
import CornerHandle from './corner-handle'
import EdgeHandle from './edge-handle'
import RotateHandle from './rotate-handle'
export default function Bounds(): JSX.Element {
const isBrushing = useSelector((s) => s.isIn('brushSelecting'))
const shouldDisplay = useSelector((s) =>
s.isInAny('selecting', 'selectPinching')
)
const zoom = useSelector((s) => tld.getCurrentCamera(s.data).zoom)
const bounds = useSelector((s) => s.values.selectedBounds)
const rotation = useSelector((s) => s.values.selectedRotation)
const isAllLocked = useSelector((s) => {
const page = tld.getPage(s.data)
return s.values.selectedIds.every((id) => page.shapes[id]?.isLocked)
})
const isSingleHandles = useSelector((s) => {
const page = tld.getPage(s.data)
return (
s.values.selectedIds.length === 1 &&
page.shapes[s.values.selectedIds[0]]?.handles !== undefined
)
})
if (!bounds) return null
if (!shouldDisplay) return null
if (isSingleHandles) return null
const size = (isMobile() ? 10 : 8) / zoom // Touch target size
const center = getBoundsCenter(bounds)
return (
<g
pointerEvents={isBrushing ? 'none' : 'all'}
transform={`
rotate(${rotation * (180 / Math.PI)},${center})
translate(${bounds.minX},${bounds.minY})
rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)`}
>
<CenterHandle bounds={bounds} isLocked={isAllLocked} />
{!isAllLocked && (
<>
<EdgeHandle size={size} bounds={bounds} edge={Edge.Top} />
<EdgeHandle size={size} bounds={bounds} edge={Edge.Right} />
<EdgeHandle size={size} bounds={bounds} edge={Edge.Bottom} />
<EdgeHandle size={size} bounds={bounds} edge={Edge.Left} />
<CornerHandle size={size} bounds={bounds} corner={Corner.TopLeft} />
<CornerHandle size={size} bounds={bounds} corner={Corner.TopRight} />
<CornerHandle
size={size}
bounds={bounds}
corner={Corner.BottomRight}
/>
<CornerHandle
size={size}
bounds={bounds}
corner={Corner.BottomLeft}
/>
<RotateHandle size={size} bounds={bounds} />
</>
)}
</g>
)
}

View file

@ -1,77 +0,0 @@
import { useRef } from 'react'
import state, { useSelector } from 'state'
import inputs from 'state/inputs'
import styled from 'styles'
import tld from 'utils/tld'
function handlePointerDown(e: React.PointerEvent<SVGRectElement>) {
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId)
const info = inputs.pointerDown(e, 'bounds')
if (e.button === 0) {
state.send('POINTED_BOUNDS', info)
} else if (e.button === 2) {
state.send('RIGHT_POINTED', info)
}
}
function handlePointerUp(e: React.PointerEvent<SVGRectElement>) {
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
e.currentTarget.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', inputs.pointerUp(e, 'bounds'))
}
export default function BoundsBg(): JSX.Element {
const rBounds = useRef<SVGRectElement>(null)
const bounds = useSelector((state) => state.values.selectedBounds)
const shouldDisplay = useSelector((s) =>
s.isInAny('selecting', 'selectPinching')
)
const rotation = useSelector((s) => s.values.selectedRotation)
const isAllHandles = useSelector((s) => {
const selectedIds = s.values.selectedIds
if (selectedIds.length === 1) {
const page = tld.getPage(s.data)
const selected = selectedIds[0]
return (
selectedIds.length === 1 && page.shapes[selected]?.handles !== undefined
)
}
})
if (isAllHandles) return null
if (!bounds) return null
if (!shouldDisplay) return null
const { width, height } = bounds
return (
<StyledBoundsBg
ref={rBounds}
width={Math.max(1, width)}
height={Math.max(1, height)}
transform={`
rotate(${rotation * (180 / Math.PI)},
${(bounds.minX + bounds.maxX) / 2},
${(bounds.minY + bounds.maxY) / 2})
translate(${bounds.minX},${bounds.minY})
rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)`}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
pointerEvents="all"
/>
)
}
const StyledBoundsBg = styled('rect', {
fill: '$boundsBg',
})

View file

@ -1,36 +0,0 @@
import styled from 'styles'
import { Bounds } from 'types'
export default function CenterHandle({
bounds,
isLocked,
}: {
bounds: Bounds
isLocked: boolean
}): JSX.Element {
return (
<StyledBounds
x={-1}
y={-1}
width={bounds.width + 2}
height={bounds.height + 2}
pointerEvents="none"
isLocked={isLocked}
/>
)
}
const StyledBounds = styled('rect', {
fill: 'none',
stroke: '$bounds',
zStrokeWidth: 1.5,
variants: {
isLocked: {
true: {
zStrokeWidth: 1.5,
zDash: 2,
},
},
},
})

View file

@ -1,57 +0,0 @@
import useBoundsEvents from 'hooks/useBoundsEvents'
import styled from 'styles'
import { Corner, Bounds } from 'types'
export default function CornerHandle({
size,
corner,
bounds,
}: {
size: number
bounds: Bounds
corner: Corner
}): JSX.Element {
const events = useBoundsEvents(corner)
const isTop = corner === Corner.TopLeft || corner === Corner.TopRight
const isLeft = corner === Corner.TopLeft || corner === Corner.BottomLeft
return (
<g>
<StyledCorner
corner={corner}
x={(isLeft ? -1 : bounds.width + 1) - size}
y={(isTop ? -1 : bounds.height + 1) - size}
width={size * 2}
height={size * 2}
{...events}
/>
<StyledCornerInner
x={(isLeft ? -1 : bounds.width + 1) - size / 2}
y={(isTop ? -1 : bounds.height + 1) - size / 2}
width={size}
height={size}
pointerEvents="none"
/>
</g>
)
}
const StyledCorner = styled('rect', {
stroke: 'none',
fill: 'transparent',
variants: {
corner: {
[Corner.TopLeft]: { cursor: 'nwse-resize' },
[Corner.TopRight]: { cursor: 'nesw-resize' },
[Corner.BottomRight]: { cursor: 'nwse-resize' },
[Corner.BottomLeft]: { cursor: 'nesw-resize' },
},
},
})
const StyledCornerInner = styled('rect', {
stroke: '$bounds',
fill: '$canvas',
zStrokeWidth: 1.5,
})

View file

@ -1,44 +0,0 @@
import useBoundsEvents from 'hooks/useBoundsEvents'
import styled from 'styles'
import { Edge, Bounds } from 'types'
export default function EdgeHandle({
size,
bounds,
edge,
}: {
size: number
bounds: Bounds
edge: Edge
}): JSX.Element {
const events = useBoundsEvents(edge)
const isHorizontal = edge === Edge.Top || edge === Edge.Bottom
const isFarEdge = edge === Edge.Right || edge === Edge.Bottom
const { height, width } = bounds
return (
<StyledEdge
edge={edge}
x={isHorizontal ? size / 2 : (isFarEdge ? width + 1 : -1) - size / 2}
y={isHorizontal ? (isFarEdge ? height + 1 : -1) - size / 2 : size / 2}
width={isHorizontal ? Math.max(0, width + 1 - size) : size}
height={isHorizontal ? size : Math.max(0, height + 1 - size)}
{...events}
/>
)
}
const StyledEdge = styled('rect', {
stroke: 'none',
fill: 'none',
variants: {
edge: {
[Edge.Top]: { cursor: 'ns-resize' },
[Edge.Right]: { cursor: 'ew-resize' },
[Edge.Bottom]: { cursor: 'ns-resize' },
[Edge.Left]: { cursor: 'ew-resize' },
},
},
})

View file

@ -1,81 +0,0 @@
import useHandleEvents from 'hooks/useHandleEvents'
import { getShapeUtils } from 'state/shape-utils'
import { useRef } from 'react'
import { useSelector } from 'state'
import styled from 'styles'
import tld from 'utils/tld'
import vec from 'utils/vec'
export default function Handles(): JSX.Element {
const shape = useSelector(
(s) =>
s.values.selectedIds.length === 1 &&
tld.getPage(s.data).shapes[s.values.selectedIds[0]]
)
const isSelecting = useSelector((s) =>
s.isInAny('notPointing', 'pinching', 'translatingHandles')
)
if (!shape || !shape.handles || !isSelecting) return null
const center = getShapeUtils(shape).getCenter(shape)
return (
<g transform={`rotate(${shape.rotation * (180 / Math.PI)},${center})`}>
{Object.values(shape.handles).map((handle) => (
<Handle
key={handle.id}
id={handle.id}
point={vec.add(handle.point, shape.point)}
/>
))}
</g>
)
}
function Handle({ id, point }: { id: string; point: number[] }) {
const rGroup = useRef<SVGGElement>(null)
const events = useHandleEvents(id, rGroup)
return (
<StyledGroup
key={id}
className="handles"
ref={rGroup}
{...events}
pointerEvents="all"
transform={`translate(${point})`}
>
<HandleCircleOuter r={12} />
<use href="#handle" pointerEvents="none" />
</StyledGroup>
)
}
const StyledGroup = styled('g', {
'&:hover': {
cursor: 'pointer',
},
'&:active': {
cursor: 'none',
},
})
const HandleCircleOuter = styled('circle', {
fill: 'transparent',
stroke: 'none',
opacity: 0.2,
pointerEvents: 'all',
cursor: 'pointer',
transform: 'scale(var(--scale))',
'&:hover': {
fill: '$selected',
'& > *': {
stroke: '$selected',
},
},
'&:active': {
fill: '$selected',
},
})

View file

@ -1,38 +0,0 @@
import useHandleEvents from 'hooks/useBoundsEvents'
import styled from 'styles'
import { Bounds } from 'types'
export default function Rotate({
bounds,
size,
}: {
bounds: Bounds
size: number
}): JSX.Element {
const events = useHandleEvents('rotate')
return (
<g cursor="grab" {...events}>
<circle
cx={bounds.width / 2}
cy={size * -2}
r={size * 2}
fill="transparent"
stroke="none"
/>
<StyledRotateHandle
cx={bounds.width / 2}
cy={size * -2}
r={size / 2}
pointerEvents="none"
/>
</g>
)
}
const StyledRotateHandle = styled('circle', {
stroke: '$bounds',
fill: '$canvas',
zStrokeWidth: 1.5,
cursor: 'grab',
})

View file

@ -1,23 +0,0 @@
import { useSelector } from 'state'
import styled from 'styles'
export default function Brush(): JSX.Element {
const brush = useSelector(({ data }) => data.brush)
if (!brush) return null
return (
<BrushRect
x={brush.minX}
y={brush.minY}
width={brush.width}
height={brush.height}
/>
)
}
const BrushRect = styled('rect', {
fill: '$brushFill',
stroke: '$brushStroke',
zStrokeWidth: 1,
})

View file

@ -1,99 +0,0 @@
import * as Sentry from '@sentry/node'
import React, { useEffect, useRef } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import state, { useSelector } from 'state'
import styled from 'styles'
import useCamera from 'hooks/useCamera'
import useCanvasEvents from 'hooks/useCanvasEvents'
import useZoomEvents from 'hooks/useZoomEvents'
import Bounds from './bounds/bounding-box'
import BoundsBg from './bounds/bounds-bg'
import Handles from './bounds/handles'
import ContextMenu from './context-menu/context-menu'
import Coop from './coop/coop'
import Brush from './brush'
import Defs from './defs'
import Page from './page'
import useSafariFocusOutFix from 'hooks/useSafariFocusOutFix'
function resetError() {
null
}
export default function Canvas(): JSX.Element {
const rCanvas = useRef<SVGSVGElement>(null)
const rGroup = useRef<SVGGElement>(null)
useCamera(rGroup)
useZoomEvents()
useSafariFocusOutFix()
const events = useCanvasEvents(rCanvas)
const isSettingCamera = useSelector((s) => s.isIn('settingCamera'))
const isReady = useSelector((s) => s.isIn('ready'))
useEffect(() => {
if (isSettingCamera) {
state.send('MOUNTED_SHAPES')
}
}, [isSettingCamera])
return (
<ContextMenu>
<MainSVG ref={rCanvas} {...events}>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={resetError}>
<Defs />
<g ref={rGroup} id="shapes" opacity={isReady ? 1 : 0}>
<BoundsBg />
<Page />
<Coop />
<Bounds />
<Handles />
<Brush />
</g>
</ErrorBoundary>
</MainSVG>
</ContextMenu>
)
}
const MainSVG = styled('svg', {
position: 'fixed',
overflow: 'hidden',
top: 0,
left: 0,
width: '100%',
height: '100%',
touchAction: 'none',
zIndex: 100,
pointerEvents: 'all',
backgroundColor: '$canvas',
borderTop: '1px solid $border',
borderBottom: '1px solid $border',
'& *': {
userSelect: 'none',
},
})
function ErrorFallback({ error, resetErrorBoundary }) {
React.useEffect(() => {
const copy =
'Sorry, something went wrong. Press Ok to reset the document, or press cancel to continue and see if it resolves itself.'
console.error(error)
Sentry.captureException(error)
if (window.confirm(copy)) {
state.send('RESET_DOCUMENT_STATE')
resetErrorBoundary()
}
}, [])
return <g />
}

View file

@ -1,368 +0,0 @@
import * as _ContextMenu from '@radix-ui/react-context-menu'
import styled from 'styles'
import {
IconWrapper,
breakpoints,
RowButton,
ContextMenuArrow,
ContextMenuDivider,
ContextMenuButton,
ContextMenuSubMenu,
ContextMenuIconButton,
ContextMenuRoot,
MenuContent,
} from 'components/shared'
import { commandKey, deepCompareArrays } from 'utils'
import state, { useSelector } from 'state'
import {
AlignType,
DistributeType,
MoveType,
ShapeType,
StretchType,
} from 'types'
import tld from 'utils/tld'
import React, { useRef } from 'react'
import {
ChevronRightIcon,
AlignBottomIcon,
AlignCenterHorizontallyIcon,
AlignCenterVerticallyIcon,
AlignLeftIcon,
AlignRightIcon,
AlignTopIcon,
SpaceEvenlyHorizontallyIcon,
SpaceEvenlyVerticallyIcon,
StretchHorizontallyIcon,
StretchVerticallyIcon,
} from '@radix-ui/react-icons'
import { Kbd } from 'components/shared'
function alignTop() {
state.send('ALIGNED', { type: AlignType.Top })
}
function alignCenterVertical() {
state.send('ALIGNED', { type: AlignType.CenterVertical })
}
function alignBottom() {
state.send('ALIGNED', { type: AlignType.Bottom })
}
function stretchVertically() {
state.send('STRETCHED', { type: StretchType.Vertical })
}
function distributeVertically() {
state.send('DISTRIBUTED', { type: DistributeType.Vertical })
}
function alignLeft() {
state.send('ALIGNED', { type: AlignType.Left })
}
function alignCenterHorizontal() {
state.send('ALIGNED', { type: AlignType.CenterHorizontal })
}
function alignRight() {
state.send('ALIGNED', { type: AlignType.Right })
}
function stretchHorizontally() {
state.send('STRETCHED', { type: StretchType.Horizontal })
}
function distributeHorizontally() {
state.send('DISTRIBUTED', { type: DistributeType.Horizontal })
}
export default function ContextMenu({
children,
}: {
children: React.ReactNode
}): JSX.Element {
const selectedShapeIds = useSelector(
(s) => s.values.selectedIds,
deepCompareArrays
)
const rContent = useRef<HTMLDivElement>(null)
const hasGroupSelected = useSelector((s) =>
selectedShapeIds.some(
(id) => tld.getShape(s.data, id)?.type === ShapeType.Group
)
)
const hasTwoOrMore = selectedShapeIds.length > 1
const hasThreeOrMore = selectedShapeIds.length > 2
return (
<ContextMenuRoot>
<_ContextMenu.Trigger>{children}</_ContextMenu.Trigger>
<MenuContent as={_ContextMenu.Content} ref={rContent}>
{selectedShapeIds.length ? (
<>
{/* <ContextMenuButton onSelect={() => state.send('COPIED')}>
<span>Copy</span>
<Kbd>
<span>{commandKey()}</span>
<span>C</span>
</Kbd>
</ContextMenuButton>
<ContextMenuButton onSelect={() => state.send('CUT')}>
<span>Cut</span>
<Kbd>
<span>{commandKey()}</span>
<span>X</span>
</Kbd>
</ContextMenuButton>
*/}
<ContextMenuButton onSelect={() => state.send('DUPLICATED')}>
<span>Duplicate</span>
<Kbd>
<span>{commandKey()}</span>
<span>D</span>
</Kbd>
</ContextMenuButton>
<ContextMenuDivider />
{hasGroupSelected ||
(hasTwoOrMore && (
<>
{hasGroupSelected && (
<ContextMenuButton onSelect={() => state.send('UNGROUPED')}>
<span>Ungroup</span>
<Kbd>
<span>{commandKey()}</span>
<span></span>
<span>G</span>
</Kbd>
</ContextMenuButton>
)}
{hasTwoOrMore && (
<ContextMenuButton onSelect={() => state.send('GROUPED')}>
<span>Group</span>
<Kbd>
<span>{commandKey()}</span>
<span>G</span>
</Kbd>
</ContextMenuButton>
)}
</>
))}
<ContextMenuSubMenu label="Move">
<ContextMenuButton
onSelect={() =>
state.send('MOVED', {
type: MoveType.ToFront,
})
}
>
<span>To Front</span>
<Kbd>
<span>{commandKey()}</span>
<span></span>
<span>]</span>
</Kbd>
</ContextMenuButton>
<ContextMenuButton
onSelect={() =>
state.send('MOVED', {
type: MoveType.Forward,
})
}
>
<span>Forward</span>
<Kbd>
<span>{commandKey()}</span>
<span>]</span>
</Kbd>
</ContextMenuButton>
<ContextMenuButton
onSelect={() =>
state.send('MOVED', {
type: MoveType.Backward,
})
}
>
<span>Backward</span>
<Kbd>
<span>{commandKey()}</span>
<span>[</span>
</Kbd>
</ContextMenuButton>
<ContextMenuButton
onSelect={() =>
state.send('MOVED', {
type: MoveType.ToBack,
})
}
>
<span>To Back</span>
<Kbd>
<span>{commandKey()}</span>
<span></span>
<span>[</span>
</Kbd>
</ContextMenuButton>
</ContextMenuSubMenu>
{hasTwoOrMore && (
<AlignDistributeSubMenu
hasTwoOrMore={hasTwoOrMore}
hasThreeOrMore={hasThreeOrMore}
/>
)}
<MoveToPageMenu />
<ContextMenuButton onSelect={() => state.send('COPIED_TO_SVG')}>
<span>Copy to SVG</span>
<Kbd>
<span>{commandKey()}</span>
<span></span>
<span>C</span>
</Kbd>
</ContextMenuButton>
<ContextMenuDivider />
<ContextMenuButton onSelect={() => state.send('DELETED')}>
<span>Delete</span>
<Kbd>
<span></span>
</Kbd>
</ContextMenuButton>
</>
) : (
<>
<ContextMenuButton onSelect={() => state.send('UNDO')}>
<span>Undo</span>
<Kbd>
<span>{commandKey()}</span>
<span>Z</span>
</Kbd>
</ContextMenuButton>
<ContextMenuButton onSelect={() => state.send('REDO')}>
<span>Redo</span>
<Kbd>
<span>{commandKey()}</span>
<span></span>
<span>Z</span>
</Kbd>
</ContextMenuButton>
</>
)}
</MenuContent>
</ContextMenuRoot>
)
}
function AlignDistributeSubMenu({
hasThreeOrMore,
}: {
hasTwoOrMore: boolean
hasThreeOrMore: boolean
}) {
return (
<ContextMenuRoot>
<_ContextMenu.TriggerItem as={RowButton} bp={breakpoints}>
<span>Align / Distribute</span>
<IconWrapper size="small">
<ChevronRightIcon />
</IconWrapper>
</_ContextMenu.TriggerItem>
<StyledGrid
as={_ContextMenu.Content}
sideOffset={2}
alignOffset={-2}
selectedStyle={hasThreeOrMore ? 'threeOrMore' : 'twoOrMore'}
>
<ContextMenuIconButton onSelect={alignLeft}>
<AlignLeftIcon />
</ContextMenuIconButton>
<ContextMenuIconButton onSelect={alignCenterHorizontal}>
<AlignCenterHorizontallyIcon />
</ContextMenuIconButton>
<ContextMenuIconButton onSelect={alignRight}>
<AlignRightIcon />
</ContextMenuIconButton>
<ContextMenuIconButton onSelect={stretchHorizontally}>
<StretchHorizontallyIcon />
</ContextMenuIconButton>
{hasThreeOrMore && (
<ContextMenuIconButton onSelect={distributeHorizontally}>
<SpaceEvenlyHorizontallyIcon />
</ContextMenuIconButton>
)}
<ContextMenuIconButton onSelect={alignTop}>
<AlignTopIcon />
</ContextMenuIconButton>
<ContextMenuIconButton onSelect={alignCenterVertical}>
<AlignCenterVerticallyIcon />
</ContextMenuIconButton>
<ContextMenuIconButton onSelect={alignBottom}>
<AlignBottomIcon />
</ContextMenuIconButton>
<ContextMenuIconButton onSelect={stretchVertically}>
<StretchVerticallyIcon />
</ContextMenuIconButton>
{hasThreeOrMore && (
<ContextMenuIconButton onSelect={distributeVertically}>
<SpaceEvenlyVerticallyIcon />
</ContextMenuIconButton>
)}
<ContextMenuArrow offset={13} />
</StyledGrid>
</ContextMenuRoot>
)
}
const StyledGrid = styled(MenuContent, {
display: 'grid',
variants: {
selectedStyle: {
threeOrMore: {
gridTemplateColumns: 'repeat(5, auto)',
},
twoOrMore: {
gridTemplateColumns: 'repeat(4, auto)',
},
},
},
})
function MoveToPageMenu() {
const documentPages = useSelector((s) => s.data.document.pages)
const currentPageId = useSelector((s) => s.data.currentPageId)
if (!documentPages[currentPageId]) return null
const sorted = Object.values(documentPages)
.sort((a, b) => a.childIndex - b.childIndex)
.filter((a) => a.id !== currentPageId)
if (sorted.length === 0) return null
return (
<ContextMenuRoot>
<ContextMenuButton>
<span>Move To Page</span>
<IconWrapper size="small">
<ChevronRightIcon />
</IconWrapper>
</ContextMenuButton>
<MenuContent as={_ContextMenu.Content} sideOffset={2} alignOffset={-2}>
{sorted.map(({ id, name }) => (
<ContextMenuButton
key={id}
disabled={id === currentPageId}
onSelect={() => state.send('MOVED_TO_PAGE', { id })}
>
<span>{name}</span>
</ContextMenuButton>
))}
<ContextMenuArrow offset={13} />
</MenuContent>
</ContextMenuRoot>
)
}

View file

@ -1,29 +0,0 @@
import Cursor from './cursor'
import { useCoopSelector } from 'state/coop/coop-state'
import { useSelector } from 'state'
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)
.filter(({ presence }) => presence?.pageId === currentPageId)
.map(({ connectionId, presence }) => {
return (
<Cursor
key={`cursor-${connectionId}`}
color={'red'}
duration={presence.duration}
times={presence.times}
bufferedXs={presence.bufferedXs}
bufferedYs={presence.bufferedYs}
/>
)
})}
</>
)
}

View file

@ -1,65 +0,0 @@
import React from 'react'
import styled from 'styles'
import { motion } from 'framer-motion'
export default function Cursor({
color = 'dodgerblue',
duration = 0,
bufferedXs = [],
bufferedYs = [],
times = [],
}: {
color: string
duration: number
bufferedXs: number[]
bufferedYs: number[]
times: number[]
}): JSX.Element {
return (
<StyledCursor
color={color}
initial={false}
animate={{
x: bufferedXs,
y: bufferedYs,
transition: {
type: 'tween',
ease: 'linear',
duration,
times,
},
}}
width="35px"
height="35px"
viewBox="0 0 35 35"
version="1.1"
pointerEvents="none"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<path
d="M12,24.4219 L12,8.4069 L23.591,20.0259 L16.81,20.0259 L16.399,20.1499 L12,24.4219 Z"
fill="#ffffff"
/>
<path
d="M21.0845,25.0962 L17.4795,26.6312 L12.7975,15.5422 L16.4835,13.9892 L21.0845,25.0962 Z"
fill="#ffffff"
/>
<path
d="M19.751,24.4155 L17.907,25.1895 L14.807,17.8155 L16.648,17.0405 L19.751,24.4155 Z"
fill="currentColor"
/>
<path
d="M13,10.814 L13,22.002 L15.969,19.136 L16.397,18.997 L21.165,18.997 L13,10.814 Z"
fill="currentColor"
/>
</StyledCursor>
)
}
const StyledCursor = styled(motion.g, {
position: 'absolute',
zIndex: 1000,
top: 0,
left: 0,
})

View file

@ -1,43 +0,0 @@
import React from 'react'
import { useSelector } from 'state'
import tld from 'utils/tld'
import { DotCircle, Handle } from './misc'
import styled from 'styles'
export default function Defs(): JSX.Element {
return (
<defs>
<DotCircle id="dot" r={4} />
<Handle id="handle" r={4} />
<ExpandDef />
<HoverDef />
</defs>
)
}
function ExpandDef() {
const zoom = useSelector((s) => tld.getCurrentCamera(s.data).zoom)
return (
<filter id="expand">
<feMorphology operator="dilate" radius={0.5 / zoom} />
</filter>
)
}
function HoverDef() {
return (
<filter id="hover">
<StyledShadow
dx="2"
dy="2"
stdDeviation="0.5"
floodOpacity="1"
floodColor="blue"
/>
</filter>
)
}
const StyledShadow = styled('feDropShadow', {
floodColor: '$selected',
})

View file

@ -1,43 +0,0 @@
import { memo } from 'react'
import tld from 'utils/tld'
import { getShapeUtils } from 'state/shape-utils'
import vec from 'utils/vec'
import styled from 'styles'
import { useSelector } from 'state'
import { getShapeStyle } from 'state/shape-styles'
function HoveredShape({ id }: { id: string }) {
const transform = useSelector((s) => {
const shape = tld.getShape(s.data, id)
const center = getShapeUtils(shape).getCenter(shape)
const rotation = shape.rotation * (180 / Math.PI)
const parentPoint = tld.getShape(s.data, shape.parentId)?.point || [0, 0]
return `
translate(${vec.neg(parentPoint)})
rotate(${rotation}, ${center})
translate(${shape.point})
`
})
const strokeWidth = useSelector((s) => {
const shape = tld.getShape(s.data, id)
const style = getShapeStyle(shape.style, s.data.settings.isDarkMode)
return +style.strokeWidth
})
return (
<StyledHoverShape
href={'#' + id}
transform={transform}
strokeWidth={strokeWidth + 8}
/>
)
}
const StyledHoverShape = styled('use', {
stroke: '$selected',
opacity: 0.1,
})
export default memo(HoveredShape)

View file

@ -1,19 +0,0 @@
import styled from 'styles'
export const DotCircle = styled('circle', {
transform: 'scale(var(--scale))',
fill: '$canvas',
stroke: '$text',
strokeWidth: '2',
})
export const Handle = styled('circle', {
transform: 'scale(var(--scale))',
fill: '$canvas',
stroke: '$selected',
strokeWidth: '2',
})
export const ThinLine = styled('line', {
zStrokeWidth: 1,
})

View file

@ -1,57 +0,0 @@
import { useSelector } from 'state'
import { ShapeTreeNode } from 'types'
import ShapeComponent from './shape'
export default function Page(): JSX.Element {
const shapesToRender = useSelector((s) => s.values.shapesToRender)
const allowHovers = useSelector((s) =>
s.isInAny('selecting', 'text', 'editingShape')
)
return (
<>
{shapesToRender.map((node) => (
<ShapeNode key={node.shape.id} node={node} allowHovers={allowHovers} />
))}
</>
)
}
interface ShapeNodeProps {
node: ShapeTreeNode
allowHovers: boolean
}
const ShapeNode = ({
node: {
shape,
children,
isEditing,
isHovered,
isDarkMode,
isSelected,
isCurrentParent,
},
allowHovers,
}: ShapeNodeProps) => {
return (
<>
<ShapeComponent
shape={shape}
isEditing={isEditing}
isHovered={allowHovers && isHovered}
isSelected={isSelected}
isDarkMode={isDarkMode}
isCurrentParent={isCurrentParent}
/>
{children.map((childNode) => (
<ShapeNode
key={childNode.shape.id}
node={childNode}
allowHovers={allowHovers}
/>
))}
</>
)
}

View file

@ -1,149 +0,0 @@
import useShapeEvents from 'hooks/useShapeEvents'
import { Shape as _Shape, ShapeType, TextShape } from 'types'
import { getShapeUtils } from 'state/shape-utils'
import { shallowEqual } from 'utils'
import { memo, useRef } from 'react'
import styled from 'styles'
interface ShapeProps {
shape: _Shape
isEditing: boolean
isHovered: boolean
isSelected: boolean
isDarkMode: boolean
isCurrentParent: boolean
}
const Shape = memo(
({
shape,
isEditing,
isHovered,
isSelected,
isDarkMode,
isCurrentParent,
}: ShapeProps) => {
const rGroup = useRef<SVGGElement>(null)
const events = useShapeEvents(shape.id, isCurrentParent, rGroup)
const utils = getShapeUtils(shape)
const center = utils.getCenter(shape)
const rotation = shape.rotation * (180 / Math.PI)
const transform = `rotate(${rotation}, ${center}) translate(${shape.point})`
return (
<ShapeGroup
ref={rGroup}
id={shape.id}
transform={transform}
isCurrentParent={isCurrentParent}
filter={isHovered ? 'url(#expand)' : 'none'}
{...events}
>
{isEditing && shape.type === ShapeType.Text ? (
<EditingTextShape shape={shape} isDarkMode={isDarkMode} />
) : (
<RenderedShape
shape={shape}
isEditing={isEditing}
isHovered={isHovered}
isSelected={isSelected}
isDarkMode={isDarkMode}
isCurrentParent={isCurrentParent}
/>
)}
</ShapeGroup>
)
},
shallowEqual
)
export default Shape
interface RenderedShapeProps {
shape: _Shape
isEditing: boolean
isHovered: boolean
isSelected: boolean
isDarkMode: boolean
isCurrentParent: boolean
}
const RenderedShape = memo(
function RenderedShape({
shape,
isEditing,
isHovered,
isSelected,
isDarkMode,
isCurrentParent,
}: RenderedShapeProps) {
return getShapeUtils(shape).render(shape, {
isEditing,
isHovered,
isSelected,
isDarkMode,
isCurrentParent,
})
},
(prev, next) => {
if (
prev.isEditing !== next.isEditing ||
prev.isHovered !== next.isHovered ||
prev.isSelected !== next.isSelected ||
prev.isDarkMode !== next.isDarkMode ||
prev.isCurrentParent !== next.isCurrentParent
) {
return false
}
if (next.shape !== prev.shape) {
return !getShapeUtils(next.shape).shouldRender(next.shape, prev.shape)
}
return true
}
)
function EditingTextShape({
shape,
isDarkMode,
}: {
shape: TextShape
isDarkMode: boolean
}) {
const ref = useRef<HTMLTextAreaElement>(null)
return getShapeUtils(shape).render(shape, {
ref,
isEditing: true,
isHovered: false,
isSelected: false,
isDarkMode,
isCurrentParent: false,
})
}
const ShapeGroup = styled('g', {
outline: 'none',
'& > *[data-shy=true]': {
opacity: 0,
},
'&:hover': {
'& > *[data-shy=true]': {
opacity: 1,
},
},
variants: {
isCurrentParent: {
true: {
'& > *[data-shy=true]': {
opacity: 1,
},
},
},
},
})

View file

@ -1,121 +0,0 @@
import styled from 'styles'
import ReactMarkdown from 'react-markdown'
import docs from './docs-content'
export default function CodeDocs({
isHidden,
}: {
isHidden: boolean
}): JSX.Element {
return (
<StyledDocs isHidden={isHidden}>
<ReactMarkdown>{docs.content}</ReactMarkdown>
</StyledDocs>
)
}
const StyledDocs = styled('div', {
position: 'absolute',
backgroundColor: '$panel',
top: 0,
left: 0,
width: '100%',
height: '100%',
padding: 16,
overflowY: 'scroll',
userSelect: 'none',
paddingBottom: 64,
fontFamily: '$body',
fontWeight: 500,
variants: {
isHidden: {
true: {
visibility: 'hidden',
},
false: {
visibility: 'visible',
},
},
},
'& p': {
fontSize: '$3',
lineHeight: '1.3',
},
'& ol, ul': {
fontSize: '$3',
lineHeight: '1.3',
marginTop: 16,
marginBottom: 16,
},
'& li': {
fontSize: '$3',
lineHeight: '1.5',
},
'& code': {
font: '$mono',
},
'& hr': {
margin: '32px 0',
borderColor: '$muted',
},
'& h2': {
margin: '40px 0px 24px 0',
},
'& h3': {
fontSize: 20,
margin: '48px 0px 32px 0px',
},
'& h3 > code': {
fontWeight: 600,
font: '$mono',
},
'& h4': {
margin: '32px 0px 0px 0px',
},
'& h4 > code': {
font: '$mono',
fontSize: 16,
userSelect: 'all',
},
'& h4 > code > i': {
fontSize: 14,
color: '$muted',
},
'& pre': {
border: '1px solid $brushStroke',
font: '$mono',
fontWeight: 420,
lineHeight: 1.5,
padding: 16,
borderRadius: 4,
userSelect: 'all',
margin: '24px 0',
},
'& p > code, blockquote > code': {
padding: '2px 4px',
borderRadius: 2,
color: '$text',
backgroundColor: '$codeHl',
},
'& blockquote': {
backgroundColor: '$overlay',
padding: 12,
margin: '20px 0',
borderRadius: 8,
},
})

View file

@ -1,248 +0,0 @@
import Editor, { Monaco } from '@monaco-editor/react'
import { useTheme } from 'next-themes'
import libImport from './es5-lib'
import typesImport from './types-import'
import React, { useCallback, useEffect, useRef } from 'react'
import styled from 'styles'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
import { getFormattedCode } from 'utils/code'
import { metaKey } from 'utils'
export type IMonaco = typeof monaco
export type IMonacoEditor = monaco.editor.IStandaloneCodeEditor
const modifierKeys = ['Escape', 'Meta', 'Control', 'Shift', 'Option', 'Alt']
interface Props {
value: string
error: { line: number; column: number }
fontSize: number
monacoRef?: React.MutableRefObject<IMonaco>
editorRef?: React.MutableRefObject<IMonacoEditor>
readOnly?: boolean
onMount?: (value: string, editor: IMonacoEditor) => void
onUnmount?: (editor: IMonacoEditor) => void
onChange?: (value: string, editor: IMonacoEditor) => void
onSave?: (value: string, editor: IMonacoEditor) => void
onError?: (error: Error, line: number, col: number) => void
onKey?: () => void
}
export default function CodeEditor({
editorRef,
monacoRef,
fontSize,
value,
error,
readOnly,
onChange,
onSave,
onKey,
}: Props): JSX.Element {
const { theme } = useTheme()
const rEditor = useRef<IMonacoEditor>(null)
const rMonaco = useRef<IMonaco>(null)
const handleBeforeMount = useCallback((monaco: Monaco) => {
if (monacoRef) {
monacoRef.current = monaco
}
rMonaco.current = monaco
// Set the compiler options.
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
allowJs: true,
checkJs: true,
strict: true,
noLib: true,
lib: ['es6'],
target: monaco.languages.typescript.ScriptTarget.ES2016,
allowNonTsExtensions: true,
})
// Sync the intellisense on load.
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true)
// Run both semantic and syntax validation.
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
})
// Add custom types
monaco.languages.typescript.typescriptDefaults.addExtraLib(
typesImport.content
)
// Add es5 library types
monaco.languages.typescript.typescriptDefaults.addExtraLib(
libImport.content
)
// Use prettier as a formatter
monaco.languages.registerDocumentFormattingEditProvider('typescript', {
async provideDocumentFormattingEdits(model) {
try {
const text = getFormattedCode(model.getValue())
return [
{
range: model.getFullModelRange(),
text,
},
]
} catch (e) {
return [
{
range: model.getFullModelRange(),
text: model.getValue(),
},
]
}
},
})
}, [])
const handleMount = useCallback((editor: IMonacoEditor) => {
if (editorRef) {
editorRef.current = editor
}
rEditor.current = editor
editor.updateOptions({
fontSize,
fontFamily: "'Recursive Mono', monospace",
wordBasedSuggestions: false,
minimap: { enabled: false },
lightbulb: {
enabled: false,
},
readOnly,
})
}, [])
const handleChange = useCallback((code: string | undefined) => {
onChange(code, rEditor.current)
}, [])
const handleKeydown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation()
!modifierKeys.includes(e.key) && onKey?.()
if ((e.key === 's' || e.key === 'Enter') && metaKey(e)) {
const editor = rEditor.current
if (!editor) return
editor
.getAction('editor.action.formatDocument')
.run()
.then(() =>
onSave(rEditor.current?.getModel().getValue(), rEditor.current)
)
e.preventDefault()
}
if (e.key === 'p' && metaKey(e)) {
e.preventDefault()
}
if (e.key === 'd' && metaKey(e)) {
e.preventDefault()
}
},
[]
)
const handleKeyUp = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => e.stopPropagation(),
[]
)
const rDecorations = useRef<any>([])
useEffect(() => {
const monaco = rMonaco.current
if (!monaco) return
const editor = rEditor.current
if (!editor) return
if (!error) {
rDecorations.current = editor.deltaDecorations(rDecorations.current, [])
return
}
if (!error.line) return
rDecorations.current = editor.deltaDecorations(rDecorations.current, [
{
range: new monaco.Range(
Number(error.line) - 1,
0,
Number(error.line) - 1,
0
),
options: {
isWholeLine: true,
className: 'editorLineError',
},
},
])
}, [error])
useEffect(() => {
const monaco = rMonaco.current
if (!monaco) return
monaco.editor.setTheme(theme === 'dark' ? 'vs-dark' : 'light')
}, [theme])
useEffect(() => {
const editor = rEditor.current
if (!editor) return
editor.updateOptions({
fontSize,
})
}, [fontSize])
return (
<EditorContainer onKeyDown={handleKeydown} onKeyUp={handleKeyUp}>
<Editor
height="100%"
language="typescript"
value={value}
theme={theme === 'dark' ? 'vs-dark' : 'light'}
beforeMount={handleBeforeMount}
onMount={handleMount}
onChange={handleChange}
defaultPath="index.ts"
/>
</EditorContainer>
)
}
const EditorContainer = styled('div', {
height: '100%',
pointerEvents: 'all',
userSelect: 'all',
'& > *': {
userSelect: 'all',
pointerEvents: 'all',
},
'.editorLineError': {
backgroundColor: '$lineError',
},
})

View file

@ -1,235 +0,0 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import styled from 'styles'
import { useStateDesigner } from '@state-designer/react'
import React, { useCallback, useEffect, useRef } from 'react'
import state, { useSelector } from 'state'
import { CodeError, CodeFile, CodeResult } from 'types'
import CodeDocs from './code-docs'
import { generateFromCode } from 'state/code/generate'
import * as Panel from '../panel'
import { breakpoints, IconButton } from '../shared'
import {
Cross2Icon,
CodeIcon,
PlayIcon,
ChevronUpIcon,
ChevronDownIcon,
} from '@radix-ui/react-icons'
import dynamic from 'next/dynamic'
import { ReaderIcon } from '@radix-ui/react-icons'
const CodeEditor = dynamic(() => import('./code-editor'))
const increaseCodeSize = () => state.send('INCREASED_CODE_FONT_SIZE')
const decreaseCodeSize = () => state.send('DECREASED_CODE_FONT_SIZE')
const toggleCodePanel = () => state.send('TOGGLED_CODE_PANEL_OPEN')
const handleWheel = (e: React.WheelEvent) => e.stopPropagation()
export default function CodePanel(): JSX.Element {
const rContainer = useRef<HTMLDivElement>(null)
const isReadOnly = useSelector((s) => s.data.isReadOnly)
const fileId = useSelector((s) => s.data.currentCodeFileId)
const file = useSelector(
(s) => s.data.document.code[s.data.currentCodeFileId]
)
const isOpen = useSelector((s) => s.data.settings.isCodeOpen)
const fontSize = useSelector((s) => s.data.settings.fontSize)
const local = useStateDesigner({
data: {
code: file.code,
error: null as CodeError | null,
},
on: {
MOUNTED: 'setCode',
CHANGED_FILE: 'loadFile',
},
initial: 'editingCode',
states: {
editingCode: {
on: {
RAN_CODE: { do: 'saveCode', to: 'evaluatingCode' },
SAVED_CODE: { do: 'saveCode', to: 'evaluatingCode' },
CHANGED_CODE: { secretlyDo: 'setCode' },
CLEARED_ERROR: { if: 'hasError', do: 'clearError' },
TOGGLED_DOCS: { to: 'viewingDocs' },
},
},
evaluatingCode: {
async: {
await: 'evalCode',
onResolve: {
do: ['clearError', 'sendResultToGlobalState'],
to: 'editingCode',
},
onReject: { do: 'setErrorFromResult', to: 'editingCode' },
},
},
viewingDocs: {
on: {
TOGGLED_DOCS: { to: 'editingCode' },
},
},
},
conditions: {
hasError(data) {
return !!data.error
},
},
actions: {
loadFile(data, payload: { file: CodeFile }) {
data.code = payload.file.code
},
setCode(data, payload: { code: string }) {
data.code = payload.code
},
saveCode(data) {
const { code } = data
state.send('SAVED_CODE', { code })
},
clearError(data) {
data.error = null
},
setErrorFromResult(data, payload, result: CodeResult) {
data.error = result.error
},
sendResultToGlobalState(data, payload, result: CodeResult) {
state.send('GENERATED_FROM_CODE', result)
},
},
asyncs: {
evalCode(data) {
return new Promise((resolve, reject) => {
generateFromCode(state.data, data.code).then((result) => {
if (result.error !== null) {
reject(result)
} else {
resolve(result)
}
})
})
},
},
})
useEffect(() => {
local.send('CHANGED_FILE', { file })
}, [file])
useEffect(() => {
local.send('MOUNTED', { code: state.data.document.code[fileId].code })
return () => {
state.send('CHANGED_CODE', { fileId, code: local.data.code })
}
}, [])
const handleCodeChange = useCallback(
(code: string) => local.send('CHANGED_CODE', { code }),
[local]
)
const handleSave = useCallback(() => local.send('SAVED_CODE'), [local])
const handleKey = useCallback(() => local.send('CLEARED_ERROR'), [local])
const toggleDocs = useCallback(() => local.send('TOGGLED_DOCS'), [local])
const { error } = local.data
return (
<Panel.Root
dir="ltr"
bp={breakpoints}
data-bp-desktop
ref={rContainer}
isOpen={isOpen}
variant="code"
onWheel={handleWheel}
>
{isOpen ? (
<Panel.Layout>
<Panel.Header side="left">
<IconButton bp={breakpoints} size="small" onClick={toggleCodePanel}>
<Cross2Icon />
</IconButton>
<h3>Code</h3>
<ButtonsGroup>
<FontSizeButtons>
<IconButton
bp={breakpoints}
size="small"
disabled={!local.isIn('editingCode')}
onClick={increaseCodeSize}
>
<ChevronUpIcon />
</IconButton>
<IconButton
size="small"
disabled={!local.isIn('editingCode')}
onClick={decreaseCodeSize}
>
<ChevronDownIcon />
</IconButton>
</FontSizeButtons>
<IconButton bp={breakpoints} size="small" onClick={toggleDocs}>
<ReaderIcon />
</IconButton>
<IconButton
bp={breakpoints}
size="small"
disabled={!local.isIn('editingCode')}
onClick={handleSave}
>
<PlayIcon />
</IconButton>
</ButtonsGroup>
</Panel.Header>
<Panel.Content>
<CodeEditor
fontSize={fontSize}
readOnly={isReadOnly}
value={file.code}
error={error}
onChange={handleCodeChange}
onSave={handleSave}
onKey={handleKey}
/>
<CodeDocs isHidden={!local.isIn('viewingDocs')} />
</Panel.Content>
{error && <Panel.Footer>{error.message}</Panel.Footer>}
</Panel.Layout>
) : (
<IconButton bp={breakpoints} size="small" onClick={toggleCodePanel}>
<CodeIcon />
</IconButton>
)}
</Panel.Root>
)
}
const ButtonsGroup = styled('div', {
gridRow: '1',
gridColumn: '3',
display: 'flex',
})
const FontSizeButtons = styled('div', {
paddingRight: 4,
display: 'flex',
flexDirection: 'column',
'& > button': {
height: '50%',
'&:nth-of-type(1)': {
alignItems: 'flex-end',
},
'&:nth-of-type(2)': {
alignItems: 'flex-start',
},
'& svg': {
height: 12,
},
},
})

View file

@ -1,129 +0,0 @@
/* eslint-disable */
// HEY! DO NOT MODIFY THIS FILE. THE CONTENTS OF THIS FILE
// ARE AUTO-GENERATED BY A SCRIPT AT: /scripts/docs-gen.js
// ANY CHANGES WILL BE LOST WHEN THE SCRIPT RUNS AGAIN!
export default {
name: 'docs-content.ts',
content: `
Welcome to the documentation for tldraw's code editor. You can use the code editor to create shapes using JavaScript or TypeScript code.
\`\`\`ts
const rect = new Rectangle({
point: [100, 100],
size: [200, 200],
style: {
color: ColorStyle.Blue,
},
})
rect.x = 300
\`\`\`
To run your code, press **Command + S**.
Your new shapes will appear on the canvas. You can interact with code-created shapes just like any other shape: you can move the shape, change its style, delete it, etc.
Each time you run your code, any existing code-created shapes will be replaced by your new code-created shapes. If you want to keep your code-created shapes, select the shapes that you want to keep, press **Command + D** to duplicate them, and move them off to the side.
## Shapes
You can use the code editor to create any of the regular shapes:
- Draw
- Rectangle
- Ellipse
- Arrow
- Text
You can also create shapes that can _only_ be created with code:
- Dot
- Ray
- Line
- Polyline
Each of these shapes is a \`class\`. To create the shape, use the following syntax:
\`\`\`ts
const myShape = new Rectangle()
\`\`\`
You can also create a shape with custom properties like this:
\`\`\`ts
const myShape = new Rectangle({
point: [100, 100],
size: [200, 200],
style: {
color: ColorStyle.Blue,
size: SizeStyle.Large,
dash: DashStyle.Dotted,
},
})
\`\`\`
Once you've created a shape, you can set its properties like this:
\`\`\`ts
const myShape = new Rectangle()
myShape.x = 100
myShape.color = ColorStyle.Red
\`\`\`
You can find more information on each shape class by clicking its name in the list above.
## Controls
In addition to shapes, you can also use code to create controls.
\`\`\`ts
new NumberControl({
label: 'x',
value: 0,
})
const myShape = new Rectangle({
point: [controls.x, 0],
})
\`\`\`
Once you've created a control, the app's will display a panel where you can edit the control's value. As you edit the value, your code will run again with the control's new value.
There are two kinds of controls:
- NumberControl
- VectorControl
- TextControl
Each of these controls is a \`class\`. To create the control, use the following syntax:
\`\`\`ts
const control = new TextControl({
label: 'myLabel',
value: 'my value',
})
\`\`\`
Once you've created a control, you can use its value in your code like this:
\`\`\`ts
const myShape = new Text({
text: controls.myLabel,
})
\`\`\`
You can find more information on each control class by clicking its name in the list above.
## Shape Classes
...
## Control Classes
...
`,
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,165 +0,0 @@
import state, { useSelector } from 'state'
import styled from 'styles'
import {
ControlType,
NumberCodeControl,
TextCodeControl,
VectorCodeControl,
} from 'types'
export default function Control({ id }: { id: string }): JSX.Element {
const control = useSelector((s) => s.data.codeControls[id])
if (!control) return null
return (
<>
<label>{control.label}</label>
{(() => {
switch (control.type) {
case ControlType.Number:
return <NumberControl {...control} />
case ControlType.Vector:
return <VectorControl {...control} />
case ControlType.Text:
return <TextControl {...control} />
}
})()}
</>
)
}
function NumberControl({ id, min, max, step, value }: NumberCodeControl) {
return (
<Inputs>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) =>
state.send('CHANGED_CODE_CONTROL', {
[id]: Number(e.currentTarget.value),
})
}
/>
<input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(e) =>
state.send('CHANGED_CODE_CONTROL', {
[id]: Number(e.currentTarget.value),
})
}
/>
</Inputs>
)
}
function VectorControl({
id,
value,
min = -Infinity,
max = Infinity,
step = 0.01,
isNormalized = false,
}: VectorCodeControl) {
return (
<Inputs>
<input
type="range"
min={isNormalized ? -1 : min}
max={isNormalized ? 1 : max}
step={step}
value={value[0]}
onChange={(e) =>
state.send('CHANGED_CODE_CONTROL', {
[id]: [Number(e.currentTarget.value), value[1]],
})
}
/>
<input
type="number"
min={isNormalized ? -1 : min}
max={isNormalized ? 1 : max}
step={step}
value={value[0]}
onChange={(e) =>
state.send('CHANGED_CODE_CONTROL', {
[id]: [Number(e.currentTarget.value), value[1]],
})
}
/>
<input
type="range"
min={isNormalized ? -1 : min}
max={isNormalized ? 1 : max}
step={step}
value={value[1]}
onChange={(e) =>
state.send('CHANGED_CODE_CONTROL', {
[id]: [value[0], Number(e.currentTarget.value)],
})
}
/>
<input
type="number"
min={isNormalized ? -1 : min}
max={isNormalized ? 1 : max}
step={step}
value={value[1]}
onChange={(e) =>
state.send('CHANGED_CODE_CONTROL', {
[id]: [value[0], Number(e.currentTarget.value)],
})
}
/>
</Inputs>
)
}
function TextControl({ id, value }: TextCodeControl) {
return (
<Inputs>
<input
type="text"
value={value}
onChange={(e) =>
state.send('CHANGED_CODE_CONTROL', {
[id]: e.currentTarget.value,
})
}
/>
</Inputs>
)
}
const Inputs = styled('div', {
display: 'flex',
gap: '8px',
height: '100%',
'& input': {
font: '$ui',
width: '64px',
fontSize: '$1',
border: '1px solid $inputBorder',
backgroundColor: '$input',
color: '$text',
height: '100%',
padding: '0px 6px',
},
"& input[type='range']": {
padding: 0,
flexGrow: 2,
},
"& input[type='text']": {
minWidth: 200,
padding: 4,
flexGrow: 2,
},
})

View file

@ -1,76 +0,0 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import styled from 'styles'
import React, { useRef } from 'react'
import state, { useSelector } from 'state'
import { X } from 'react-feather'
import { breakpoints, IconButton } from 'components/shared'
import * as Panel from '../panel'
import Control from './control'
import { deepCompareArrays } from 'utils'
function handleClose() {
state.send('CLOSED_CONTROLS')
}
const stopKeyboardPropagation = (e: KeyboardEvent | React.KeyboardEvent) =>
e.stopPropagation()
export default function ControlPanel(): JSX.Element {
const rContainer = useRef<HTMLDivElement>(null)
const isOpen = useSelector((s) => Object.keys(s.data.codeControls).length > 0)
const codeControls = useSelector(
(state) => Object.keys(state.data.codeControls),
deepCompareArrays
)
if (codeControls.length === 0) {
return null
}
return (
<Panel.Root
ref={rContainer}
dir="ltr"
data-bp-desktop
variant="controls"
isOpen={isOpen}
onKeyDown={stopKeyboardPropagation}
onKeyUp={stopKeyboardPropagation}
>
<Panel.Layout>
<Panel.Header>
<IconButton bp={breakpoints} size="small" onClick={handleClose}>
<X />
</IconButton>
<h3>Controls</h3>
</Panel.Header>
<ControlsList>
{codeControls.map((id) => (
<Control key={id} id={id} />
))}
</ControlsList>
</Panel.Layout>
</Panel.Root>
)
}
const ControlsList = styled(Panel.Content, {
padding: 12,
display: 'grid',
gridTemplateColumns: '1fr 4fr',
gridAutoRows: '24px',
alignItems: 'center',
gridColumnGap: '8px',
gridRowGap: '8px',
'& input': {
font: '$ui',
fontSize: '$1',
border: '1px solid $inputBorder',
backgroundColor: '$input',
color: '$text',
height: '100%',
padding: '0px 6px',
},
})

View file

@ -1,224 +0,0 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import styled from 'styles'
import React, { useRef } from 'react'
import state, { useSelector } from 'state'
import * as Panel from 'components/panel'
import {
breakpoints,
IconButton,
RowButton,
IconWrapper,
} from 'components/shared'
import {
Cross2Icon,
PlayIcon,
DotIcon,
CrumpledPaperIcon,
StopIcon,
ClipboardIcon,
ClipboardCopyIcon,
TrashIcon,
} from '@radix-ui/react-icons'
import logger from 'state/logger'
import { useStateDesigner } from '@state-designer/react'
const stopPropagation = (e: React.KeyboardEvent) => e.stopPropagation()
const toggleDebugPanel = () => state.send('TOGGLED_DEBUG_PANEL')
const handleStateCopy = () => state.send('COPIED_STATE_TO_CLIPBOARD')
const handleError = () => {
throw Error('Error!')
}
export default function CodePanel(): JSX.Element {
const rContainer = useRef<HTMLDivElement>(null)
const isDebugging = useSelector((s) => s.data.settings.isDebugMode)
const isOpen = useSelector((s) => s.data.settings.isDebugOpen)
const rTextArea = useRef<HTMLTextAreaElement>(null)
const local = useStateDesigner({
initial: 'stopped',
data: {
log: '',
},
states: {
stopped: {
on: {
CHANGED_LOG: 'setLog',
COPIED_LOG: { if: 'hasLog', do: 'copyLog' },
PLAYED_BACK_LOG: { if: 'hasLog', do: 'playbackLog' },
STARTED_LOGGING: { do: 'startLogger', to: 'logging' },
},
},
logging: {
on: {
STOPPED_LOGGING: { do: 'stopLogger', to: 'stopped' },
},
},
},
conditions: {
hasLog(data) {
return data.log !== ''
},
},
actions: {
setLog(data, payload: { value: string }) {
data.log = payload.value
},
startLogger(data) {
logger.start(state.data)
data.log = ''
},
stopLogger(data) {
logger.stop(state.data)
data.log = logger.copyToJson()
},
playbackLog(data) {
logger.playback(state.data, data.log)
},
copyLog() {
logger.copyToJson()
},
},
})
if (!isDebugging) return null
const handleLoggingStop = () => local.send('STOPPED_LOGGING')
const handlePlayback = () =>
local.send('PLAYED_BACK_LOG', { log: rTextArea.current?.value })
const handleLoggingStart = () => local.send('STARTED_LOGGING')
const handleLoggingCopy = () => local.send('COPIED_DEBUG_LOG')
return (
<StylePanelRoot
dir="ltr"
bp={breakpoints}
data-bp-desktop
ref={rContainer}
variant="code"
onWheel={(e) => e.stopPropagation()}
>
{isOpen ? (
<Panel.Layout onKeyDown={stopPropagation}>
<Panel.Header side="left">
<IconButton
bp={breakpoints}
size="small"
onClick={toggleDebugPanel}
>
<Cross2Icon />
</IconButton>
<span>Debugging</span>
<div />
</Panel.Header>
<Panel.Content>
<hr />
<RowButton bp={breakpoints} onClick={handleStateCopy}>
<span>Copy State</span>
<IconWrapper size="small">
<ClipboardCopyIcon />
</IconWrapper>
</RowButton>
<RowButton bp={breakpoints} onClick={handleError}>
<span>Create Error</span>
<IconWrapper size="small">
<TrashIcon />
</IconWrapper>
</RowButton>
<hr />
{local.isIn('stopped') ? (
<RowButton bp={breakpoints} onClick={handleLoggingStart}>
<span>Start Logger</span>
<IconWrapper size="small">
<DotIcon />
</IconWrapper>
</RowButton>
) : (
<RowButton bp={breakpoints} onClick={handleLoggingStop}>
<span>Stop Logger</span>
<IconWrapper size="small">
<StopIcon />
</IconWrapper>
</RowButton>
)}
<JSONTextAreaWrapper>
<IconButton
bp={breakpoints}
onClick={handleLoggingCopy}
disabled={!local.can('COPIED_LOG')}
style={{ position: 'absolute', top: 2, right: 2 }}
>
<ClipboardIcon />
</IconButton>
<JSONTextArea
ref={rTextArea}
value={local.data.log}
onChange={(e) =>
local.send('CHANGED_LOG', { value: e.currentTarget.value })
}
/>
</JSONTextAreaWrapper>
<RowButton
bp={breakpoints}
onClick={handlePlayback}
disabled={!local.can('PLAYED_BACK_LOG')}
>
<span>Play Back Log</span>
<IconWrapper size="small">
<PlayIcon />
</IconWrapper>
</RowButton>
</Panel.Content>
</Panel.Layout>
) : (
<IconButton bp={breakpoints} size="small" onClick={toggleDebugPanel}>
<CrumpledPaperIcon />
</IconButton>
)}
</StylePanelRoot>
)
}
const StylePanelRoot = styled(Panel.Root, {
width: 'fit-content',
maxWidth: 'fit-content',
overflow: 'hidden',
position: 'relative',
border: '1px solid $panel',
boxShadow: '$4',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pointerEvents: 'all',
padding: '$0',
'& hr': {
marginTop: 2,
marginBottom: 2,
marginLeft: '-$0',
border: 'none',
height: 1,
backgroundColor: '$brushFill',
width: 'calc(100% + 4px)',
},
})
const JSONTextAreaWrapper = styled('div', {
position: 'relative',
margin: '4px 0',
})
const JSONTextArea = styled('textarea', {
minHeight: '100px',
width: '100%',
font: '$mono',
backgroundColor: '$panel',
border: '1px solid $border',
borderRadius: '4px',
padding: '4px',
outline: 'none',
})

View file

@ -1,66 +0,0 @@
import useKeyboardEvents from 'hooks/useKeyboardEvents'
import useLoadOnMount from 'hooks/useLoadOnMount'
import useStateTheme from 'hooks/useStateTheme'
import Menu from './menu/menu'
import Canvas from './canvas/canvas'
import ToolsPanel from './tools-panel/tools-panel'
import StylePanel from './style-panel/style-panel'
import styled from 'styles'
import PagePanel from './page-panel/page-panel'
import CodePanel from './code-panel/code-panel'
import DebugPanel from './debug-panel/debug-panel'
import ControlsPanel from './controls-panel/controls-panel'
export default function Editor({ roomId }: { roomId?: string }): JSX.Element {
useLoadOnMount(roomId)
useStateTheme()
useKeyboardEvents()
return (
<Layout>
<MenuButtons>
<Menu />
<DebugPanel />
<CodePanel />
<PagePanel />
</MenuButtons>
<ControlsPanel />
<Spacer />
<StylePanel />
<Canvas />
<ToolsPanel />
</Layout>
)
}
const Spacer = styled('div', {
flexGrow: 2,
})
const MenuButtons = styled('div', {
display: 'flex',
gap: 8,
})
const Layout = styled('main', {
position: 'fixed',
overflow: 'hidden',
top: 0,
left: 0,
bottom: 0,
right: 0,
height: '100%',
width: '100%',
padding: '8px 8px 0 8px',
zIndex: 200,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-start',
boxSizing: 'border-box',
outline: 'none',
pointerEvents: 'none',
'& > *': {
PointerEvent: 'all',
},
})

View file

@ -1,135 +0,0 @@
import * as Dialog from '@radix-ui/react-alert-dialog'
import { MixerVerticalIcon } from '@radix-ui/react-icons'
import {
breakpoints,
IconButton,
DialogOverlay,
DialogContent,
RowButton,
MenuTextInput,
DialogInputWrapper,
Divider,
} from 'components/shared'
import { useEffect, useRef, useState } from 'react'
import state, { useSelector } from 'state'
import { Page } from 'types'
export default function PageOptions({ page }: { page: Page }): JSX.Element {
const [isOpen, setIsOpen] = useState(false)
const rInput = useRef<HTMLInputElement>(null)
const hasOnlyOnePage = useSelector(
(s) => Object.keys(s.data.document.pages).length <= 1
)
const [name, setName] = useState(page.name)
function handleNameChange(e: React.ChangeEvent<HTMLInputElement>) {
setName(e.currentTarget.value)
}
function handleDuplicate() {
state.send('DUPLICATED_PAGE', { id: page.id })
}
function handleDelete() {
state.send('DELETED_PAGE', { id: page.id })
}
function handleOpenChange(isOpen: boolean) {
setIsOpen(isOpen)
if (isOpen) {
return
}
if (page.name.length === 0) {
state.send('RENAMED_PAGE', {
id: page.id,
name: 'Page',
})
}
state.send('SAVED_PAGE_RENAME', { id: page.id })
}
function handleSave() {
state.send('RENAMED_PAGE', {
id: page.id,
name,
})
}
function stopPropagation(e: React.KeyboardEvent<HTMLDivElement>) {
e.stopPropagation()
}
function handleKeydown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key === 'Enter') {
handleSave()
setIsOpen(false)
}
}
useEffect(() => {
if (isOpen) {
setTimeout(() => {
rInput.current?.focus()
rInput.current?.select()
}, 0)
}
}, [isOpen])
return (
<Dialog.Root open={isOpen} onOpenChange={handleOpenChange}>
<Dialog.Trigger
as={IconButton}
bp={breakpoints}
size="small"
data-shy="true"
>
<MixerVerticalIcon />
</Dialog.Trigger>
<Dialog.Overlay as={DialogOverlay} />
<Dialog.Content
as={DialogContent}
onKeyDown={stopPropagation}
onKeyUp={stopPropagation}
>
<DialogInputWrapper>
<MenuTextInput
ref={rInput}
value={name}
onChange={handleNameChange}
onKeyDown={handleKeydown}
/>
</DialogInputWrapper>
<Divider />
<Dialog.Action
as={RowButton}
bp={breakpoints}
onClick={handleDuplicate}
>
Duplicate
</Dialog.Action>
<Dialog.Action
as={RowButton}
bp={breakpoints}
disabled={hasOnlyOnePage}
onClick={handleDelete}
warn={true}
>
Delete
</Dialog.Action>
<Divider />
<Dialog.Action as={RowButton} bp={breakpoints} onClick={handleSave}>
Save
</Dialog.Action>
<Dialog.Cancel as={RowButton} bp={breakpoints}>
Cancel
</Dialog.Cancel>
</Dialog.Content>
</Dialog.Root>
)
}

View file

@ -1,106 +0,0 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import styled from 'styles'
import {
breakpoints,
DropdownMenuButton,
DropdownMenuDivider,
RowButton,
MenuContent,
FloatingContainer,
IconWrapper,
} from 'components/shared'
import PageOptions from './page-options'
import { PlusIcon, CheckIcon } from '@radix-ui/react-icons'
import state, { useSelector } from 'state'
import { useEffect, useRef, useState } from 'react'
function handleCreatePage() {
state.send('CREATED_PAGE')
}
export default function PagePanel(): JSX.Element {
const rIsOpen = useRef(false)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
if (rIsOpen.current !== isOpen) {
rIsOpen.current = isOpen
}
}, [isOpen])
const documentPages = useSelector((s) => s.data.document.pages)
const currentPageId = useSelector((s) => s.data.currentPageId)
if (!documentPages[currentPageId]) return null
const sorted = Object.values(documentPages).sort(
(a, b) => a.childIndex - b.childIndex
)
return (
<DropdownMenu.Root
dir="ltr"
open={isOpen}
onOpenChange={(isOpen) => {
if (rIsOpen.current !== isOpen) {
setIsOpen(isOpen)
}
}}
>
<FloatingContainer>
<RowButton as={DropdownMenu.Trigger} bp={breakpoints} variant="noIcon">
<span>{documentPages[currentPageId].name}</span>
</RowButton>
</FloatingContainer>
<MenuContent as={DropdownMenu.Content} sideOffset={8} align="start">
<DropdownMenu.RadioGroup
value={currentPageId}
onValueChange={(id) => {
setIsOpen(false)
state.send('CHANGED_PAGE', { id })
}}
>
{sorted.map((page) => (
<ButtonWithOptions key={page.id}>
<DropdownMenu.RadioItem
as={RowButton}
bp={breakpoints}
value={page.id}
variant="pageButton"
>
<span>{page.name}</span>
<DropdownMenu.ItemIndicator>
<IconWrapper size="small">
<CheckIcon />
</IconWrapper>
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
<PageOptions page={page} />
</ButtonWithOptions>
))}
</DropdownMenu.RadioGroup>
<DropdownMenuDivider />
<DropdownMenuButton onSelect={handleCreatePage}>
<span>Create Page</span>
<IconWrapper size="small">
<PlusIcon />
</IconWrapper>
</DropdownMenuButton>
</MenuContent>
</DropdownMenu.Root>
)
}
const ButtonWithOptions = styled('div', {
display: 'grid',
gridTemplateColumns: '1fr auto',
gridAutoFlow: 'column',
'& > *[data-shy="true"]': {
opacity: 0,
},
'&:hover > *[data-shy="true"]': {
opacity: 1,
},
})

View file

@ -1,136 +0,0 @@
import styled from 'styles'
export const Root = styled('div', {
position: 'relative',
backgroundColor: '$panel',
borderRadius: '4px',
overflow: 'hidden',
pointerEvents: 'all',
userSelect: 'none',
zIndex: 200,
border: '1px solid $panel',
boxShadow: '$4',
font: '$ui',
variants: {
bp: {
mobile: {},
small: {},
},
variant: {
code: {},
controls: {
position: 'absolute',
right: 156,
},
},
isOpen: {
true: {},
false: {
padding: '$0',
height: 38,
width: 38,
},
},
},
compoundVariants: [
{
isOpen: true,
variant: 'code',
css: {
position: 'absolute',
top: 8,
left: 8,
right: 8,
bottom: 48,
maxWidth: 680,
zIndex: 1000,
},
},
{
isOpen: true,
variant: 'code',
bp: 'small',
css: {
position: 'absolute',
top: 8,
left: 8,
right: 8,
bottom: 128,
maxWidth: 720,
zIndex: 1000,
},
},
],
})
export const Layout = styled('div', {
display: 'grid',
gridTemplateColumns: '1fr',
gridTemplateRows: 'auto 1fr',
gridAutoRows: '28px',
height: '100%',
width: 'auto',
minWidth: '100%',
maxWidth: 560,
overflow: 'hidden',
userSelect: 'none',
pointerEvents: 'all',
})
export const Header = styled('div', {
pointerEvents: 'all',
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
padding: '$0',
position: 'relative',
'& h3': {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
textAlign: 'center',
padding: 0,
margin: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '13px',
pointerEvents: 'none',
userSelect: 'none',
},
variants: {
side: {
left: {
flexDirection: 'row',
},
right: {
flexDirection: 'row-reverse',
},
},
},
})
export const ButtonsGroup = styled('div', {
display: 'flex',
})
export const Content = styled('div', {
position: 'relative',
pointerEvents: 'all',
overflowY: 'scroll',
})
export const Footer = styled('div', {
overflowX: 'scroll',
color: '$text',
font: '$debug',
padding: '0 12px',
display: 'flex',
alignItems: 'center',
})

View file

@ -1,727 +0,0 @@
import React from 'react'
import Tooltip from 'components/tooltip'
import * as ContextMenu from '@radix-ui/react-context-menu'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import * as RadioGroup from '@radix-ui/react-radio-group'
import * as Panel from './panel'
import styled from 'styles'
import { CheckIcon, ChevronRightIcon } from '@radix-ui/react-icons'
import { isMobile } from 'utils'
export const breakpoints: any = { '@initial': 'mobile', '@sm': 'small' }
export const IconButton = styled('button', {
position: 'relative',
height: '32px',
width: '32px',
backgroundColor: '$panel',
borderRadius: '4px',
padding: '0',
margin: '0',
display: 'grid',
alignItems: 'center',
justifyContent: 'center',
outline: 'none',
border: 'none',
pointerEvents: 'all',
fontSize: '$0',
color: '$text',
cursor: 'pointer',
'& > *': {
gridRow: 1,
gridColumn: 1,
},
'&:disabled': {
opacity: '0.5',
},
'& > span': {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
},
variants: {
bp: {
mobile: {
backgroundColor: 'transparent',
},
small: {
'&:hover:not(:disabled)': {
backgroundColor: '$hover',
},
},
},
size: {
small: {
height: 32,
width: 32,
'& svg:nth-of-type(1)': {
height: '16px',
width: '16px',
},
},
medium: {
height: 44,
width: 44,
'& svg:nth-of-type(1)': {
height: '18px',
width: '18px',
},
},
large: {
height: 44,
width: 44,
'& svg:nth-of-type(1)': {
height: '20px',
width: '20px',
},
},
},
isActive: {
true: {
color: '$selected',
},
},
},
})
export const RowButton = styled('button', {
position: 'relative',
display: 'flex',
width: '100%',
background: 'none',
height: '32px',
border: 'none',
cursor: 'pointer',
color: '$text',
outline: 'none',
alignItems: 'center',
fontFamily: '$ui',
fontWeight: 400,
fontSize: '$1',
justifyContent: 'space-between',
padding: '4px 8px 4px 12px',
borderRadius: 4,
userSelect: 'none',
'& label': {
fontWeight: '$1',
margin: 0,
padding: 0,
},
'& svg': {
position: 'relative',
stroke: '$overlay',
strokeWidth: 1,
zIndex: 1,
},
'&[data-disabled]': {
opacity: 0.3,
},
'&:disabled': {
opacity: 0.3,
},
variants: {
bp: {
mobile: {},
small: {
'& *[data-shy="true"]': {
opacity: 0,
},
'&:hover:not(:disabled)': {
backgroundColor: '$hover',
'& *[data-shy="true"]': {
opacity: 1,
},
},
},
},
size: {
icon: {
padding: '4px ',
width: 'auto',
},
},
variant: {
noIcon: {
padding: '4px 12px',
},
pageButton: {
display: 'grid',
gridTemplateColumns: '24px auto',
width: '100%',
paddingLeft: '$1',
gap: '$3',
justifyContent: 'flex-start',
[`& > *[data-state="checked"]`]: {
gridRow: 1,
gridColumn: 1,
},
'& > span': {
gridRow: 1,
gridColumn: 2,
width: '100%',
},
},
},
warn: {
true: {
color: '$warn',
},
},
isActive: {
true: {
backgroundColor: '$hover',
},
},
},
})
export const StylePanelRoot = styled(Panel.Root, {
minWidth: 1,
width: 184,
maxWidth: 184,
overflow: 'hidden',
position: 'relative',
border: '1px solid $panel',
boxShadow: '$4',
variants: {
isOpen: {
true: {},
false: {
padding: '$0',
height: 38,
width: 38,
},
},
},
})
export const Group = styled(RadioGroup.Root, {
display: 'flex',
})
export const ShortcutKey = styled('span', {
fontSize: '$0',
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '1px 1px 0px rgba(0,0,0,.5)',
})
export const IconWrapper = styled('div', {
height: '100%',
borderRadius: '4px',
marginRight: '1px',
display: 'grid',
alignItems: 'center',
justifyContent: 'center',
outline: 'none',
border: 'none',
pointerEvents: 'all',
cursor: 'pointer',
color: '$text',
'& svg': {
height: 22,
width: 22,
strokeWidth: 1,
},
'& > *': {
gridRow: 1,
gridColumn: 1,
},
variants: {
size: {
small: {
'& svg': {
height: '16px',
width: '16px',
},
},
medium: {
'& svg': {
height: '22px',
width: '22px',
},
},
},
},
})
export const ButtonsRow = styled('div', {
position: 'relative',
display: 'flex',
width: '100%',
background: 'none',
border: 'none',
cursor: 'pointer',
outline: 'none',
alignItems: 'center',
justifyContent: 'flex-start',
padding: 0,
})
export const VerticalDivider = styled('hr', {
width: '1px',
margin: '-2px 3px',
border: 'none',
backgroundColor: '$brushFill',
})
export const FloatingContainer = styled('div', {
backgroundColor: '$panel',
border: '1px solid $panel',
borderRadius: '4px',
boxShadow: '$4',
display: 'flex',
height: 'fit-content',
padding: '$0',
pointerEvents: 'all',
position: 'relative',
userSelect: 'none',
zIndex: 200,
variants: {
direction: {
row: {
flexDirection: 'row',
},
column: {
flexDirection: 'column',
},
},
elevation: {
0: {
boxShadow: 'none',
},
2: {
boxShadow: '$2',
},
3: {
boxShadow: '$3',
},
4: {
boxShadow: '$4',
},
},
},
})
export const StyledKbd = styled('kbd', {
marginLeft: '32px',
fontSize: '$1',
fontFamily: '$ui',
fontWeight: 400,
'& > span': {
display: 'inline-block',
width: '12px',
},
})
export function Kbd({ children }: { children: React.ReactNode }): JSX.Element {
if (isMobile()) return null
return <StyledKbd>{children}</StyledKbd>
}
/* -------------------------------------------------- */
/* Dialog */
/* -------------------------------------------------- */
export const DialogContent = styled('div', {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
minWidth: 240,
maxWidth: 'fit-content',
maxHeight: '85vh',
marginTop: '-5vh',
pointerEvents: 'all',
backgroundColor: '$panel',
border: '1px solid $panel',
padding: '$0',
boxShadow: '$4',
borderRadius: '4px',
font: '$ui',
'&:focus': {
outline: 'none',
},
})
export const DialogOverlay = styled('div', {
backgroundColor: 'rgba(0, 0, 0, .15)',
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
left: 0,
})
export const DialogInputWrapper = styled('div', {
padding: '$4 $2',
})
export const DialogTitleRow = styled('div', {
display: 'flex',
padding: '0 0 0 $4',
alignItems: 'center',
justifyContent: 'space-between',
h3: {
fontSize: '$1',
},
})
/* -------------------------------------------------- */
/* Menus */
/* -------------------------------------------------- */
export const MenuContent = styled('div', {
position: 'relative',
overflow: 'hidden',
userSelect: 'none',
zIndex: 180,
minWidth: 180,
pointerEvents: 'all',
backgroundColor: '$panel',
border: '1px solid $panel',
padding: '$0',
boxShadow: '$4',
borderRadius: '4px',
font: '$ui',
})
export const Divider = styled('div', {
backgroundColor: '$hover',
height: 1,
marginTop: '$2',
marginRight: '-$2',
marginBottom: '$2',
marginLeft: '-$2',
})
export function MenuButton({
warn,
onSelect,
children,
disabled = false,
}: {
warn?: boolean
onSelect?: () => void
disabled?: boolean
children: React.ReactNode
}): JSX.Element {
return (
<RowButton
bp={breakpoints}
disabled={disabled}
warn={warn}
onSelect={onSelect}
>
{children}
</RowButton>
)
}
export const MenuTextInput = styled('input', {
backgroundColor: '$panel',
border: 'none',
padding: '$4 $3',
width: '100%',
outline: 'none',
background: '$input',
borderRadius: '4px',
font: '$ui',
fontSize: '$1',
})
/* -------------------------------------------------- */
/* Dropdown Menu */
/* -------------------------------------------------- */
export function DropdownMenuRoot({
isOpen,
onOpenChange,
children,
}: {
isOpen?: boolean
onOpenChange?: (isOpen: boolean) => void
children: React.ReactNode
}): JSX.Element {
return (
<DropdownMenu.Root dir="ltr" open={isOpen} onOpenChange={onOpenChange}>
{children}
</DropdownMenu.Root>
)
}
export function DropdownMenuSubMenu({
children,
disabled = false,
label,
}: {
label: string
disabled?: boolean
children: React.ReactNode
}): JSX.Element {
return (
<DropdownMenu.Root dir="ltr">
<DropdownMenu.TriggerItem
as={RowButton}
bp={breakpoints}
disabled={disabled}
>
<span>{label}</span>
<IconWrapper size="small">
<ChevronRightIcon />
</IconWrapper>
</DropdownMenu.TriggerItem>
<DropdownMenu.Content as={MenuContent} sideOffset={2} alignOffset={-2}>
{children}
<DropdownMenuArrow offset={13} />
</DropdownMenu.Content>
</DropdownMenu.Root>
)
}
export const DropdownMenuDivider = styled(DropdownMenu.Separator, {
backgroundColor: '$hover',
height: 1,
marginTop: '$2',
marginRight: '-$2',
marginBottom: '$2',
marginLeft: '-$2',
})
export const DropdownMenuArrow = styled(DropdownMenu.Arrow, {
fill: '$panel',
})
export function DropdownMenuButton({
onSelect,
children,
disabled = false,
}: {
onSelect?: () => void
disabled?: boolean
children: React.ReactNode
}): JSX.Element {
return (
<DropdownMenu.Item
as={RowButton}
bp={breakpoints}
disabled={disabled}
onSelect={onSelect}
>
{children}
</DropdownMenu.Item>
)
}
interface DropdownMenuIconButtonProps {
onSelect: () => void
disabled?: boolean
children: React.ReactNode
}
export function DropdownMenuIconButton({
onSelect,
children,
disabled = false,
}: DropdownMenuIconButtonProps): JSX.Element {
return (
<DropdownMenu.Item
as={IconButton}
bp={breakpoints}
disabled={disabled}
onSelect={onSelect}
>
{children}
</DropdownMenu.Item>
)
}
interface DropdownMenuIconTriggerButtonProps {
label: string
kbd?: string
disabled?: boolean
children: React.ReactNode
}
export function DropdownMenuIconTriggerButton({
label,
kbd,
children,
disabled = false,
}: DropdownMenuIconTriggerButtonProps): JSX.Element {
return (
<DropdownMenu.Trigger as={IconButton} bp={breakpoints} disabled={disabled}>
<Tooltip label={label} kbd={kbd}>
{children}
</Tooltip>
</DropdownMenu.Trigger>
)
}
interface MenuCheckboxItemProps {
checked: boolean
disabled?: boolean
onCheckedChange: (isChecked: boolean) => void
children: React.ReactNode
}
export function DropdownMenuCheckboxItem({
checked,
disabled = false,
onCheckedChange,
children,
}: MenuCheckboxItemProps): JSX.Element {
return (
<DropdownMenu.CheckboxItem
as={RowButton}
bp={breakpoints}
onCheckedChange={onCheckedChange}
checked={checked}
disabled={disabled}
>
{children}
<DropdownMenu.ItemIndicator>
<IconWrapper size="small">
<CheckIcon />
</IconWrapper>
</DropdownMenu.ItemIndicator>
</DropdownMenu.CheckboxItem>
)
}
/* -------------------------------------------------- */
/* Context Menu */
/* -------------------------------------------------- */
export function ContextMenuRoot({
onOpenChange,
children,
}: {
onOpenChange?: (isOpen: boolean) => void
children: React.ReactNode
}): JSX.Element {
return (
<ContextMenu.Root dir="ltr" onOpenChange={onOpenChange}>
{children}
</ContextMenu.Root>
)
}
export function ContextMenuSubMenu({
children,
label,
}: {
label: string
children: React.ReactNode
}): JSX.Element {
return (
<ContextMenu.Root dir="ltr">
<ContextMenu.TriggerItem as={RowButton} bp={breakpoints}>
<span>{label}</span>
<IconWrapper size="small">
<ChevronRightIcon />
</IconWrapper>
</ContextMenu.TriggerItem>
<ContextMenu.Content as={MenuContent} sideOffset={2} alignOffset={-2}>
{children}
<ContextMenuArrow offset={13} />
</ContextMenu.Content>
</ContextMenu.Root>
)
}
export const ContextMenuDivider = styled(ContextMenu.Separator, {
backgroundColor: '$hover',
height: 1,
margin: '$2 -$2',
})
export const ContextMenuArrow = styled(ContextMenu.Arrow, {
fill: '$panel',
})
export function ContextMenuButton({
onSelect,
children,
disabled = false,
}: {
onSelect?: () => void
disabled?: boolean
children: React.ReactNode
}): JSX.Element {
return (
<RowButton
as={ContextMenu.Item}
bp={breakpoints}
disabled={disabled}
onSelect={onSelect}
>
{children}
</RowButton>
)
}
export function ContextMenuIconButton({
onSelect,
children,
disabled = false,
}: {
onSelect: () => void
disabled?: boolean
children: React.ReactNode
}): JSX.Element {
return (
<ContextMenu.Item
as={IconButton}
bp={breakpoints}
disabled={disabled}
onSelect={onSelect}
>
{children}
</ContextMenu.Item>
)
}
export function ContextMenuCheckboxItem({
checked,
disabled = false,
onCheckedChange,
children,
}: MenuCheckboxItemProps): JSX.Element {
return (
<ContextMenu.CheckboxItem
as={RowButton}
bp={breakpoints}
onCheckedChange={onCheckedChange}
checked={checked}
disabled={disabled}
>
{children}
<ContextMenu.ItemIndicator>
<IconWrapper size="small">
<CheckIcon />
</IconWrapper>
</ContextMenu.ItemIndicator>
</ContextMenu.CheckboxItem>
)
}

View file

@ -1,60 +0,0 @@
import { useStateDesigner } from '@state-designer/react'
import state from 'state'
import styled from 'styles'
const size: any = { '@sm': 'small' }
export default function StatusBar(): JSX.Element {
const local = useStateDesigner(state)
const shapesInView = state.values.shapesToRender.length
const active = local.active
.slice(1)
.map((s) => {
const states = s.split('.')
return states[states.length - 1]
})
.join(' | ')
const log = local.log[0]
if (process.env.NODE_ENV !== 'development') return null
return (
<StatusBarContainer size={size}>
<Section>
{active} - {log}
</Section>
<Section>{shapesInView || '0'} Shapes</Section>
</StatusBarContainer>
)
}
const StatusBarContainer = styled('div', {
height: 40,
userSelect: 'none',
borderTop: '1px solid $border',
gridArea: 'status',
display: 'flex',
color: '$text',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '$panel',
gap: 8,
fontSize: '$0',
padding: '0 16px',
variants: {
size: {
small: {
fontSize: '$1',
},
},
},
})
const Section = styled('div', {
whiteSpace: 'nowrap',
overflow: 'hidden',
})

View file

@ -1,155 +0,0 @@
import {
AlignBottomIcon,
AlignCenterHorizontallyIcon,
AlignCenterVerticallyIcon,
AlignLeftIcon,
AlignRightIcon,
AlignTopIcon,
SpaceEvenlyHorizontallyIcon,
SpaceEvenlyVerticallyIcon,
StretchHorizontallyIcon,
StretchVerticallyIcon,
} from '@radix-ui/react-icons'
import { breakpoints, ButtonsRow, IconButton } from 'components/shared'
import { memo } from 'react'
import state from 'state'
import { AlignType, DistributeType, StretchType } from 'types'
function alignTop() {
state.send('ALIGNED', { type: AlignType.Top })
}
function alignCenterVertical() {
state.send('ALIGNED', { type: AlignType.CenterVertical })
}
function alignBottom() {
state.send('ALIGNED', { type: AlignType.Bottom })
}
function stretchVertically() {
state.send('STRETCHED', { type: StretchType.Vertical })
}
function distributeVertically() {
state.send('DISTRIBUTED', { type: DistributeType.Vertical })
}
function alignLeft() {
state.send('ALIGNED', { type: AlignType.Left })
}
function alignCenterHorizontal() {
state.send('ALIGNED', { type: AlignType.CenterHorizontal })
}
function alignRight() {
state.send('ALIGNED', { type: AlignType.Right })
}
function stretchHorizontally() {
state.send('STRETCHED', { type: StretchType.Horizontal })
}
function distributeHorizontally() {
state.send('DISTRIBUTED', { type: DistributeType.Horizontal })
}
function AlignDistribute({
hasTwoOrMore,
hasThreeOrMore,
}: {
hasTwoOrMore: boolean
hasThreeOrMore: boolean
}): JSX.Element {
return (
<>
<ButtonsRow>
<IconButton
bp={breakpoints}
size="small"
disabled={!hasTwoOrMore}
onClick={alignLeft}
>
<AlignLeftIcon />
</IconButton>
<IconButton
bp={breakpoints}
size="small"
disabled={!hasTwoOrMore}
onClick={alignCenterHorizontal}
>
<AlignCenterHorizontallyIcon />
</IconButton>
<IconButton
bp={breakpoints}
size="small"
disabled={!hasTwoOrMore}
onClick={alignRight}
>
<AlignRightIcon />
</IconButton>
<IconButton
bp={breakpoints}
size="small"
disabled={!hasTwoOrMore}
onClick={stretchHorizontally}
>
<StretchHorizontallyIcon />
</IconButton>
<IconButton
bp={breakpoints}
size="small"
disabled={!hasThreeOrMore}
onClick={distributeHorizontally}
>
<SpaceEvenlyHorizontallyIcon />
</IconButton>
</ButtonsRow>
<ButtonsRow>
<IconButton
bp={breakpoints}
size="small"
disabled={!hasTwoOrMore}
onClick={alignTop}
>
<AlignTopIcon />
</IconButton>
<IconButton
bp={breakpoints}
size="small"
disabled={!hasTwoOrMore}
onClick={alignCenterVertical}
>
<AlignCenterVerticallyIcon />
</IconButton>
<IconButton
bp={breakpoints}
size="small"
disabled={!hasTwoOrMore}
onClick={alignBottom}
>
<AlignBottomIcon />
</IconButton>
<IconButton
bp={breakpoints}
size="small"
disabled={!hasTwoOrMore}
onClick={stretchVertically}
>
<StretchVerticallyIcon />
</IconButton>
<IconButton
bp={breakpoints}
size="small"
disabled={!hasThreeOrMore}
onClick={distributeVertically}
>
<SpaceEvenlyVerticallyIcon />
</IconButton>
</ButtonsRow>
</>
)
}
export default memo(AlignDistribute)

View file

@ -1,43 +0,0 @@
import * as Checkbox from '@radix-ui/react-checkbox'
import tld from 'utils/tld'
import { breakpoints, IconButton, IconWrapper } from '../shared'
import { BoxIcon, IsFilledFillIcon } from './shared'
import state, { useSelector } from 'state'
import { getShapeUtils } from 'state/shape-utils'
import Tooltip from 'components/tooltip'
function handleIsFilledChange(isFilled: boolean) {
state.send('CHANGED_STYLE', { isFilled })
}
export default function IsFilledPicker(): JSX.Element {
const isFilled = useSelector((s) => s.values.selectedStyle.isFilled)
const canFill = useSelector((s) => {
const selectedShapes = tld.getSelectedShapes(s.data)
return (
selectedShapes.length === 0 ||
selectedShapes.some((shape) => getShapeUtils(shape).canStyleFill)
)
})
return (
<Checkbox.Root
dir="ltr"
as={IconButton}
bp={breakpoints}
checked={isFilled}
disabled={!canFill}
onCheckedChange={handleIsFilledChange}
>
<Tooltip label="Fill">
<IconWrapper>
<BoxIcon />
<Checkbox.Indicator>
<IsFilledFillIcon />
</Checkbox.Indicator>
</IconWrapper>
</Tooltip>
</Checkbox.Root>
)
}

View file

@ -1,214 +0,0 @@
import tld from 'utils/tld'
import state, { useSelector } from 'state'
import { IconButton, ButtonsRow, breakpoints } from 'components/shared'
import { memo } from 'react'
import { MoveType, ShapeType } from 'types'
import { Trash2 } from 'react-feather'
import Tooltip from 'components/tooltip'
import {
ArrowDownIcon,
ArrowUpIcon,
AspectRatioIcon,
CopyIcon,
GroupIcon,
LockClosedIcon,
LockOpen1Icon,
PinBottomIcon,
PinTopIcon,
RotateCounterClockwiseIcon,
} from '@radix-ui/react-icons'
import { commandKey } from 'utils'
function handleRotateCcw() {
state.send('ROTATED_CCW')
}
function handleDuplicate() {
state.send('DUPLICATED')
}
function handleGroup() {
state.send('GROUPED')
}
function handleUngroup() {
state.send('UNGROUPED')
}
function handleLock() {
state.send('TOGGLED_SHAPE_LOCK')
}
function handleAspectLock() {
state.send('TOGGLED_SHAPE_ASPECT_LOCK')
}
function handleMoveToBack() {
state.send('MOVED', { type: MoveType.ToBack })
}
function handleMoveBackward() {
state.send('MOVED', { type: MoveType.Backward })
}
function handleMoveForward() {
state.send('MOVED', { type: MoveType.Forward })
}
function handleMoveToFront() {
state.send('MOVED', { type: MoveType.ToFront })
}
function handleDelete() {
state.send('DELETED')
}
function ShapesFunctions() {
const isAllLocked = useSelector((s) => {
const page = tld.getPage(s.data)
return s.values.selectedIds.every((id) => page.shapes[id].isLocked)
})
const isAllAspectLocked = useSelector((s) => {
const page = tld.getPage(s.data)
return s.values.selectedIds.every(
(id) => page.shapes[id].isAspectRatioLocked
)
})
const isAllGrouped = useSelector((s) => {
const selectedShapes = tld.getSelectedShapes(s.data)
return selectedShapes.every(
(shape) =>
shape.type === ShapeType.Group ||
(shape.parentId === selectedShapes[0].parentId &&
selectedShapes[0].parentId !== s.data.currentPageId)
)
})
const hasSelection = useSelector((s) => {
return tld.getSelectedIds(s.data).length > 0
})
const hasMultipleSelection = useSelector((s) => {
return tld.getSelectedIds(s.data).length > 1
})
return (
<>
<ButtonsRow>
<IconButton
bp={breakpoints}
disabled={!hasSelection}
size="small"
onClick={handleDuplicate}
>
<Tooltip label="Duplicate" kbd={`${commandKey()}D`}>
<CopyIcon />
</Tooltip>
</IconButton>
<IconButton
disabled={!hasSelection}
size="small"
onClick={handleRotateCcw}
>
<Tooltip label="Rotate">
<RotateCounterClockwiseIcon />
</Tooltip>
</IconButton>
<IconButton
bp={breakpoints}
disabled={!hasSelection}
size="small"
onClick={handleLock}
>
<Tooltip label="Toogle Locked" kbd={`${commandKey()}L`}>
{isAllLocked ? <LockClosedIcon /> : <LockOpen1Icon opacity={0.4} />}
</Tooltip>
</IconButton>
<IconButton
bp={breakpoints}
disabled={!hasSelection}
size="small"
onClick={handleAspectLock}
>
<Tooltip label="Toogle Aspect Ratio Lock">
<AspectRatioIcon opacity={isAllAspectLocked ? 1 : 0.4} />
</Tooltip>
</IconButton>
<IconButton
bp={breakpoints}
disabled={!isAllGrouped && !hasMultipleSelection}
size="small"
onClick={isAllGrouped ? handleUngroup : handleGroup}
>
<Tooltip label="Group" kbd={`${commandKey()}G`}>
<GroupIcon opacity={isAllGrouped ? 1 : 0.4} />
</Tooltip>
</IconButton>
</ButtonsRow>
<ButtonsRow>
<IconButton
bp={breakpoints}
disabled={!hasSelection}
size="small"
onClick={handleMoveToBack}
>
<Tooltip label="Move to Back" kbd={`${commandKey()}⇧[`}>
<PinBottomIcon />
</Tooltip>
</IconButton>
<IconButton
bp={breakpoints}
disabled={!hasSelection}
size="small"
onClick={handleMoveBackward}
>
<Tooltip label="Move Backward" kbd={`${commandKey()}[`}>
<ArrowDownIcon />
</Tooltip>
</IconButton>
<IconButton
bp={breakpoints}
disabled={!hasSelection}
size="small"
onClick={handleMoveForward}
>
<Tooltip label="Move Forward" kbd={`${commandKey()}]`}>
<ArrowUpIcon />
</Tooltip>
</IconButton>
<IconButton
bp={breakpoints}
disabled={!hasSelection}
size="small"
onClick={handleMoveToFront}
>
<Tooltip label="More to Front" kbd={`${commandKey()}⇧]`}>
<PinTopIcon />
</Tooltip>
</IconButton>
<IconButton
bp={breakpoints}
disabled={!hasSelection}
size="small"
onClick={handleDelete}
>
<Tooltip label="Delete" kbd="⌫">
<Trash2 size="15" />
</Tooltip>
</IconButton>
</ButtonsRow>
</>
)
}
export default memo(ShapesFunctions)

View file

@ -1,101 +0,0 @@
import state, { useSelector } from 'state'
import {
IconButton,
ButtonsRow,
breakpoints,
RowButton,
FloatingContainer,
Divider,
Kbd,
} from 'components/shared'
import ShapesFunctions from './shapes-functions'
import AlignDistribute from './align-distribute'
import QuickColorSelect from './quick-color-select'
import QuickSizeSelect from './quick-size-select'
import QuickDashSelect from './quick-dash-select'
import QuickFillSelect from './quick-fill-select'
import Tooltip from 'components/tooltip'
import { DotsHorizontalIcon, Cross2Icon } from '@radix-ui/react-icons'
import { commandKey, isMobile } from 'utils'
const handleStylePanelOpen = () => state.send('TOGGLED_STYLE_PANEL_OPEN')
const handleCopy = () => state.send('COPIED')
const handlePaste = () => state.send('PASTED')
const handleCopyToSvg = () => state.send('COPIED_TO_SVG')
export default function StylePanel(): JSX.Element {
const isOpen = useSelector((s) => s.data.settings.isStyleOpen)
return (
<FloatingContainer direction="column">
<ButtonsRow>
<QuickColorSelect />
<QuickSizeSelect />
<QuickDashSelect />
<QuickFillSelect />
<IconButton
bp={breakpoints}
title="Style"
size="small"
onPointerDown={handleStylePanelOpen}
>
<Tooltip label={isOpen ? 'Close' : 'More'}>
{isOpen ? <Cross2Icon /> : <DotsHorizontalIcon />}
</Tooltip>
</IconButton>
</ButtonsRow>
{isOpen && <SelectedShapeContent />}
</FloatingContainer>
)
}
function SelectedShapeContent(): JSX.Element {
const selectedShapesCount = useSelector((s) => s.values.selectedIds.length)
const showKbds = !isMobile()
return (
<>
<Divider />
<ShapesFunctions />
<Divider />
<AlignDistribute
hasTwoOrMore={selectedShapesCount > 1}
hasThreeOrMore={selectedShapesCount > 2}
/>
<Divider />
<RowButton
bp={breakpoints}
disabled={selectedShapesCount === 0}
onClick={handleCopy}
>
<span>Copy</span>
{showKbds && (
<Kbd>
<span>{commandKey()}</span>
<span>C</span>
</Kbd>
)}
</RowButton>
<RowButton bp={breakpoints} onClick={handlePaste}>
<span>Paste</span>
{showKbds && (
<Kbd>
<span>{commandKey()}</span>
<span>V</span>
</Kbd>
)}
</RowButton>
<RowButton bp={breakpoints} onClick={handleCopyToSvg}>
<span>Copy to SVG</span>
{showKbds && (
<Kbd>
<span></span>
<span>{commandKey()}</span>
<span>C</span>
</Kbd>
)}
</RowButton>
</>
)
}

View file

@ -1,32 +0,0 @@
import { FloatingContainer, RowButton } from 'components/shared'
import { motion } from 'framer-motion'
import { memo } from 'react'
import state, { useSelector } from 'state'
import styled from 'styles'
function BackToContent() {
const shouldDisplay = useSelector((s) => {
const { currentShapes, shapesToRender } = s.values
return currentShapes.length > 0 && shapesToRender.length === 0
})
if (!shouldDisplay) return null
return (
<BackToContentButton initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<RowButton onClick={() => state.send('ZOOMED_TO_CONTENT')}>
Back to content
</RowButton>
</BackToContentButton>
)
}
export default memo(BackToContent)
const BackToContentButton = styled(motion(FloatingContainer), {
pointerEvents: 'all',
width: 'fit-content',
gridRow: 1,
flexGrow: 2,
display: 'block',
})

View file

@ -1,24 +0,0 @@
import { TertiaryButton, TertiaryButtonsContainer } from './shared'
import { Undo, Redo, Trash } from 'components/icons'
import state from 'state'
import { commandKey } from 'utils'
const undo = () => state.send('UNDO')
const redo = () => state.send('REDO')
const clear = () => state.send('CLEARED_PAGE')
export default function UndoRedo(): JSX.Element {
return (
<TertiaryButtonsContainer bp={{ '@initial': 'mobile', '@sm': 'small' }}>
<TertiaryButton label="Undo" kbd={`${commandKey()}Z`} onClick={undo}>
<Undo />
</TertiaryButton>
<TertiaryButton label="Redo" kbd={`${commandKey()}⇧Z`} onClick={redo}>
<Redo />
</TertiaryButton>
<TertiaryButton label="Delete" kbd="⌫" onClick={clear}>
<Trash />
</TertiaryButton>
</TertiaryButtonsContainer>
)
}

View file

@ -1,43 +0,0 @@
import { ZoomInIcon, ZoomOutIcon } from '@radix-ui/react-icons'
import { TertiaryButton, TertiaryButtonsContainer } from './shared'
import state, { useSelector } from 'state'
import tld from 'utils/tld'
import { commandKey } from 'utils'
const zoomIn = () => state.send('ZOOMED_IN')
const zoomOut = () => state.send('ZOOMED_OUT')
const zoomToFit = () => state.send('ZOOMED_TO_FIT')
const zoomToActual = () => state.send('ZOOMED_TO_ACTUAL')
export default function Zoom(): JSX.Element {
return (
<TertiaryButtonsContainer bp={{ '@initial': 'mobile', '@sm': 'small' }}>
<TertiaryButton
label="Zoom Out"
kbd={`${commandKey()}`}
onClick={zoomOut}
>
<ZoomOutIcon />
</TertiaryButton>
<TertiaryButton label="Zoom In" kbd={`${commandKey()}+`} onClick={zoomIn}>
<ZoomInIcon />
</TertiaryButton>
<ZoomCounter />
</TertiaryButtonsContainer>
)
}
function ZoomCounter() {
const zoom = useSelector((s) => tld.getCurrentCamera(s.data).zoom)
return (
<TertiaryButton
label="Reset Zoom"
kbd="⇧0"
onClick={zoomToActual}
onDoubleClick={zoomToFit}
>
{Math.round(zoom * 100)}%
</TertiaryButton>
)
}

View file

@ -1,74 +0,0 @@
import * as _Tooltip from '@radix-ui/react-tooltip'
import React from 'react'
import styled from 'styles'
interface TooltipProps {
children: React.ReactNode
label: string
kbd?: string
side?: 'bottom' | 'left' | 'right' | 'top'
}
export default function Tooltip({
children,
label,
kbd,
side = 'top',
}: TooltipProps): JSX.Element {
return (
<_Tooltip.Root>
<_Tooltip.Trigger as="span">{children}</_Tooltip.Trigger>
<StyledContent side={side} sideOffset={8}>
{label}
{kbd ? (
<kbd>
{kbd.split('').map((k, i) => (
<span key={i}>{k}</span>
))}
</kbd>
) : null}
<StyledArrow />
</StyledContent>
</_Tooltip.Root>
)
}
const StyledContent = styled(_Tooltip.Content, {
borderRadius: 3,
padding: '$3 $3 $3 $3',
fontSize: '$1',
backgroundColor: '$tooltipBg',
color: '$tooltipText',
boxShadow: '$3',
display: 'flex',
alignItems: 'center',
'& kbd': {
marginLeft: '$3',
textShadow: '$2',
textAlign: 'center',
fontSize: '$1',
fontFamily: '$ui',
fontWeight: 400,
gap: '$1',
display: 'flex',
alignItems: 'center',
'& > span': {
padding: '$0',
borderRadius: '$0',
background: '$overlayContrast',
boxShadow: '$key',
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
},
})
const StyledArrow = styled(_Tooltip.Arrow, {
fill: '$tooltipBg',
margin: '0 8px',
})

31
decs.d.ts vendored
View file

@ -1,31 +0,0 @@
type CSSOMString = string
type FontFaceLoadStatus = 'unloaded' | 'loading' | 'loaded' | 'error'
type FontFaceSetStatus = 'loading' | 'loaded'
interface FontFace {
family: CSSOMString
style: CSSOMString
weight: CSSOMString
stretch: CSSOMString
unicodeRange: CSSOMString
variant: CSSOMString
featureSettings: CSSOMString
variationSettings: CSSOMString
display: CSSOMString
readonly status: FontFaceLoadStatus
readonly loaded: Promise<FontFace>
load(): Promise<FontFace>
}
interface FontFaceSet {
readonly status: FontFaceSetStatus
readonly ready: Promise<FontFaceSet>
check(font: string, text?: string): boolean
load(font: string, text?: string): Promise<FontFace[]>
}
declare global {
interface Document {
fonts: FontFaceSet
}
}

View file

@ -1,57 +0,0 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { useCallback } from 'react'
import { fastTransform } from 'state/hacks'
import inputs from 'state/inputs'
import { Edge, Corner } from 'types'
import state from '../state'
export default function useBoundsEvents(handle: Edge | Corner | 'rotate') {
const onPointerDown = useCallback(
(e) => {
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId)
if (e.button === 0) {
const info = inputs.pointerDown(e, handle)
if (inputs.isDoubleClick() && !(info.altKey || info.metaKey)) {
state.send('DOUBLE_POINTED_BOUNDS_HANDLE', info)
}
state.send('POINTED_BOUNDS_HANDLE', info)
}
},
[handle]
)
const onPointerMove = useCallback(
(e) => {
if (e.buttons !== 1) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
const info = inputs.pointerMove(e)
if (state.isIn('transformingSelection')) {
fastTransform(info)
return
}
state.send('MOVED_POINTER', info)
},
[handle]
)
const onPointerUp = useCallback((e) => {
if (e.buttons !== 1) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
e.currentTarget.releasePointerCapture(e.pointerId)
e.currentTarget.replaceWith(e.currentTarget)
state.send('STOPPED_POINTING', inputs.pointerUp(e, 'bounds'))
}, [])
return { onPointerDown, onPointerMove, onPointerUp }
}

View file

@ -1,34 +0,0 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import React, { useEffect } from 'react'
import state from 'state'
import storage from 'state/storage'
import tld from 'utils/tld'
/**
* When the state's camera changes, update the transform of
* the SVG group to reflect the correct zoom and pan.
* @param ref
*/
export default function useCamera(ref: React.MutableRefObject<SVGGElement>) {
useEffect(() => {
let prev = tld.getCurrentCamera(state.data)
return state.onUpdate(() => {
const g = ref.current
if (!g) return
const { point, zoom } = tld.getCurrentCamera(state.data)
if (point !== prev.point || zoom !== prev.zoom) {
g.setAttribute(
'transform',
`scale(${zoom}) translate(${point[0]} ${point[1]})`
)
storage.savePageState(state.data)
prev = tld.getCurrentCamera(state.data)
}
})
}, [state])
}

View file

@ -1,142 +0,0 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { MutableRefObject, useCallback, useEffect } from 'react'
import state from 'state'
import {
fastBrushSelect,
fastDrawUpdate,
fastPanUpdate,
fastTransform,
fastTranslate,
} from 'state/hacks'
import inputs from 'state/inputs'
import Vec from 'utils/vec'
export default function useCanvasEvents(
rCanvas: MutableRefObject<SVGGElement>
) {
const handlePointerDown = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!inputs.canAccept(e.pointerId)) return
rCanvas.current.setPointerCapture(e.pointerId)
const info = inputs.pointerDown(e, 'canvas')
if (e.button === 0) {
if (inputs.isDoubleClick() && !(info.altKey || info.metaKey)) {
state.send('DOUBLE_POINTED_CANVAS', info)
}
state.send('POINTED_CANVAS', info)
} else if (e.button === 2) {
state.send('RIGHT_POINTED', info)
}
},
[]
)
const handlePointerMove = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!inputs.canAccept(e.pointerId)) return
const prev = inputs.pointer?.point
const info = inputs.pointerMove(e)
if (prev && state.isIn('selecting') && inputs.keys[' ']) {
const delta = Vec.sub(prev, info.point)
fastPanUpdate(delta)
state.send('KEYBOARD_PANNED_CAMERA', {
delta: Vec.sub(prev, info.point),
})
return
}
if (state.isIn('draw.editing')) {
fastDrawUpdate(info)
} else if (state.isIn('brushSelecting')) {
fastBrushSelect(info.point)
} else if (state.isIn('translatingSelection')) {
fastTranslate(info)
} else if (state.isIn('transformingSelection')) {
fastTransform(info)
}
state.send('MOVED_POINTER', info)
},
[]
)
const handlePointerUp = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!inputs.canAccept(e.pointerId)) return
rCanvas.current.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', {
id: 'canvas',
...inputs.pointerUp(e, 'canvas'),
})
},
[]
)
const handleTouchStart = useCallback((e: React.TouchEvent<SVGSVGElement>) => {
if ('safari' in window) {
e.preventDefault()
}
}, [])
useEffect(() => {
const preventGestureNavigation = (event: TouchEvent) => {
event.preventDefault()
}
const preventNavigation = (event: TouchEvent) => {
// Center point of the touch area
const touchXPosition = event.touches[0].pageX
// Size of the touch area
const touchXRadius = event.touches[0].radiusX || 0
// We set a threshold (10px) on both sizes of the screen,
// if the touch area overlaps with the screen edges
// it's likely to trigger the navigation. We prevent the
// touchstart event in that case.
if (
touchXPosition - touchXRadius < 10 ||
touchXPosition + touchXRadius > window.innerWidth - 10
) {
event.preventDefault()
}
}
rCanvas.current.addEventListener('gestureend', preventGestureNavigation)
rCanvas.current.addEventListener('gesturechange', preventGestureNavigation)
rCanvas.current.addEventListener('gesturestart', preventGestureNavigation)
rCanvas.current.addEventListener('touchstart', preventNavigation)
return () => {
if (rCanvas.current) {
rCanvas.current.removeEventListener(
'gestureend',
preventGestureNavigation
)
rCanvas.current.removeEventListener(
'gesturechange',
preventGestureNavigation
)
rCanvas.current.removeEventListener(
'gesturestart',
preventGestureNavigation
)
rCanvas.current.removeEventListener('touchstart', preventNavigation)
}
}
}, [])
return {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onTouchStart: handleTouchStart,
}
}

View file

@ -1,20 +0,0 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import router from 'next/router'
import { useEffect } from 'react'
import * as gtag from 'utils/gtag'
export default function useGtag() {
useEffect(() => {
if (process.env.NODE_ENV !== 'production') return
function handleRouteChange(url: URL) {
gtag.pageview(url)
}
router.events.on('routeChangeComplete', handleRouteChange)
return () => {
router.events.off('routeChangeComplete', handleRouteChange)
}
}, [router.events])
}

Some files were not shown because too many files have changed in this diff Show more