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:
commit
010a09ff19
568 changed files with 52871 additions and 42819 deletions
|
@ -1,3 +0,0 @@
|
|||
**/node_modules/*
|
||||
**/out/*
|
||||
**/.next/*
|
15
.eslintrc.js
Normal file
15
.eslintrc.js
Normal 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],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
|
@ -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
2
.gitattributes
vendored
|
@ -1,2 +0,0 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
6
.github/workflows/main.yml
vendored
6
.github/workflows/main.yml
vendored
|
@ -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
43
.gitignore
vendored
|
@ -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
17
.npmignore
Normal 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/
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
5
.vscode/snippets.code-snippets
vendored
5
.vscode/snippets.code-snippets
vendored
|
@ -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
18
.vscode/tasks.json
vendored
Normal 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
21
LICENSE
Normal 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
367
README.md
|
@ -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`.
|
||||
|
|
|
@ -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)
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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": {}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
}
|
||||
`;
|
|
@ -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",
|
||||
}
|
||||
`;
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
|
@ -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'])
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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,
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,3 +0,0 @@
|
|||
module.exports = {
|
||||
presets: ['next/babel'],
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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',
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
})
|
|
@ -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' },
|
||||
},
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
})
|
|
@ -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,
|
||||
})
|
|
@ -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 />
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
})
|
|
@ -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',
|
||||
})
|
|
@ -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)
|
|
@ -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,
|
||||
})
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
})
|
|
@ -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
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
})
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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',
|
||||
})
|
|
@ -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)
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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',
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
31
decs.d.ts
vendored
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue