Camera options (#3282)
This PR implements a camera options API. - [x] Initial PR - [x] Updated unit tests - [x] Feedback / review - [x] New unit tests - [x] Update use-case examples - [x] Ship? ## Public API A user can provide camera options to the `Tldraw` component via the `cameraOptions` prop. The prop is also available on the `TldrawEditor` component and the constructor parameters of the `Editor` class. ```tsx export default function CameraOptionsExample() { return ( <div className="tldraw__editor"> <Tldraw cameraOptions={CAMERA_OPTIONS} /> </div> ) } ``` At runtime, a user can: - get the current camera options with `Editor.getCameraOptions` - update the camera options with `Editor.setCameraOptions` Setting the camera options automatically applies them to the current camera. ```ts editor.setCameraOptions({...editor.getCameraOptions(), isLocked: true }) ``` A user can get the "camera fit zoom" via `editor.getCameraFitZoom()`. # Interface The camera options themselves can look a few different ways depending on the `type` provided. ```tsx export type TLCameraOptions = { /** Whether the camera is locked. */ isLocked: boolean /** The speed of a scroll wheel / trackpad pan. Default is 1. */ panSpeed: number /** The speed of a scroll wheel / trackpad zoom. Default is 1. */ zoomSpeed: number /** The steps that a user can zoom between with zoom in / zoom out. The first and last value will determine the min and max zoom. */ zoomSteps: number[] /** Controls whether the wheel pans or zooms. * * - `zoom`: The wheel will zoom in and out. * - `pan`: The wheel will pan the camera. * - `none`: The wheel will do nothing. */ wheelBehavior: 'zoom' | 'pan' | 'none' /** The camera constraints. */ constraints?: { /** The bounds (in page space) of the constrained space */ bounds: BoxModel /** The padding inside of the viewport (in screen space) */ padding: VecLike /** The origin for placement. Used to position the bounds within the viewport when an axis is fixed or contained and zoom is below the axis fit. */ origin: VecLike /** The camera's initial zoom, used also when the camera is reset. * * - `default`: Sets the initial zoom to 100%. * - `fit-x`: The x axis will completely fill the viewport bounds. * - `fit-y`: The y axis will completely fill the viewport bounds. * - `fit-min`: The smaller axis will completely fill the viewport bounds. * - `fit-max`: The larger axis will completely fill the viewport bounds. * - `fit-x-100`: The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. * - `fit-y-100`: The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. * - `fit-min-100`: The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. * - `fit-max-100`: The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. */ initialZoom: | 'fit-min' | 'fit-max' | 'fit-x' | 'fit-y' | 'fit-min-100' | 'fit-max-100' | 'fit-x-100' | 'fit-y-100' | 'default' /** The camera's base for its zoom steps. * * - `default`: Sets the initial zoom to 100%. * - `fit-x`: The x axis will completely fill the viewport bounds. * - `fit-y`: The y axis will completely fill the viewport bounds. * - `fit-min`: The smaller axis will completely fill the viewport bounds. * - `fit-max`: The larger axis will completely fill the viewport bounds. * - `fit-x-100`: The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. * - `fit-y-100`: The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. * - `fit-min-100`: The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. * - `fit-max-100`: The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. */ baseZoom: | 'fit-min' | 'fit-max' | 'fit-x' | 'fit-y' | 'fit-min-100' | 'fit-max-100' | 'fit-x-100' | 'fit-y-100' | 'default' /** The behavior for the constraints for both axes or each axis individually. * * - `free`: The bounds are ignored when moving the camera. * - 'fixed': The bounds will be positioned within the viewport based on the origin * - `contain`: The 'fixed' behavior will be used when the zoom is below the zoom level at which the bounds would fill the viewport; and when above this zoom, the bounds will use the 'inside' behavior. * - `inside`: The bounds will stay completely within the viewport. * - `outside`: The bounds will stay touching the viewport. */ behavior: | 'free' | 'fixed' | 'inside' | 'outside' | 'contain' | { x: 'free' | 'fixed' | 'inside' | 'outside' | 'contain' y: 'free' | 'fixed' | 'inside' | 'outside' | 'contain' } } } ``` ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `feature` — New feature ### Test Plan These features combine in different ways, so we'll want to write some more tests to find surprises. 1. Add a step-by-step description of how to test your PR here. 2. - [ ] Unit Tests ### Release Notes - SDK: Adds camera options. --------- Co-authored-by: Mitja Bezenšek <mitja.bezensek@gmail.com>
This commit is contained in:
parent
db7c3f59bf
commit
fabba66c0f
66 changed files with 3564 additions and 1855 deletions
|
@ -201,7 +201,156 @@ The [Editor#getInstanceState](?) method returns settings that relate to each ind
|
|||
|
||||
The editor's user preferences are shared between all instances. See the [TLUserPreferences](?) docs for more about the user preferences.
|
||||
|
||||
## Common things to do with the editor
|
||||
# Camera and coordinates
|
||||
|
||||
The editor offers many methods and properties relating to the part of the infinite canvas that is displayed in the component. This section includes key concepts and methods that you can use to change or control which parts of the canvas are visible.
|
||||
|
||||
## Viewport
|
||||
|
||||
The viewport is the rectangular area contained by the editor.
|
||||
|
||||
| Method | Description |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Editor#getViewportScreenBounds](?) | A [Box](?) that describes the size and position of the component's canvas in actual screen pixels. |
|
||||
| [Editor#getViewportPageBounds](?) | A [Box](?) that describes the size and position of the part of the current page that is displayed in the viewport. |
|
||||
|
||||
## Screen vs. page coordinates
|
||||
|
||||
In tldraw, coordinates can either be in page or screen space.
|
||||
|
||||
A "screen point" refers to the point's distance from the top left corner of the component.
|
||||
|
||||
A "page point" refers to the point's distance from the "zero point" of the canvas.
|
||||
|
||||
When the camera is at `{x: 0, y: 0, z: 0}`, the screen point and page point will be identical. As the camera moves, however, the viewport will display a different part of the page; and so a screen point will correspond to a different page point.
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------ | ---------------------------------------------- |
|
||||
| [Editor#screenToPage](?) | Convert a point in screen space to page space. |
|
||||
| [Editor#pageToScreen](?) | Convert a point in page space to screen space. |
|
||||
|
||||
You can get the user's pointer position in both screen and page space.
|
||||
|
||||
```ts
|
||||
const {
|
||||
// The user's most recent page / screen points
|
||||
currentPagePoint,
|
||||
currentScreenPoint,
|
||||
// The user's previous page / screen points
|
||||
previousPagePoint,
|
||||
previousScreenPoint,
|
||||
// The last place where the most recent pointer down occurred
|
||||
originPagePoint,
|
||||
originScreenPoint,
|
||||
} = editor.inputs
|
||||
```
|
||||
|
||||
## Camera options
|
||||
|
||||
You can use the editor's camera options to configure the behavior of the editor's camera. There are many options available.
|
||||
|
||||
### `wheelBehavior`
|
||||
|
||||
When set to `'pan'`, scrolling the mousewheel will pan the camera. When set to `'zoom'`, scrolling the mousewheel will zoom the camera. When set to `none`, it will have no effect.
|
||||
|
||||
### `panSpeed`
|
||||
|
||||
The speed at which the camera pans. A pan can occur when the user holds the spacebar and drags, holds the middle mouse button and drags, drags while using the hand tool, or scrolls the mousewheel. The default value is `1`. A value of `0.5` would be twice as slow as default. A value of `2` would be twice as fast. When set to `0`, the camera will not pan.
|
||||
|
||||
### `zoomSpeed`
|
||||
|
||||
The speed at which the camera zooms. A zoom can occur when the user pinches or scrolls the mouse wheel. The default value is `1`. A value of `0.5` would be twice as slow as default. A value of `2` would be twice as fast. When set to `0`, the camera will not zoom.
|
||||
|
||||
### `zoomSteps`
|
||||
|
||||
The camera's "zoom steps" are an array of discrete zoom levels that the camera will move between when using the "zoom in" or "zoom out" controls.
|
||||
|
||||
The first number in the `zoomSteps` array defines the camera's minimum zoom level. The last number in the `zoomSteps` array defines the camera's maximum zoom level.
|
||||
|
||||
If the `constraints` are provided, then the actual value for the camera's zoom will be be calculated by multiplying the value from the `zoomSteps` array with the value from the `baseZoom`. See the `baseZoom` property for more information.
|
||||
|
||||
### `isLocked`
|
||||
|
||||
Whether the camera is locked. When the camera is locked, the camera will not move.
|
||||
|
||||
### `constraints`
|
||||
|
||||
By default the camera is free to move anywhere on the infinite canvas. However, you may provide the camera with a `constraints` object that constrains the camera based on a relationship between `bounds` (in page space) and the `viewport` (in screen space).
|
||||
|
||||
### `constraints.bounds`
|
||||
|
||||
A box model describing the bounds in page space.
|
||||
|
||||
### `constraints.padding`
|
||||
|
||||
An object with padding to apply to the `x` and `y` dimensions of the viewport. The padding is in screen space.
|
||||
|
||||
### `constraints.origin`
|
||||
|
||||
An object with an origin for the `x` and `y` dimensions. Depending on the `behavior`, the origin may be used to position the bounds within the viewport.
|
||||
|
||||
For example, when the `behavior` is `fixed` and the `origin.x` is `0`, the bounds will be placed with its left side touching the left side of the viewport. When `origin.x` is `1` the bounds will be placed with its right side touching the right side of the viewport. By default the origin for each dimension is .5. This places the bounds in the center of the viewport.
|
||||
|
||||
### `constraints.initialZoom`
|
||||
|
||||
The `initialZoom` option defines the camera's initial zoom level and what the zoom should be when when the camera is reset. The zoom it produces is based on the value provided:
|
||||
|
||||
| Value | Description |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `default` | Sets the initial zoom to 100%. |
|
||||
| `fit-x` | The x axis will completely fill the viewport bounds. |
|
||||
| `fit-y` | The y axis will completely fill the viewport bounds. |
|
||||
| `fit-min` | The smaller axis will completely fill the viewport bounds. |
|
||||
| `fit-max` | The larger axis will completely fill the viewport bounds. |
|
||||
| `fit-x-100` | The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. |
|
||||
| `fit-y-100` | The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. |
|
||||
| `fit-min-100` | The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. |
|
||||
| `fit-max-100` | The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. |
|
||||
|
||||
### `constraints.baseZoom`
|
||||
|
||||
The `baseZoom` property defines the base property for the camera's zoom steps. It accepts the same values as `initialZoom`.
|
||||
|
||||
When `constraints` are provided, then the actual value for the camera's zoom will be be calculated by multiplying the value from the `zoomSteps` array with the value from the `baseZoom`.
|
||||
|
||||
For example, if the `baseZoom` is set to `default`, then a zoom step of 2 will be 200%. However, if the `baseZoom` is set to `fit-x`, then a zoom step value of 2 will be twice the zoom level at which the bounds width exactly fits within the viewport.
|
||||
|
||||
### `constraints.behavior`
|
||||
|
||||
The `behavior` property defines which logic should be used when calculating the bounds position.
|
||||
|
||||
| Value | Description |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 'free' | The bounds may be placed anywhere relative to the viewport. This is the default "infinite canvas" experience. |
|
||||
| 'inside' | The bounds must stay entirely within the viewport. |
|
||||
| 'outside' | The bounds may partially leave the viewport but must never leave it completely. |
|
||||
| 'fixed' | The bounds are placed in the viewport at a fixed location according to the `'origin'`. |
|
||||
| 'contain' | When the zoom is below the "fit zoom" for an axis, the bounds use the `'fixed'` behavior; when above, the bounds use the `inside` behavior. |
|
||||
|
||||
## Controlling the camera
|
||||
|
||||
There are several `Editor` methods available for controlling the camera.
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| [Editor#setCamera](?) | Moves the camera to the provided coordinates. |
|
||||
| [Editor#zoomIn](?) | Zooms the camera in to the nearest zoom step. See the `constraints.zoomSteps` for more information. |
|
||||
| [Editor#zoomOut](?) | Zooms the camera in to the nearest zoom step. See the `constraints.zoomSteps` for more information. |
|
||||
| [Editor#zoomToFit](?) | Zooms the camera in to the nearest zoom step. See the `constraints.zoomSteps` for more information. |
|
||||
| [Editor#zoomToBounds](?) | Moves the camera to fit the given bounding box. |
|
||||
| [Editor#zoomToSelection](?) | Moves the camera to fit the current selection. |
|
||||
| [Editor#zoomToUser](?) | Moves the camera to center on a user's cursor. |
|
||||
| [Editor#resetZoom](?) | Resets the zoom to 100% or to the `initialZoom` zoom level. |
|
||||
| [Editor#centerOnPoint](?) | Centers the camera on the given point. |
|
||||
| [Editor#stopCameraAnimation](?) | Stops any camera animation. |
|
||||
|
||||
## Camera state
|
||||
|
||||
The camera may be in two states, `idle` or `moving`.
|
||||
|
||||
You can get the current camera state with [Editor#getCameraState](?).
|
||||
|
||||
# Common things to do with the editor
|
||||
|
||||
### Create a shape id
|
||||
|
||||
|
@ -301,10 +450,10 @@ editor.setCamera(0, 0, 1)
|
|||
|
||||
### Freeze the camera
|
||||
|
||||
You can prevent the user from changing the camera using the [Editor#updateInstanceState](?) method.
|
||||
You can prevent the user from changing the camera using the `Editor.setCameraOptions` method.
|
||||
|
||||
```ts
|
||||
editor.updateInstanceState({ canMoveCamera: false })
|
||||
editor.setCameraOptions({ isLocked: true })
|
||||
```
|
||||
|
||||
### Turn on dark mode
|
||||
|
|
|
@ -38,7 +38,7 @@ export const PeopleMenuItem = track(function PeopleMenuItem({ userId }: { userId
|
|||
<TldrawUiButton
|
||||
type="menu"
|
||||
className="tlui-people-menu__item__button"
|
||||
onClick={() => editor.animateToUser(userId)}
|
||||
onClick={() => editor.zoomToUser(userId)}
|
||||
onDoubleClick={handleFollowClick}
|
||||
>
|
||||
<TldrawUiIcon icon="color" color={presence.color} />
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { default as React, useEffect } from 'react'
|
||||
import { Editor, MAX_ZOOM, MIN_ZOOM, TLPageId, debounce, react, useEditor } from 'tldraw'
|
||||
import { Editor, TLPageId, Vec, clamp, debounce, react, useEditor } from 'tldraw'
|
||||
|
||||
const PARAMS = {
|
||||
// deprecated
|
||||
|
@ -70,14 +70,15 @@ export function useUrlState(onChangeUrl: (params: UrlStateParams) => void) {
|
|||
const viewport = viewportFromString(newViewportRaw)
|
||||
const { x, y, w, h } = viewport
|
||||
const { w: sw, h: sh } = editor.getViewportScreenBounds()
|
||||
|
||||
const zoom = Math.min(Math.max(Math.min(sw / w, sh / h), MIN_ZOOM), MAX_ZOOM)
|
||||
|
||||
editor.setCamera({
|
||||
x: -x + (sw - w * zoom) / 2 / zoom,
|
||||
y: -y + (sh - h * zoom) / 2 / zoom,
|
||||
z: zoom,
|
||||
})
|
||||
const initialZoom = editor.getInitialZoom()
|
||||
const { zoomSteps } = editor.getCameraOptions()
|
||||
const zoomMin = zoomSteps[0]
|
||||
const zoomMax = zoomSteps[zoomSteps.length - 1]
|
||||
const zoom = clamp(Math.min(sw / w, sh / h), zoomMin * initialZoom, zoomMax * initialZoom)
|
||||
editor.setCamera(
|
||||
new Vec(-x + (sw - w * zoom) / 2 / zoom, -y + (sh - h * zoom) / 2 / zoom, zoom),
|
||||
{ immediate: true }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
|
|
@ -86,7 +86,6 @@ export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TL
|
|||
editor.history.clear()
|
||||
// Put the old bounds back in place
|
||||
editor.updateViewportScreenBounds(bounds)
|
||||
editor.updateRenderingBounds()
|
||||
editor.updateInstanceState({ isFocused })
|
||||
})
|
||||
},
|
||||
|
|
|
@ -68,5 +68,5 @@ function createDemoShapes(editor: Editor) {
|
|||
},
|
||||
}))
|
||||
)
|
||||
.zoomToContent({ duration: 0 })
|
||||
.zoomToFit({ animation: { duration: 0 } })
|
||||
}
|
||||
|
|
|
@ -58,5 +58,5 @@ function createDemoShapes(editor: Editor) {
|
|||
})),
|
||||
])
|
||||
|
||||
editor.zoomToContent({ duration: 0 })
|
||||
editor.zoomToFit({ animation: { duration: 0 } })
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ export default function BeforeCreateUpdateShapeExample() {
|
|||
editor.zoomToBounds(new Box(-500, -500, 1000, 1000))
|
||||
|
||||
// lock the camera on that area
|
||||
editor.updateInstanceState({ canMoveCamera: false })
|
||||
editor.setCameraOptions({ isLocked: true })
|
||||
}}
|
||||
components={{
|
||||
// to make it a little clearer what's going on in this example, we'll draw a
|
||||
|
|
|
@ -44,5 +44,5 @@ function createDemoShapes(editor: Editor) {
|
|||
},
|
||||
},
|
||||
])
|
||||
.zoomToContent({ duration: 0 })
|
||||
.zoomToFit({ animation: { duration: 0 } })
|
||||
}
|
||||
|
|
|
@ -0,0 +1,495 @@
|
|||
import { useEffect } from 'react'
|
||||
import {
|
||||
BoxModel,
|
||||
TLCameraOptions,
|
||||
Tldraw,
|
||||
Vec,
|
||||
clamp,
|
||||
track,
|
||||
useEditor,
|
||||
useLocalStorageState,
|
||||
} from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
const CAMERA_OPTIONS: TLCameraOptions = {
|
||||
isLocked: false,
|
||||
wheelBehavior: 'pan',
|
||||
panSpeed: 1,
|
||||
zoomSpeed: 1,
|
||||
zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8],
|
||||
constraints: {
|
||||
initialZoom: 'fit-max',
|
||||
baseZoom: 'fit-max',
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 1600,
|
||||
h: 900,
|
||||
},
|
||||
behavior: { x: 'contain', y: 'contain' },
|
||||
padding: { x: 100, y: 100 },
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
},
|
||||
}
|
||||
|
||||
const BOUNDS_SIZES: Record<string, BoxModel> = {
|
||||
a4: { x: 0, y: 0, w: 1050, h: 1485 },
|
||||
landscape: { x: 0, y: 0, w: 1600, h: 900 },
|
||||
portrait: { x: 0, y: 0, w: 900, h: 1600 },
|
||||
square: { x: 0, y: 0, w: 900, h: 900 },
|
||||
}
|
||||
|
||||
export default function CameraOptionsExample() {
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw
|
||||
// persistenceKey="camera-options"
|
||||
components={components}
|
||||
>
|
||||
<CameraOptionsControlPanel />
|
||||
</Tldraw>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PaddingDisplay = track(() => {
|
||||
const editor = useEditor()
|
||||
const cameraOptions = editor.getCameraOptions()
|
||||
|
||||
if (!cameraOptions.constraints) return null
|
||||
|
||||
const {
|
||||
constraints: {
|
||||
padding: { x: px, y: py },
|
||||
},
|
||||
} = cameraOptions
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: py,
|
||||
left: px,
|
||||
width: `calc(100% - ${px * 2}px)`,
|
||||
height: `calc(100% - ${py * 2}px)`,
|
||||
border: '1px dotted var(--color-text)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const BoundsDisplay = track(() => {
|
||||
const editor = useEditor()
|
||||
const cameraOptions = editor.getCameraOptions()
|
||||
|
||||
if (!cameraOptions.constraints) return null
|
||||
|
||||
const {
|
||||
constraints: {
|
||||
bounds: { x, y, w, h },
|
||||
},
|
||||
} = cameraOptions
|
||||
|
||||
const d = Vec.ToAngle({ x: w, y: h }) * (180 / Math.PI)
|
||||
const colB = '#00000002'
|
||||
const colA = '#0000001F'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: y,
|
||||
left: x,
|
||||
width: w,
|
||||
height: h,
|
||||
// grey and white stripes
|
||||
border: '1px dashed var(--color-text)',
|
||||
backgroundImage: `
|
||||
|
||||
`,
|
||||
backgroundSize: '200px 200px',
|
||||
backgroundPosition: '0 0, 0 100px, 100px -100px, -100px 0px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundImage: `
|
||||
linear-gradient(0deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%),
|
||||
linear-gradient(90deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%),
|
||||
linear-gradient(${d}deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%),
|
||||
linear-gradient(-${d}deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%)`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const components = {
|
||||
// These components are just included for debugging / visualization!
|
||||
OnTheCanvas: BoundsDisplay,
|
||||
InFrontOfTheCanvas: PaddingDisplay,
|
||||
}
|
||||
|
||||
const CameraOptionsControlPanel = track(() => {
|
||||
const editor = useEditor()
|
||||
|
||||
const [cameraOptions, setCameraOptions] = useLocalStorageState('camera ex1', CAMERA_OPTIONS)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.batch(() => {
|
||||
editor.setCameraOptions(cameraOptions, { immediate: true })
|
||||
editor.setCamera(editor.getCamera(), {
|
||||
immediate: true,
|
||||
})
|
||||
})
|
||||
}, [editor, cameraOptions])
|
||||
|
||||
const { constraints } = cameraOptions
|
||||
|
||||
const updateOptions = (
|
||||
options: Partial<
|
||||
Omit<TLCameraOptions, 'constraints'> & {
|
||||
constraints: Partial<TLCameraOptions['constraints']>
|
||||
}
|
||||
>
|
||||
) => {
|
||||
const { constraints } = options
|
||||
const cameraOptions = editor.getCameraOptions()
|
||||
setCameraOptions({
|
||||
...cameraOptions,
|
||||
...options,
|
||||
constraints:
|
||||
constraints === undefined
|
||||
? cameraOptions.constraints
|
||||
: {
|
||||
...(cameraOptions.constraints! ?? CAMERA_OPTIONS.constraints),
|
||||
...constraints,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: 'all',
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
left: 0,
|
||||
padding: 4,
|
||||
background: 'white',
|
||||
zIndex: 1000000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
columnGap: 12,
|
||||
rowGap: 4,
|
||||
marginBottom: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<label htmlFor="lock">Lock</label>
|
||||
<select
|
||||
name="lock"
|
||||
value={cameraOptions.isLocked ? 'true' : 'false'}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
updateOptions({
|
||||
...CAMERA_OPTIONS,
|
||||
isLocked: value === 'true',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
<label htmlFor="wheelBehavior">Wheel behavior</label>
|
||||
<select
|
||||
name="wheelBehavior"
|
||||
value={cameraOptions.wheelBehavior}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
updateOptions({
|
||||
...CAMERA_OPTIONS,
|
||||
wheelBehavior: value as 'zoom' | 'pan',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option>zoom</option>
|
||||
<option>pan</option>
|
||||
</select>
|
||||
<label htmlFor="panspeed">Pan Speed</label>
|
||||
<input
|
||||
name="panspeed"
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={cameraOptions.panSpeed}
|
||||
onChange={(e) => {
|
||||
const val = clamp(Number(e.target.value), 0, 2)
|
||||
updateOptions({ panSpeed: val })
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="zoomspeed">Zoom Speed</label>
|
||||
<input
|
||||
name="zoomspeed"
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={cameraOptions.zoomSpeed}
|
||||
onChange={(e) => {
|
||||
const val = clamp(Number(e.target.value), 0, 2)
|
||||
updateOptions({ zoomSpeed: val })
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="zoomsteps">Zoom Steps</label>
|
||||
<input
|
||||
name="zoomsteps"
|
||||
type="text"
|
||||
defaultValue={cameraOptions.zoomSteps.join(', ')}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const val = e.target.value.split(', ').map((v) => Number(v))
|
||||
if (val.every((v) => typeof v === 'number' && Number.isFinite(v))) {
|
||||
updateOptions({ zoomSteps: val })
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="bounds">Bounds</label>
|
||||
<select
|
||||
name="bounds"
|
||||
value={
|
||||
Object.entries(BOUNDS_SIZES).find(([_, b]) => b.w === constraints?.bounds.w)?.[0] ??
|
||||
'none'
|
||||
}
|
||||
onChange={(e) => {
|
||||
const currentConstraints = constraints ?? CAMERA_OPTIONS.constraints
|
||||
const value = e.target.value
|
||||
|
||||
if (value === 'none') {
|
||||
updateOptions({
|
||||
...CAMERA_OPTIONS,
|
||||
constraints: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updateOptions({
|
||||
...CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...currentConstraints,
|
||||
bounds: BOUNDS_SIZES[value] ?? BOUNDS_SIZES.a4,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="none">none</option>
|
||||
<option value="a4">A4 Page</option>
|
||||
<option value="portrait">Portait</option>
|
||||
<option value="landscape">Landscape</option>
|
||||
<option value="square">Square</option>
|
||||
</select>
|
||||
{constraints ? (
|
||||
<>
|
||||
<label htmlFor="initialZoom">Initial Zoom</label>
|
||||
<select
|
||||
name="initialZoom"
|
||||
value={constraints.initialZoom}
|
||||
onChange={(e) => {
|
||||
updateOptions({
|
||||
constraints: {
|
||||
...constraints,
|
||||
initialZoom: e.target.value as any,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option>fit-min</option>
|
||||
<option>fit-max</option>
|
||||
<option>fit-x</option>
|
||||
<option>fit-y</option>
|
||||
<option>fit-min-100</option>
|
||||
<option>fit-max-100</option>
|
||||
<option>fit-x-100</option>
|
||||
<option>fit-y-100</option>
|
||||
<option>default</option>
|
||||
</select>
|
||||
<label htmlFor="zoomBehavior">Base Zoom</label>
|
||||
<select
|
||||
name="zoomBehavior"
|
||||
value={constraints.baseZoom}
|
||||
onChange={(e) => {
|
||||
updateOptions({
|
||||
constraints: {
|
||||
...constraints,
|
||||
baseZoom: e.target.value as any,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option>fit-min</option>
|
||||
<option>fit-max</option>
|
||||
<option>fit-x</option>
|
||||
<option>fit-y</option>
|
||||
<option>fit-min-100</option>
|
||||
<option>fit-max-100</option>
|
||||
<option>fit-x-100</option>
|
||||
<option>fit-y-100</option>
|
||||
<option>default</option>
|
||||
</select>
|
||||
<label htmlFor="originX">Origin X</label>
|
||||
<input
|
||||
name="originX"
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={constraints.origin.x}
|
||||
onChange={(e) => {
|
||||
const val = clamp(Number(e.target.value), 0, 1)
|
||||
updateOptions({
|
||||
constraints: {
|
||||
origin: {
|
||||
...constraints.origin,
|
||||
x: val,
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="originY">Origin Y</label>
|
||||
<input
|
||||
name="originY"
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={constraints.origin.y}
|
||||
onChange={(e) => {
|
||||
const val = clamp(Number(e.target.value), 0, 1)
|
||||
updateOptions({
|
||||
constraints: {
|
||||
...constraints,
|
||||
origin: {
|
||||
...constraints.origin,
|
||||
y: val,
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="paddingX">Padding X</label>
|
||||
<input
|
||||
name="paddingX"
|
||||
type="number"
|
||||
step={10}
|
||||
value={constraints.padding.x}
|
||||
onChange={(e) => {
|
||||
const val = clamp(Number(e.target.value), 0)
|
||||
updateOptions({
|
||||
constraints: {
|
||||
...constraints,
|
||||
padding: {
|
||||
...constraints.padding,
|
||||
x: val,
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="paddingY">Padding Y</label>
|
||||
<input
|
||||
name="paddingY"
|
||||
type="number"
|
||||
step={10}
|
||||
value={constraints.padding.y}
|
||||
onChange={(e) => {
|
||||
const val = clamp(Number(e.target.value), 0)
|
||||
updateOptions({
|
||||
constraints: {
|
||||
padding: {
|
||||
...constraints.padding,
|
||||
y: val,
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="behaviorX">Behavior X</label>
|
||||
<select
|
||||
name="behaviorX"
|
||||
value={(constraints.behavior as { x: any; y: any }).x}
|
||||
onChange={(e) => {
|
||||
setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...constraints,
|
||||
behavior: {
|
||||
...(constraints.behavior as { x: any; y: any }),
|
||||
x: e.target.value as any,
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option>free</option>
|
||||
<option>contain</option>
|
||||
<option>inside</option>
|
||||
<option>outside</option>
|
||||
<option>fixed</option>
|
||||
</select>
|
||||
<label htmlFor="behaviorY">Behavior Y</label>
|
||||
<select
|
||||
name="behaviorY"
|
||||
value={(constraints.behavior as { x: any; y: any }).y}
|
||||
onChange={(e) => {
|
||||
setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...constraints,
|
||||
behavior: {
|
||||
...(constraints.behavior as { x: any; y: any }),
|
||||
y: e.target.value as any,
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option>free</option>
|
||||
<option>contain</option>
|
||||
<option>inside</option>
|
||||
<option>outside</option>
|
||||
<option>fixed</option>
|
||||
</select>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.setCamera(editor.getCamera(), { reset: true })
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(editor.getCameraOptions())
|
||||
}}
|
||||
>
|
||||
Reset Camera
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateOptions(CAMERA_OPTIONS)
|
||||
}}
|
||||
>
|
||||
Reset Camera Options
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
11
apps/examples/src/examples/camera-options/README.md
Normal file
11
apps/examples/src/examples/camera-options/README.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
title: Camera options
|
||||
component: ./CameraOptionsExample.tsx
|
||||
category: basic
|
||||
---
|
||||
|
||||
You can set the camera's options and constraints.
|
||||
|
||||
---
|
||||
|
||||
The `Tldraw` component provides a prop, `cameraOptions`, that can be used to set the camera's constraints, zoom behavior, and other options.
|
|
@ -90,7 +90,7 @@ export class EditableShapeUtil extends BaseBoxShapeUtil<IMyEditableShape> {
|
|||
override onEditEnd: TLOnEditEndHandler<IMyEditableShape> = (shape) => {
|
||||
this.editor.animateShape(
|
||||
{ ...shape, rotation: shape.rotation + Math.PI * 2 },
|
||||
{ duration: 250 }
|
||||
{ animation: { duration: 250 } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,7 +51,7 @@ export default function ExternalContentSourcesExample() {
|
|||
const htmlSource = sources?.find((s) => s.type === 'text' && s.subtype === 'html')
|
||||
|
||||
if (htmlSource) {
|
||||
const center = point ?? editor.getViewportPageCenter()
|
||||
const center = point ?? editor.getViewportPageBounds().center
|
||||
|
||||
editor.createShape({
|
||||
type: 'html',
|
||||
|
|
|
@ -1,20 +1,14 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
AssetRecordType,
|
||||
Box,
|
||||
Editor,
|
||||
PORTRAIT_BREAKPOINT,
|
||||
SVGContainer,
|
||||
TLImageShape,
|
||||
TLShapeId,
|
||||
Tldraw,
|
||||
clamp,
|
||||
createShapeId,
|
||||
exportToBlob,
|
||||
getIndexBelow,
|
||||
react,
|
||||
track,
|
||||
useBreakpoint,
|
||||
useEditor,
|
||||
} from 'tldraw'
|
||||
import { AnnotatorImage } from './types'
|
||||
|
@ -31,9 +25,19 @@ export function ImageAnnotationEditor({
|
|||
onDone: (result: Blob) => void
|
||||
}) {
|
||||
const [imageShapeId, setImageShapeId] = useState<TLShapeId | null>(null)
|
||||
const [editor, setEditor] = useState(null as Editor | null)
|
||||
|
||||
function onMount(editor: Editor) {
|
||||
setEditor(editor)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
// Turn off debug mode
|
||||
editor.updateInstanceState({ isDebugMode: false })
|
||||
|
||||
// Create the asset and image shape
|
||||
const assetId = AssetRecordType.createId()
|
||||
editor.createAssets([
|
||||
{
|
||||
|
@ -51,10 +55,9 @@ export function ImageAnnotationEditor({
|
|||
},
|
||||
},
|
||||
])
|
||||
|
||||
const imageId = createShapeId()
|
||||
const shapeId = createShapeId()
|
||||
editor.createShape<TLImageShape>({
|
||||
id: imageId,
|
||||
id: shapeId,
|
||||
type: 'image',
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
@ -66,13 +69,88 @@ export function ImageAnnotationEditor({
|
|||
},
|
||||
})
|
||||
|
||||
editor.history.clear()
|
||||
setImageShapeId(imageId)
|
||||
// Make sure the shape is at the bottom of the page
|
||||
function makeSureShapeIsAtBottom() {
|
||||
if (!editor) return
|
||||
|
||||
// zoom aaaaallll the way out. our camera constraints will make sure we end up nicely
|
||||
// centered on the image
|
||||
editor.setCamera({ x: 0, y: 0, z: 0.0001 })
|
||||
}
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) return
|
||||
|
||||
const pageId = editor.getCurrentPageId()
|
||||
|
||||
// The shape should always be the child of the current page
|
||||
if (shape.parentId !== pageId) {
|
||||
editor.moveShapesToPage([shape], pageId)
|
||||
}
|
||||
|
||||
// The shape should always be at the bottom of the page's children
|
||||
const siblings = editor.getSortedChildIdsForParent(pageId)
|
||||
const currentBottomShape = editor.getShape(siblings[0])!
|
||||
if (currentBottomShape.id !== shapeId) {
|
||||
editor.sendToBack([shape])
|
||||
}
|
||||
}
|
||||
|
||||
makeSureShapeIsAtBottom()
|
||||
|
||||
const removeOnCreate = editor.sideEffects.registerAfterCreateHandler(
|
||||
'shape',
|
||||
makeSureShapeIsAtBottom
|
||||
)
|
||||
|
||||
const removeOnChange = editor.sideEffects.registerAfterChangeHandler(
|
||||
'shape',
|
||||
makeSureShapeIsAtBottom
|
||||
)
|
||||
|
||||
// The shape should always be locked
|
||||
const cleanupKeepShapeLocked = editor.sideEffects.registerBeforeChangeHandler(
|
||||
'shape',
|
||||
(prev, next) => {
|
||||
if (next.id !== shapeId) return next
|
||||
if (next.isLocked) return next
|
||||
return { ...prev, isLocked: true }
|
||||
}
|
||||
)
|
||||
|
||||
// Reset the history
|
||||
editor.history.clear()
|
||||
setImageShapeId(shapeId)
|
||||
|
||||
return () => {
|
||||
removeOnChange()
|
||||
removeOnCreate()
|
||||
cleanupKeepShapeLocked()
|
||||
}
|
||||
}, [image, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!imageShapeId) return
|
||||
|
||||
/**
|
||||
* We don't want the user to be able to scroll away from the image, or zoom it all the way out. This
|
||||
* component hooks into camera updates to keep the camera constrained - try uploading a very long,
|
||||
* thin image and seeing how the camera behaves.
|
||||
*/
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
constraints: {
|
||||
initialZoom: 'fit-max',
|
||||
baseZoom: 'default',
|
||||
bounds: { w: image.width, h: image.height, x: 0, y: 0 },
|
||||
padding: { x: 32, y: 64 },
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
behavior: 'contain',
|
||||
},
|
||||
zoomSteps: [1, 2, 4, 8],
|
||||
zoomSpeed: 1,
|
||||
panSpeed: 1,
|
||||
isLocked: false,
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
}, [editor, imageShapeId, image])
|
||||
|
||||
return (
|
||||
<Tldraw
|
||||
|
@ -91,11 +169,7 @@ export function ImageAnnotationEditor({
|
|||
return <DoneButton imageShapeId={imageShapeId} onClick={onDone} />
|
||||
}, [imageShapeId, onDone]),
|
||||
}}
|
||||
>
|
||||
{imageShapeId && <KeepShapeAtBottomOfCurrentPage shapeId={imageShapeId} />}
|
||||
{imageShapeId && <KeepShapeLocked shapeId={imageShapeId} />}
|
||||
{imageShapeId && <ConstrainCamera shapeId={imageShapeId} />}
|
||||
</Tldraw>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -172,165 +246,3 @@ function DoneButton({
|
|||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* We want to keep our locked image at the bottom of the current page - people shouldn't be able to
|
||||
* place other shapes beneath it. This component adds side effects for when shapes are created or
|
||||
* updated to make sure that this shape is always kept at the bottom.
|
||||
*/
|
||||
function KeepShapeAtBottomOfCurrentPage({ shapeId }: { shapeId: TLShapeId }) {
|
||||
const editor = useEditor()
|
||||
|
||||
useEffect(() => {
|
||||
function makeSureShapeIsAtBottom() {
|
||||
let shape = editor.getShape(shapeId)
|
||||
if (!shape) return
|
||||
const pageId = editor.getCurrentPageId()
|
||||
|
||||
if (shape.parentId !== pageId) {
|
||||
editor.moveShapesToPage([shape], pageId)
|
||||
shape = editor.getShape(shapeId)!
|
||||
}
|
||||
|
||||
const siblings = editor.getSortedChildIdsForParent(pageId)
|
||||
const currentBottomShape = editor.getShape(siblings[0])!
|
||||
if (currentBottomShape.id === shapeId) return
|
||||
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
isLocked: shape.isLocked,
|
||||
index: getIndexBelow(currentBottomShape.index),
|
||||
})
|
||||
}
|
||||
|
||||
makeSureShapeIsAtBottom()
|
||||
|
||||
const removeOnCreate = editor.sideEffects.registerAfterCreateHandler(
|
||||
'shape',
|
||||
makeSureShapeIsAtBottom
|
||||
)
|
||||
const removeOnChange = editor.sideEffects.registerAfterChangeHandler(
|
||||
'shape',
|
||||
makeSureShapeIsAtBottom
|
||||
)
|
||||
|
||||
return () => {
|
||||
removeOnCreate()
|
||||
removeOnChange()
|
||||
}
|
||||
}, [editor, shapeId])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function KeepShapeLocked({ shapeId }: { shapeId: TLShapeId }) {
|
||||
const editor = useEditor()
|
||||
|
||||
useEffect(() => {
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) return
|
||||
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
isLocked: true,
|
||||
})
|
||||
|
||||
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => {
|
||||
if (next.id !== shapeId) return next
|
||||
if (next.isLocked) return next
|
||||
return { ...prev, isLocked: true }
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeOnChange()
|
||||
}
|
||||
}, [editor, shapeId])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* We don't want the user to be able to scroll away from the image, or zoom it all the way out. This
|
||||
* component hooks into camera updates to keep the camera constrained - try uploading a very long,
|
||||
* thin image and seeing how the camera behaves.
|
||||
*/
|
||||
function ConstrainCamera({ shapeId }: { shapeId: TLShapeId }) {
|
||||
const editor = useEditor()
|
||||
const breakpoint = useBreakpoint()
|
||||
const isMobile = breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
|
||||
|
||||
useEffect(() => {
|
||||
const marginTop = 44
|
||||
const marginSide = isMobile ? 16 : 164
|
||||
const marginBottom = 60
|
||||
|
||||
function constrainCamera(camera: { x: number; y: number; z: number }): {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
} {
|
||||
const viewportBounds = editor.getViewportScreenBounds()
|
||||
const targetBounds = editor.getShapePageBounds(shapeId)!
|
||||
|
||||
const usableViewport = new Box(
|
||||
marginSide,
|
||||
marginTop,
|
||||
viewportBounds.w - marginSide * 2,
|
||||
viewportBounds.h - marginTop - marginBottom
|
||||
)
|
||||
|
||||
const minZoom = Math.min(
|
||||
usableViewport.w / targetBounds.w,
|
||||
usableViewport.h / targetBounds.h,
|
||||
1
|
||||
)
|
||||
const zoom = Math.max(minZoom, camera.z)
|
||||
|
||||
const centerX = targetBounds.x - targetBounds.w / 2 + usableViewport.midX / zoom
|
||||
const centerY = targetBounds.y - targetBounds.h / 2 + usableViewport.midY / zoom
|
||||
|
||||
const availableXMovement = Math.max(0, targetBounds.w - usableViewport.w / zoom)
|
||||
const availableYMovement = Math.max(0, targetBounds.h - usableViewport.h / zoom)
|
||||
|
||||
return {
|
||||
x: clamp(camera.x, centerX - availableXMovement / 2, centerX + availableXMovement / 2),
|
||||
y: clamp(camera.y, centerY - availableYMovement / 2, centerY + availableYMovement / 2),
|
||||
z: zoom,
|
||||
}
|
||||
}
|
||||
|
||||
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler(
|
||||
'camera',
|
||||
(_prev, next) => {
|
||||
const constrained = constrainCamera(next)
|
||||
if (constrained.x === next.x && constrained.y === next.y && constrained.z === next.z)
|
||||
return next
|
||||
return { ...next, ...constrained }
|
||||
}
|
||||
)
|
||||
|
||||
const removeReaction = react('update camera when viewport/shape changes', () => {
|
||||
const original = editor.getCamera()
|
||||
const constrained = constrainCamera(original)
|
||||
if (
|
||||
original.x === constrained.x &&
|
||||
original.y === constrained.y &&
|
||||
original.z === constrained.z
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// this needs to be in a microtask for some reason, but idk why
|
||||
queueMicrotask(() => editor.setCamera(constrained))
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeOnChange()
|
||||
removeReaction()
|
||||
}
|
||||
}, [editor, isMobile, shapeId])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -1,19 +1,17 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
PORTRAIT_BREAKPOINT,
|
||||
DEFAULT_CAMERA_OPTIONS,
|
||||
SVGContainer,
|
||||
TLComponents,
|
||||
TLImageShape,
|
||||
TLShapeId,
|
||||
TLShapePartial,
|
||||
Tldraw,
|
||||
clamp,
|
||||
compact,
|
||||
getIndicesBetween,
|
||||
react,
|
||||
sortByIndex,
|
||||
track,
|
||||
useBreakpoint,
|
||||
useEditor,
|
||||
} from 'tldraw'
|
||||
import { ExportPdfButton } from './ExportPdfButton'
|
||||
|
@ -25,13 +23,19 @@ import { Pdf } from './PdfPicker'
|
|||
// - inertial scrolling for constrained camera
|
||||
// - render pages on-demand instead of all at once.
|
||||
export function PdfEditor({ pdf }: { pdf: Pdf }) {
|
||||
const pdfShapeIds = useMemo(() => pdf.pages.map((page) => page.shapeId), [pdf.pages])
|
||||
const components = useMemo<TLComponents>(
|
||||
() => ({
|
||||
PageMenu: null,
|
||||
InFrontOfTheCanvas: () => <PageOverlayScreen pdf={pdf} />,
|
||||
SharePanel: () => <ExportPdfButton pdf={pdf} />,
|
||||
}),
|
||||
[pdf]
|
||||
)
|
||||
|
||||
return (
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
editor.updateInstanceState({ isDebugMode: false })
|
||||
editor.setCamera({ x: 1000, y: 1000, z: 1 })
|
||||
|
||||
editor.createAssets(
|
||||
pdf.pages.map((page) => ({
|
||||
id: page.assetId,
|
||||
|
@ -48,7 +52,6 @@ export function PdfEditor({ pdf }: { pdf: Pdf }) {
|
|||
},
|
||||
}))
|
||||
)
|
||||
|
||||
editor.createShapes(
|
||||
pdf.pages.map(
|
||||
(page): TLShapePartial<TLImageShape> => ({
|
||||
|
@ -56,6 +59,7 @@ export function PdfEditor({ pdf }: { pdf: Pdf }) {
|
|||
type: 'image',
|
||||
x: page.bounds.x,
|
||||
y: page.bounds.y,
|
||||
isLocked: true,
|
||||
props: {
|
||||
assetId: page.assetId,
|
||||
w: page.bounds.w,
|
||||
|
@ -64,21 +68,84 @@ export function PdfEditor({ pdf }: { pdf: Pdf }) {
|
|||
})
|
||||
)
|
||||
)
|
||||
|
||||
const shapeIds = pdf.pages.map((page) => page.shapeId)
|
||||
const shapeIdSet = new Set(shapeIds)
|
||||
|
||||
// Don't let the user unlock the pages
|
||||
editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => {
|
||||
if (!shapeIdSet.has(next.id)) return next
|
||||
if (next.isLocked) return next
|
||||
return { ...prev, isLocked: true }
|
||||
})
|
||||
|
||||
// Make sure the shapes are below any of the other shapes
|
||||
function makeSureShapesAreAtBottom() {
|
||||
const shapes = shapeIds.map((id) => editor.getShape(id)!).sort(sortByIndex)
|
||||
const pageId = editor.getCurrentPageId()
|
||||
|
||||
const siblings = editor.getSortedChildIdsForParent(pageId)
|
||||
const currentBottomShapes = siblings
|
||||
.slice(0, shapes.length)
|
||||
.map((id) => editor.getShape(id)!)
|
||||
|
||||
if (currentBottomShapes.every((shape, i) => shape.id === shapes[i].id)) return
|
||||
|
||||
const otherSiblings = siblings.filter((id) => !shapeIdSet.has(id))
|
||||
const bottomSibling = otherSiblings[0]
|
||||
const lowestIndex = editor.getShape(bottomSibling)!.index
|
||||
|
||||
const indexes = getIndicesBetween(undefined, lowestIndex, shapes.length)
|
||||
editor.updateShapes(
|
||||
shapes.map((shape, i) => ({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
isLocked: shape.isLocked,
|
||||
index: indexes[i],
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
makeSureShapesAreAtBottom()
|
||||
editor.sideEffects.registerAfterCreateHandler('shape', makeSureShapesAreAtBottom)
|
||||
editor.sideEffects.registerAfterChangeHandler('shape', makeSureShapesAreAtBottom)
|
||||
|
||||
// Constrain the camera to the bounds of the pages
|
||||
const targetBounds = pdf.pages.reduce(
|
||||
(acc, page) => acc.union(page.bounds),
|
||||
pdf.pages[0].bounds.clone()
|
||||
)
|
||||
|
||||
function updateCameraBounds(isMobile: boolean) {
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
bounds: targetBounds,
|
||||
padding: { x: isMobile ? 16 : 164, y: 64 },
|
||||
origin: { x: 0.5, y: 0 },
|
||||
initialZoom: 'fit-x-100',
|
||||
baseZoom: 'default',
|
||||
behavior: 'contain',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
}
|
||||
|
||||
let isMobile = editor.getViewportScreenBounds().width < 840
|
||||
|
||||
react('update camera', () => {
|
||||
const isMobileNow = editor.getViewportScreenBounds().width < 840
|
||||
if (isMobileNow === isMobile) return
|
||||
isMobile = isMobileNow
|
||||
updateCameraBounds(isMobile)
|
||||
})
|
||||
|
||||
updateCameraBounds(isMobile)
|
||||
}}
|
||||
components={{
|
||||
PageMenu: null,
|
||||
InFrontOfTheCanvas: useCallback(() => {
|
||||
return <PageOverlayScreen pdf={pdf} />
|
||||
}, [pdf]),
|
||||
SharePanel: useCallback(() => {
|
||||
return <ExportPdfButton pdf={pdf} />
|
||||
}, [pdf]),
|
||||
}}
|
||||
>
|
||||
<ConstrainCamera pdf={pdf} />
|
||||
<KeepShapesLocked shapeIds={pdfShapeIds} />
|
||||
<KeepShapesAtBottomOfCurrentPage shapeIds={pdfShapeIds} />
|
||||
</Tldraw>
|
||||
components={components}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -125,165 +192,3 @@ const PageOverlayScreen = track(function PageOverlayScreen({ pdf }: { pdf: Pdf }
|
|||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function ConstrainCamera({ pdf }: { pdf: Pdf }) {
|
||||
const editor = useEditor()
|
||||
const breakpoint = useBreakpoint()
|
||||
const isMobile = breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
|
||||
|
||||
useEffect(() => {
|
||||
const marginTop = 64
|
||||
const marginSide = isMobile ? 16 : 164
|
||||
const marginBottom = 80
|
||||
|
||||
const targetBounds = pdf.pages.reduce(
|
||||
(acc, page) => acc.union(page.bounds),
|
||||
pdf.pages[0].bounds.clone()
|
||||
)
|
||||
|
||||
function constrainCamera(camera: { x: number; y: number; z: number }): {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
} {
|
||||
const viewportBounds = editor.getViewportScreenBounds()
|
||||
|
||||
const usableViewport = new Box(
|
||||
marginSide,
|
||||
marginTop,
|
||||
viewportBounds.w - marginSide * 2,
|
||||
viewportBounds.h - marginTop - marginBottom
|
||||
)
|
||||
|
||||
const minZoom = Math.min(
|
||||
usableViewport.w / targetBounds.w,
|
||||
usableViewport.h / targetBounds.h,
|
||||
1
|
||||
)
|
||||
const zoom = Math.max(minZoom, camera.z)
|
||||
|
||||
const centerX = targetBounds.x - targetBounds.w / 2 + usableViewport.midX / zoom
|
||||
const centerY = targetBounds.y - targetBounds.h / 2 + usableViewport.midY / zoom
|
||||
|
||||
const availableXMovement = Math.max(0, targetBounds.w - usableViewport.w / zoom)
|
||||
const availableYMovement = Math.max(0, targetBounds.h - usableViewport.h / zoom)
|
||||
|
||||
return {
|
||||
x: clamp(camera.x, centerX - availableXMovement / 2, centerX + availableXMovement / 2),
|
||||
y: clamp(camera.y, centerY - availableYMovement / 2, centerY + availableYMovement / 2),
|
||||
z: zoom,
|
||||
}
|
||||
}
|
||||
|
||||
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler(
|
||||
'camera',
|
||||
(_prev, next) => {
|
||||
const constrained = constrainCamera(next)
|
||||
if (constrained.x === next.x && constrained.y === next.y && constrained.z === next.z)
|
||||
return next
|
||||
return { ...next, ...constrained }
|
||||
}
|
||||
)
|
||||
|
||||
const removeReaction = react('update camera when viewport/shape changes', () => {
|
||||
const original = editor.getCamera()
|
||||
const constrained = constrainCamera(original)
|
||||
if (
|
||||
original.x === constrained.x &&
|
||||
original.y === constrained.y &&
|
||||
original.z === constrained.z
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// this needs to be in a microtask for some reason, but idk why
|
||||
queueMicrotask(() => editor.setCamera(constrained))
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeOnChange()
|
||||
removeReaction()
|
||||
}
|
||||
}, [editor, isMobile, pdf.pages])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function KeepShapesLocked({ shapeIds }: { shapeIds: TLShapeId[] }) {
|
||||
const editor = useEditor()
|
||||
|
||||
useEffect(() => {
|
||||
const shapeIdSet = new Set(shapeIds)
|
||||
|
||||
for (const shapeId of shapeIdSet) {
|
||||
const shape = editor.getShape(shapeId)!
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
isLocked: true,
|
||||
})
|
||||
}
|
||||
|
||||
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => {
|
||||
if (!shapeIdSet.has(next.id)) return next
|
||||
if (next.isLocked) return next
|
||||
return { ...prev, isLocked: true }
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeOnChange()
|
||||
}
|
||||
}, [editor, shapeIds])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function KeepShapesAtBottomOfCurrentPage({ shapeIds }: { shapeIds: TLShapeId[] }) {
|
||||
const editor = useEditor()
|
||||
|
||||
useEffect(() => {
|
||||
const shapeIdSet = new Set(shapeIds)
|
||||
|
||||
function makeSureShapesAreAtBottom() {
|
||||
const shapes = shapeIds.map((id) => editor.getShape(id)!).sort(sortByIndex)
|
||||
const pageId = editor.getCurrentPageId()
|
||||
|
||||
const siblings = editor.getSortedChildIdsForParent(pageId)
|
||||
const currentBottomShapes = siblings.slice(0, shapes.length).map((id) => editor.getShape(id)!)
|
||||
|
||||
if (currentBottomShapes.every((shape, i) => shape.id === shapes[i].id)) return
|
||||
|
||||
const otherSiblings = siblings.filter((id) => !shapeIdSet.has(id))
|
||||
const bottomSibling = otherSiblings[0]
|
||||
const lowestIndex = editor.getShape(bottomSibling)!.index
|
||||
|
||||
const indexes = getIndicesBetween(undefined, lowestIndex, shapes.length)
|
||||
editor.updateShapes(
|
||||
shapes.map((shape, i) => ({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
isLocked: shape.isLocked,
|
||||
index: indexes[i],
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
makeSureShapesAreAtBottom()
|
||||
|
||||
const removeOnCreate = editor.sideEffects.registerAfterCreateHandler(
|
||||
'shape',
|
||||
makeSureShapesAreAtBottom
|
||||
)
|
||||
const removeOnChange = editor.sideEffects.registerAfterChangeHandler(
|
||||
'shape',
|
||||
makeSureShapesAreAtBottom
|
||||
)
|
||||
|
||||
return () => {
|
||||
removeOnCreate()
|
||||
removeOnChange()
|
||||
}
|
||||
}, [editor, shapeIds])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ export default function PopupShapeExample() {
|
|||
y: Math.floor(i / 3) * 220,
|
||||
})
|
||||
}
|
||||
editor.zoomToContent({ duration: 0 })
|
||||
editor.zoomToBounds(editor.getCurrentPageBounds()!, { animation: { duration: 0 } })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
title: Rendering shapes change
|
||||
component: ./RenderingShapesChangeExample.tsx
|
||||
category: basic
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
Do something when the rendering shapes change.
|
|
@ -1,27 +0,0 @@
|
|||
import { useCallback } from 'react'
|
||||
import { TLShape, Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
import { useChangedShapesReactor } from './useRenderingShapesChange'
|
||||
|
||||
const components = {
|
||||
InFrontOfTheCanvas: () => {
|
||||
const onShapesChanged = useCallback((info: { culled: TLShape[]; restored: TLShape[] }) => {
|
||||
// eslint-disable-next-line no-console
|
||||
for (const shape of info.culled) console.log('culled: ' + shape.id)
|
||||
// eslint-disable-next-line no-console
|
||||
for (const shape of info.restored) console.log('restored: ' + shape.id)
|
||||
}, [])
|
||||
|
||||
useChangedShapesReactor(onShapesChanged)
|
||||
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
export default function RenderingShapesChangeExample() {
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw persistenceKey="example" components={components} />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { TLShape, react, useEditor } from 'tldraw'
|
||||
|
||||
export function useChangedShapesReactor(
|
||||
cb: (info: { culled: TLShape[]; restored: TLShape[] }) => void
|
||||
) {
|
||||
const editor = useEditor()
|
||||
const rPrevShapes = useRef({
|
||||
renderingShapes: editor.getRenderingShapes(),
|
||||
culledShapes: editor.getCulledShapes(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return react('when rendering shapes change', () => {
|
||||
const after = {
|
||||
culledShapes: editor.getCulledShapes(),
|
||||
renderingShapes: editor.getRenderingShapes(),
|
||||
}
|
||||
const before = rPrevShapes.current
|
||||
|
||||
const culled: TLShape[] = []
|
||||
const restored: TLShape[] = []
|
||||
|
||||
const beforeToVisit = new Set(before.renderingShapes)
|
||||
|
||||
for (const afterInfo of after.renderingShapes) {
|
||||
const beforeInfo = before.renderingShapes.find((s) => s.id === afterInfo.id)
|
||||
if (!beforeInfo) {
|
||||
continue
|
||||
} else {
|
||||
const isAfterCulled = after.culledShapes.has(afterInfo.id)
|
||||
const isBeforeCulled = before.culledShapes.has(beforeInfo.id)
|
||||
if (isAfterCulled && !isBeforeCulled) {
|
||||
culled.push(afterInfo.shape)
|
||||
} else if (!isAfterCulled && isBeforeCulled) {
|
||||
restored.push(afterInfo.shape)
|
||||
}
|
||||
beforeToVisit.delete(beforeInfo)
|
||||
}
|
||||
}
|
||||
|
||||
rPrevShapes.current = after
|
||||
|
||||
cb({
|
||||
culled,
|
||||
restored,
|
||||
})
|
||||
})
|
||||
}, [cb, editor])
|
||||
}
|
|
@ -8,7 +8,10 @@ export function moveToSlide(editor: Editor, slide: SlideShape) {
|
|||
if (!bounds) return
|
||||
$currentSlide.set(slide)
|
||||
editor.selectNone()
|
||||
editor.zoomToBounds(bounds, { duration: 500, easing: EASINGS.easeInOutCubic, inset: 0 })
|
||||
editor.zoomToBounds(bounds, {
|
||||
inset: 0,
|
||||
animation: { duration: 500, easing: EASINGS.easeInOutCubic },
|
||||
})
|
||||
}
|
||||
|
||||
export function useSlides() {
|
||||
|
|
|
@ -50,7 +50,6 @@ const ZOOM_EVENT = {
|
|||
'reset-zoom': 'resetZoom',
|
||||
'zoom-to-fit': 'zoomToFit',
|
||||
'zoom-to-selection': 'zoomToSelection',
|
||||
'zoom-to-content': 'zoomToContent',
|
||||
}
|
||||
|
||||
export function getCodeSnippet(name: string, data: any) {
|
||||
|
@ -136,15 +135,11 @@ if (updates.length > 0) {
|
|||
} else if (name === 'fit-frame-to-content') {
|
||||
codeSnippet = `fitFrameToContent(editor, editor.getOnlySelectedShape().id)`
|
||||
} else if (name.startsWith('zoom-') || name === 'reset-zoom') {
|
||||
if (name === 'zoom-to-content') {
|
||||
codeSnippet = 'editor.zoomToContent()'
|
||||
} else {
|
||||
codeSnippet = `editor.${ZOOM_EVENT[name as keyof typeof ZOOM_EVENT]}(${
|
||||
name !== 'zoom-to-fit' && name !== 'zoom-to-selection'
|
||||
? 'editor.getViewportScreenCenter(), '
|
||||
: ''
|
||||
}{ duration: 320 })`
|
||||
}
|
||||
codeSnippet = `editor.${ZOOM_EVENT[name as keyof typeof ZOOM_EVENT]}(${
|
||||
name !== 'zoom-to-fit' && name !== 'zoom-to-selection'
|
||||
? 'editor.getViewportScreenCenter(), '
|
||||
: ''
|
||||
}{ duration: 320 })`
|
||||
} else if (name.startsWith('toggle-')) {
|
||||
if (name === 'toggle-lock') {
|
||||
codeSnippet = `editor.toggleLock(editor.getSelectedShapeIds())`
|
||||
|
|
|
@ -1,373 +1,373 @@
|
|||
{
|
||||
"action.align-bottom": "Poravnaj dno",
|
||||
"action.align-center-horizontal": "Poravnaj vodoravno",
|
||||
"action.align-center-horizontal.short": "Poravnaj vodoravno",
|
||||
"action.align-center-vertical": "Poravnaj navpično",
|
||||
"action.align-center-vertical.short": "Poravnaj navpično",
|
||||
"action.align-left": "Poravnaj levo",
|
||||
"action.align-right": "Poravnaj desno",
|
||||
"action.align-top": "Poravnaj vrh",
|
||||
"action.back-to-content": "Nazaj na vsebino",
|
||||
"action.bring-forward": "Premakni naprej",
|
||||
"action.bring-to-front": "Premakni v ospredje",
|
||||
"action.convert-to-bookmark": "Pretvori v zaznamek",
|
||||
"action.convert-to-embed": "Pretvori v vdelavo",
|
||||
"action.copy": "Kopiraj",
|
||||
"action.copy-as-json": "Kopiraj kot JSON",
|
||||
"action.copy-as-json.short": "JSON",
|
||||
"action.copy-as-png": "Kopiraj kot PNG",
|
||||
"action.copy-as-png.short": "PNG",
|
||||
"action.copy-as-svg": "Kopiraj kot SVG",
|
||||
"action.copy-as-svg.short": "SVG",
|
||||
"action.cut": "Izreži",
|
||||
"action.delete": "Izbriši",
|
||||
"action.distribute-horizontal": "Porazdeli vodoravno",
|
||||
"action.distribute-horizontal.short": "Porazdeli vodoravno",
|
||||
"action.distribute-vertical": "Porazdeli navpično",
|
||||
"action.distribute-vertical.short": "Porazdeli navpično",
|
||||
"action.duplicate": "Podvoji",
|
||||
"action.edit-link": "Uredi povezavo",
|
||||
"action.exit-pen-mode": "Zapustite način peresa",
|
||||
"action.export-all-as-json": "Izvozi vse kot JSON",
|
||||
"action.export-all-as-json.short": "JSON",
|
||||
"action.export-all-as-png": "Izvozi vse kot PNG",
|
||||
"action.export-all-as-png.short": "PNG",
|
||||
"action.export-all-as-svg": "Izvozi vse kot SVG",
|
||||
"action.export-all-as-svg.short": "SVG",
|
||||
"action.export-as-json": "Izvozi kot JSON",
|
||||
"action.export-as-json.short": "JSON",
|
||||
"action.export-as-png": "Izvozi kot PNG",
|
||||
"action.export-as-png.short": "PNG",
|
||||
"action.export-as-svg": "Izvozi kot SVG",
|
||||
"action.export-as-svg.short": "SVG",
|
||||
"action.fit-frame-to-content": "Prilagodi vsebini",
|
||||
"action.flip-horizontal": "Zrcali vodoravno",
|
||||
"action.flip-horizontal.short": "Zrcali horizontalno",
|
||||
"action.flip-vertical": "Zrcali navpično",
|
||||
"action.flip-vertical.short": "Zrcali vertikalno",
|
||||
"action.fork-project": "Naredi kopijo projekta",
|
||||
"action.fork-project-on-tldraw": "Naredi kopijo na tldraw",
|
||||
"action.group": "Združi",
|
||||
"action.insert-embed": "Vstavi vdelavo",
|
||||
"action.insert-media": "Naloži predstavnost",
|
||||
"action.leave-shared-project": "Zapusti skupni projekt",
|
||||
"action.new-project": "Nov projekt",
|
||||
"action.new-shared-project": "Nov skupni projekt",
|
||||
"action.open-cursor-chat": "Klepet s kazalcem",
|
||||
"action.open-embed-link": "Odpri povezavo",
|
||||
"action.open-file": "Odpri datoteko",
|
||||
"action.pack": "Spakiraj",
|
||||
"action.paste": "Prilepi",
|
||||
"action.print": "Natisni",
|
||||
"action.redo": "Uveljavi",
|
||||
"action.remove-frame": "Odstrani okvir",
|
||||
"action.rename": "Preimenuj",
|
||||
"action.rotate-ccw": "Zavrti v nasprotni smeri urinega kazalca",
|
||||
"action.rotate-cw": "Zavrti v smeri urinega kazalca",
|
||||
"action.save-copy": "Shrani kopijo",
|
||||
"action.select-all": "Izberi vse",
|
||||
"action.select-none": "Počisti izbiro",
|
||||
"action.send-backward": "Pošlji nazaj",
|
||||
"action.send-to-back": "Pošlji v ozadje",
|
||||
"action.share-project": "Deli ta projekt",
|
||||
"action.stack-horizontal": "Naloži vodoravno",
|
||||
"action.stack-horizontal.short": "Naloži vodoravno",
|
||||
"action.stack-vertical": "Naloži navpično",
|
||||
"action.stack-vertical.short": "Naloži navpično",
|
||||
"action.stop-following": "Prenehaj slediti",
|
||||
"action.stretch-horizontal": "Raztegnite vodoravno",
|
||||
"action.stretch-horizontal.short": "Raztezanje vodoravno",
|
||||
"action.stretch-vertical": "Raztegni navpično",
|
||||
"action.stretch-vertical.short": "Raztezanje navpično",
|
||||
"action.toggle-auto-size": "Preklopi samodejno velikost",
|
||||
"action.toggle-dark-mode": "Preklopi temni način",
|
||||
"action.toggle-dark-mode.menu": "Temni način",
|
||||
"action.toggle-debug-mode": "Preklopi način odpravljanja napak",
|
||||
"action.toggle-debug-mode.menu": "Način odpravljanja napak",
|
||||
"action.toggle-edge-scrolling": "Preklopi pomikanje ob robovih",
|
||||
"action.toggle-edge-scrolling.menu": "Pomikanje ob robovih",
|
||||
"action.toggle-focus-mode": "Preklopi na osredotočen način",
|
||||
"action.toggle-focus-mode.menu": "Osredotočen način",
|
||||
"action.toggle-grid": "Preklopi mrežo",
|
||||
"action.toggle-grid.menu": "Prikaži mrežo",
|
||||
"action.toggle-lock": "Zakleni \/ odkleni",
|
||||
"action.toggle-reduce-motion": "Preklop zmanjšanja gibanja",
|
||||
"action.toggle-reduce-motion.menu": "Zmanjšaj gibanje",
|
||||
"action.toggle-snap-mode": "Preklopi pripenjanje",
|
||||
"action.toggle-snap-mode.menu": "Vedno pripni",
|
||||
"action.toggle-tool-lock": "Preklopi zaklepanje orodja",
|
||||
"action.toggle-tool-lock.menu": "Zaklepanje orodja",
|
||||
"action.toggle-transparent": "Preklopi prosojno ozadje",
|
||||
"action.toggle-transparent.context-menu": "Prozorno",
|
||||
"action.toggle-transparent.menu": "Prozorno",
|
||||
"action.toggle-wrap-mode": "Preklopi Izberi ob zajetju",
|
||||
"action.toggle-wrap-mode.menu": "Izberi ob zajetju",
|
||||
"action.undo": "Razveljavi",
|
||||
"action.ungroup": "Razdruži",
|
||||
"action.unlock-all": "Odkleni vse",
|
||||
"action.zoom-in": "Povečaj",
|
||||
"action.zoom-out": "Pomanjšaj",
|
||||
"action.zoom-to-100": "Povečaj na 100 %",
|
||||
"action.zoom-to-fit": "Povečaj do prileganja",
|
||||
"action.zoom-to-selection": "Pomakni na izbiro",
|
||||
"actions-menu.title": "Akcije",
|
||||
"align-style.end": "Konec",
|
||||
"align-style.justify": "Poravnaj",
|
||||
"align-style.middle": "Sredina",
|
||||
"align-style.start": "Začetek",
|
||||
"arrowheadEnd-style.arrow": "Puščica",
|
||||
"arrowheadEnd-style.bar": "Črta",
|
||||
"arrowheadEnd-style.diamond": "Diamant",
|
||||
"arrowheadEnd-style.dot": "Pika",
|
||||
"arrowheadEnd-style.inverted": "Obrnjeno",
|
||||
"arrowheadEnd-style.none": "Brez",
|
||||
"arrowheadEnd-style.pipe": "Cev",
|
||||
"arrowheadEnd-style.square": "Kvadrat",
|
||||
"arrowheadEnd-style.triangle": "Trikotnik",
|
||||
"arrowheadStart-style.arrow": "Puščica",
|
||||
"arrowheadStart-style.bar": "Črta",
|
||||
"arrowheadStart-style.diamond": "Diamant",
|
||||
"arrowheadStart-style.dot": "Pika",
|
||||
"arrowheadStart-style.inverted": "Obrnjeno",
|
||||
"arrowheadStart-style.none": "Brez",
|
||||
"arrowheadStart-style.pipe": "Cev",
|
||||
"arrowheadStart-style.square": "Kvadrat",
|
||||
"arrowheadStart-style.triangle": "Trikotnik",
|
||||
"assets.files.upload-failed": "Nalaganje ni uspelo",
|
||||
"assets.url.failed": "Ni bilo mogoče naložiti predogleda URL",
|
||||
"color-style.black": "Črna",
|
||||
"color-style.blue": "Modra",
|
||||
"color-style.green": "Zelena",
|
||||
"color-style.grey": "Siva",
|
||||
"color-style.light-blue": "Svetlo modra",
|
||||
"color-style.light-green": "Svetlo zelena",
|
||||
"color-style.light-red": "Svetlo rdeča",
|
||||
"color-style.light-violet": "Svetlo vijolična",
|
||||
"color-style.orange": "Oranžna",
|
||||
"color-style.red": "Rdeča",
|
||||
"color-style.violet": "Vijolična",
|
||||
"color-style.white": "Bela",
|
||||
"color-style.yellow": "Rumena",
|
||||
"context-menu.arrange": "Preuredi",
|
||||
"context-menu.copy-as": "Kopiraj kot",
|
||||
"context-menu.export-all-as": "Izvozi vse kot",
|
||||
"context-menu.export-as": "Izvozi kot",
|
||||
"context-menu.move-to-page": "Premakni na stran",
|
||||
"context-menu.reorder": "Preuredite",
|
||||
"context.pages.new-page": "Nova stran",
|
||||
"cursor-chat.type-to-chat": "Vnesite za klepet ...",
|
||||
"dash-style.dashed": "Črtkano",
|
||||
"dash-style.dotted": "Pikčasto",
|
||||
"dash-style.draw": "Narisano",
|
||||
"dash-style.solid": "Polno",
|
||||
"debug-panel.more": "Več",
|
||||
"document.default-name": "Neimenovana",
|
||||
"edit-link-dialog.cancel": "Prekliči",
|
||||
"edit-link-dialog.clear": "Počisti",
|
||||
"edit-link-dialog.detail": "Povezave se bodo odprle v novem zavihku.",
|
||||
"edit-link-dialog.invalid-url": "Povezava mora biti veljavna",
|
||||
"edit-link-dialog.save": "Nadaljuj",
|
||||
"edit-link-dialog.title": "Uredi povezavo",
|
||||
"edit-link-dialog.url": "URL",
|
||||
"edit-pages-dialog.move-down": "Premakni navzdol",
|
||||
"edit-pages-dialog.move-up": "Premakni navzgor",
|
||||
"embed-dialog.back": "Nazaj",
|
||||
"embed-dialog.cancel": "Prekliči",
|
||||
"embed-dialog.create": "Ustvari",
|
||||
"embed-dialog.instruction": "Prilepite URL spletnega mesta, da ustvarite vdelavo.",
|
||||
"embed-dialog.invalid-url": "Iz tega URL-ja nismo mogli ustvariti vdelave.",
|
||||
"embed-dialog.title": "Ustvari vdelavo",
|
||||
"embed-dialog.url": "URL",
|
||||
"file-system.confirm-clear.cancel": "Prekliči",
|
||||
"file-system.confirm-clear.continue": "Nadaljuj",
|
||||
"file-system.confirm-clear.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?",
|
||||
"file-system.confirm-clear.dont-show-again": "Ne sprašuj znova",
|
||||
"file-system.confirm-clear.title": "Počisti trenutni projekt?",
|
||||
"file-system.confirm-open.cancel": "Prekliči",
|
||||
"file-system.confirm-open.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?",
|
||||
"file-system.confirm-open.dont-show-again": "Ne sprašuj znova",
|
||||
"file-system.confirm-open.open": "Odpri datoteko",
|
||||
"file-system.confirm-open.title": "Prepiši trenutni projekt?",
|
||||
"file-system.file-open-error.file-format-version-too-new": "Datoteka, ki ste jo poskušali odpreti, je iz novejše različice tldraw. Ponovno naložite stran in poskusite znova.",
|
||||
"file-system.file-open-error.generic-corrupted-file": "Datoteka, ki ste jo poskušali odpreti, je poškodovana.",
|
||||
"file-system.file-open-error.not-a-tldraw-file": "Datoteka, ki ste jo poskušali odpreti, ni videti kot datoteka tldraw.",
|
||||
"file-system.file-open-error.title": "Datoteke ni bilo mogoče odpreti",
|
||||
"file-system.shared-document-file-open-error.description": "Odpiranje datotek v skupnih projektih ni podprto.",
|
||||
"file-system.shared-document-file-open-error.title": "Datoteke ni bilo mogoče odpreti",
|
||||
"fill-style.none": "Brez",
|
||||
"fill-style.pattern": "Vzorec",
|
||||
"fill-style.semi": "Polovično",
|
||||
"fill-style.solid": "Polno",
|
||||
"focus-mode.toggle-focus-mode": "Preklopi na osredotočen način",
|
||||
"font-style.draw": "Draw",
|
||||
"font-style.mono": "Mono",
|
||||
"font-style.sans": "Sans",
|
||||
"font-style.serif": "Serif",
|
||||
"geo-style.arrow-down": "Puščica navzdol",
|
||||
"geo-style.arrow-left": "Puščica levo",
|
||||
"geo-style.arrow-right": "Puščica desno",
|
||||
"geo-style.arrow-up": "Puščica navzgor",
|
||||
"geo-style.check-box": "Potrditveno polje",
|
||||
"geo-style.cloud": "Oblak",
|
||||
"geo-style.diamond": "Diamant",
|
||||
"geo-style.ellipse": "Elipsa",
|
||||
"geo-style.hexagon": "Šesterokotnik",
|
||||
"geo-style.octagon": "Osmerokotnik",
|
||||
"geo-style.oval": "Oval",
|
||||
"geo-style.pentagon": "Peterokotnik",
|
||||
"geo-style.rectangle": "Pravokotnik",
|
||||
"geo-style.rhombus": "Romb",
|
||||
"geo-style.rhombus-2": "Romb 2",
|
||||
"geo-style.star": "Zvezda",
|
||||
"geo-style.trapezoid": "Trapez",
|
||||
"geo-style.triangle": "Trikotnik",
|
||||
"geo-style.x-box": "X polje",
|
||||
"help-menu.about": "O nas",
|
||||
"help-menu.discord": "Discord",
|
||||
"help-menu.github": "GitHub",
|
||||
"help-menu.keyboard-shortcuts": "Bližnjice na tipkovnici",
|
||||
"help-menu.title": "Pomoč in viri",
|
||||
"help-menu.twitter": "Twitter",
|
||||
"home-project-dialog.description": "To je vaš lokalni projekt. Namenjen je samo vam!",
|
||||
"home-project-dialog.ok": "V redu",
|
||||
"home-project-dialog.title": "Lokalni projekt",
|
||||
"menu.copy-as": "Kopiraj kot",
|
||||
"menu.edit": "Uredi",
|
||||
"menu.export-as": "Izvozi kot",
|
||||
"menu.file": "Datoteka",
|
||||
"menu.language": "Jezik",
|
||||
"menu.preferences": "Nastavitve",
|
||||
"menu.title": "Meni",
|
||||
"menu.view": "Pogled",
|
||||
"navigation-zone.toggle-minimap": "Preklopi mini zemljevid",
|
||||
"navigation-zone.zoom": "Povečava",
|
||||
"opacity-style.0.1": "10 %",
|
||||
"opacity-style.0.25": "25 %",
|
||||
"opacity-style.0.5": "50 %",
|
||||
"opacity-style.0.75": "75 %",
|
||||
"opacity-style.1": "100 %",
|
||||
"page-menu.create-new-page": "Ustvari novo stran",
|
||||
"page-menu.edit-done": "Zaključi",
|
||||
"page-menu.edit-start": "Uredi",
|
||||
"page-menu.go-to-page": "Pojdi na stran",
|
||||
"page-menu.max-page-count-reached": "Doseženo največje število strani",
|
||||
"page-menu.new-page-initial-name": "Stran 1",
|
||||
"page-menu.submenu.delete": "Izbriši",
|
||||
"page-menu.submenu.duplicate-page": "Podvoji",
|
||||
"page-menu.submenu.move-down": "Premakni navzdol",
|
||||
"page-menu.submenu.move-up": "Premakni navzgor",
|
||||
"page-menu.submenu.rename": "Preimenuj",
|
||||
"page-menu.submenu.title": "Meni",
|
||||
"page-menu.title": "Strani",
|
||||
"people-menu.change-color": "Spremeni barvo",
|
||||
"people-menu.change-name": "Spremeni ime",
|
||||
"people-menu.follow": "Sledi",
|
||||
"people-menu.following": "Sledim",
|
||||
"people-menu.invite": "Povabi ostale",
|
||||
"people-menu.leading": "Sledi vam",
|
||||
"people-menu.title": "Ljudje",
|
||||
"people-menu.user": "(Ti)",
|
||||
"rename-project-dialog.cancel": "Prekliči",
|
||||
"rename-project-dialog.rename": "Preimenuj",
|
||||
"rename-project-dialog.title": "Preimenuj projekt",
|
||||
"share-menu.copy-link": "Kopiraj povezavo",
|
||||
"share-menu.copy-link-note": "Vsakdo s povezavo si bo lahko ogledal in urejal ta projekt.",
|
||||
"share-menu.copy-readonly-link": "Kopiraj povezavo samo za branje",
|
||||
"share-menu.copy-readonly-link-note": "Vsakdo s povezavo si bo lahko ogledal (vendar ne urejal) ta projekt.",
|
||||
"share-menu.create-snapshot-link": "Ustvari povezavo do posnetka",
|
||||
"share-menu.default-project-name": "Skupni projekt",
|
||||
"share-menu.fork-note": "Na podlagi tega posnetka ustvarite nov skupni projekt.",
|
||||
"share-menu.offline-note": "Skupna raba tega projekta bo ustvarila živo kopijo na novem URL-ju. URL lahko delite z do tridesetimi drugimi osebami, s katerimi lahko skupaj gledate in urejate vsebino.",
|
||||
"share-menu.project-too-large": "Žal tega projekta ni mogoče deliti, ker je prevelik. Delamo na tem!",
|
||||
"share-menu.readonly-link": "Samo za branje",
|
||||
"share-menu.save-note": "Ta projekt prenesite na svoj računalnik kot datoteko .tldr.",
|
||||
"share-menu.share-project": "Deli ta projekt",
|
||||
"share-menu.snapshot-link-note": "Zajemite in delite ta projekt kot povezavo do posnetka samo za branje.",
|
||||
"share-menu.title": "Deli",
|
||||
"share-menu.upload-failed": "Oprostite, trenutno nismo mogli naložiti vašega projekta. Poskusite znova ali nam sporočite, če se težava ponovi.",
|
||||
"sharing.confirm-leave.cancel": "Prekliči",
|
||||
"sharing.confirm-leave.description": "Ali ste prepričani, da želite zapustiti ta skupni projekt? Nanj se lahko vrnete tako, da se ponovno vrnete na njegov URL.",
|
||||
"sharing.confirm-leave.dont-show-again": "Ne sprašuj znova",
|
||||
"sharing.confirm-leave.leave": "Zapusti",
|
||||
"sharing.confirm-leave.title": "Zapusti trenutni projekt?",
|
||||
"shortcuts-dialog.collaboration": "Sodelovanje",
|
||||
"shortcuts-dialog.edit": "Uredi",
|
||||
"shortcuts-dialog.file": "Datoteka",
|
||||
"shortcuts-dialog.preferences": "Nastavitve",
|
||||
"shortcuts-dialog.title": "Bližnjice na tipkovnici",
|
||||
"shortcuts-dialog.tools": "Orodja",
|
||||
"shortcuts-dialog.transform": "Preoblikuj",
|
||||
"shortcuts-dialog.view": "Pogled",
|
||||
"size-style.l": "Veliko",
|
||||
"size-style.m": "Srednje",
|
||||
"size-style.s": "Malo",
|
||||
"size-style.xl": "Zelo veliko",
|
||||
"spline-style.cubic": "Kubično",
|
||||
"spline-style.line": "Črta",
|
||||
"status.offline": "Brez povezave",
|
||||
"status.online": "Povezan",
|
||||
"style-panel.align": "Poravnava",
|
||||
"style-panel.arrowhead-end": "Konec",
|
||||
"style-panel.arrowhead-start": "Začetek",
|
||||
"style-panel.arrowheads": "Puščice",
|
||||
"style-panel.color": "Barva",
|
||||
"style-panel.dash": "Črtasto",
|
||||
"style-panel.fill": "Polnilo",
|
||||
"style-panel.font": "Pisava",
|
||||
"style-panel.geo": "Oblika",
|
||||
"style-panel.mixed": "Mešano",
|
||||
"style-panel.opacity": "Motnost",
|
||||
"style-panel.position": "Položaj",
|
||||
"style-panel.size": "Velikost",
|
||||
"style-panel.spline": "Krivulja",
|
||||
"style-panel.title": "Stili",
|
||||
"style-panel.vertical-align": "Navpična poravnava",
|
||||
"toast.close": "Zapri",
|
||||
"toast.error.copy-fail.desc": "Kopiranje slike ni uspelo",
|
||||
"toast.error.copy-fail.title": "Kopiranje ni uspelo",
|
||||
"toast.error.export-fail.desc": "Izvoz slike ni uspel",
|
||||
"toast.error.export-fail.title": "Izvoz ni uspel",
|
||||
"tool-panel.drawing": "Risanje",
|
||||
"tool-panel.more": "Več",
|
||||
"tool-panel.shapes": "Oblike",
|
||||
"tool.arrow": "Puščica",
|
||||
"tool.arrow-down": "Puščica navzdol",
|
||||
"tool.arrow-left": "Puščica levo",
|
||||
"tool.arrow-right": "Puščica desno",
|
||||
"tool.arrow-up": "Puščica navzgor",
|
||||
"tool.asset": "Sredstvo",
|
||||
"tool.check-box": "Potrditveno polje",
|
||||
"tool.cloud": "Oblak",
|
||||
"tool.diamond": "Diamant",
|
||||
"tool.draw": "Risanje",
|
||||
"tool.ellipse": "Elipsa",
|
||||
"tool.embed": "Vdelava",
|
||||
"tool.eraser": "Radirka",
|
||||
"tool.frame": "Okvir",
|
||||
"tool.hand": "Roka",
|
||||
"tool.hexagon": "Šesterokotnik",
|
||||
"tool.highlight": "Marker",
|
||||
"tool.laser": "Laser",
|
||||
"tool.line": "Črta",
|
||||
"tool.note": "Opomba",
|
||||
"tool.octagon": "Osmerokotnik",
|
||||
"tool.oval": "Oval",
|
||||
"tool.pentagon": "Peterokotnik",
|
||||
"tool.rectangle": "Pravokotnik",
|
||||
"tool.rhombus": "Romb",
|
||||
"tool.select": "Izbor",
|
||||
"tool.star": "Zvezda",
|
||||
"tool.text": "Besedilo",
|
||||
"tool.trapezoid": "Trapez",
|
||||
"tool.triangle": "Trikotnik",
|
||||
"tool.x-box": "X polje",
|
||||
"verticalAlign-style.end": "Dno",
|
||||
"verticalAlign-style.middle": "Sredina",
|
||||
"verticalAlign-style.start": "Vrh",
|
||||
"vscode.file-open.backup": "Varnostna kopija",
|
||||
"vscode.file-open.backup-failed": "Varnostno kopiranje ni uspelo: to ni datoteka .tldr.",
|
||||
"vscode.file-open.backup-saved": "Varnostna kopija shranjena",
|
||||
"vscode.file-open.desc": "Ta datoteka je bila ustvarjena s starejšo različico tldraw. Ali jo želite posodobiti, da bo deloval z novo različico?",
|
||||
"vscode.file-open.dont-show-again": "Ne sprašuj znova",
|
||||
"vscode.file-open.open": "Nadaljuj"
|
||||
}
|
||||
"action.align-bottom": "Poravnaj dno",
|
||||
"action.align-center-horizontal": "Poravnaj vodoravno",
|
||||
"action.align-center-horizontal.short": "Poravnaj vodoravno",
|
||||
"action.align-center-vertical": "Poravnaj navpično",
|
||||
"action.align-center-vertical.short": "Poravnaj navpično",
|
||||
"action.align-left": "Poravnaj levo",
|
||||
"action.align-right": "Poravnaj desno",
|
||||
"action.align-top": "Poravnaj vrh",
|
||||
"action.back-to-content": "Nazaj na vsebino",
|
||||
"action.bring-forward": "Premakni naprej",
|
||||
"action.bring-to-front": "Premakni v ospredje",
|
||||
"action.convert-to-bookmark": "Pretvori v zaznamek",
|
||||
"action.convert-to-embed": "Pretvori v vdelavo",
|
||||
"action.copy": "Kopiraj",
|
||||
"action.copy-as-json": "Kopiraj kot JSON",
|
||||
"action.copy-as-json.short": "JSON",
|
||||
"action.copy-as-png": "Kopiraj kot PNG",
|
||||
"action.copy-as-png.short": "PNG",
|
||||
"action.copy-as-svg": "Kopiraj kot SVG",
|
||||
"action.copy-as-svg.short": "SVG",
|
||||
"action.cut": "Izreži",
|
||||
"action.delete": "Izbriši",
|
||||
"action.distribute-horizontal": "Porazdeli vodoravno",
|
||||
"action.distribute-horizontal.short": "Porazdeli vodoravno",
|
||||
"action.distribute-vertical": "Porazdeli navpično",
|
||||
"action.distribute-vertical.short": "Porazdeli navpično",
|
||||
"action.duplicate": "Podvoji",
|
||||
"action.edit-link": "Uredi povezavo",
|
||||
"action.exit-pen-mode": "Zapustite način peresa",
|
||||
"action.export-all-as-json": "Izvozi vse kot JSON",
|
||||
"action.export-all-as-json.short": "JSON",
|
||||
"action.export-all-as-png": "Izvozi vse kot PNG",
|
||||
"action.export-all-as-png.short": "PNG",
|
||||
"action.export-all-as-svg": "Izvozi vse kot SVG",
|
||||
"action.export-all-as-svg.short": "SVG",
|
||||
"action.export-as-json": "Izvozi kot JSON",
|
||||
"action.export-as-json.short": "JSON",
|
||||
"action.export-as-png": "Izvozi kot PNG",
|
||||
"action.export-as-png.short": "PNG",
|
||||
"action.export-as-svg": "Izvozi kot SVG",
|
||||
"action.export-as-svg.short": "SVG",
|
||||
"action.fit-frame-to-content": "Prilagodi vsebini",
|
||||
"action.flip-horizontal": "Zrcali vodoravno",
|
||||
"action.flip-horizontal.short": "Zrcali horizontalno",
|
||||
"action.flip-vertical": "Zrcali navpično",
|
||||
"action.flip-vertical.short": "Zrcali vertikalno",
|
||||
"action.fork-project": "Naredi kopijo projekta",
|
||||
"action.fork-project-on-tldraw": "Naredi kopijo na tldraw",
|
||||
"action.group": "Združi",
|
||||
"action.insert-embed": "Vstavi vdelavo",
|
||||
"action.insert-media": "Naloži predstavnost",
|
||||
"action.leave-shared-project": "Zapusti skupni projekt",
|
||||
"action.new-project": "Nov projekt",
|
||||
"action.new-shared-project": "Nov skupni projekt",
|
||||
"action.open-cursor-chat": "Klepet s kazalcem",
|
||||
"action.open-embed-link": "Odpri povezavo",
|
||||
"action.open-file": "Odpri datoteko",
|
||||
"action.pack": "Spakiraj",
|
||||
"action.paste": "Prilepi",
|
||||
"action.print": "Natisni",
|
||||
"action.redo": "Uveljavi",
|
||||
"action.remove-frame": "Odstrani okvir",
|
||||
"action.rename": "Preimenuj",
|
||||
"action.rotate-ccw": "Zavrti v nasprotni smeri urinega kazalca",
|
||||
"action.rotate-cw": "Zavrti v smeri urinega kazalca",
|
||||
"action.save-copy": "Shrani kopijo",
|
||||
"action.select-all": "Izberi vse",
|
||||
"action.select-none": "Počisti izbiro",
|
||||
"action.send-backward": "Pošlji nazaj",
|
||||
"action.send-to-back": "Pošlji v ozadje",
|
||||
"action.share-project": "Deli ta projekt",
|
||||
"action.stack-horizontal": "Naloži vodoravno",
|
||||
"action.stack-horizontal.short": "Naloži vodoravno",
|
||||
"action.stack-vertical": "Naloži navpično",
|
||||
"action.stack-vertical.short": "Naloži navpično",
|
||||
"action.stop-following": "Prenehaj slediti",
|
||||
"action.stretch-horizontal": "Raztegnite vodoravno",
|
||||
"action.stretch-horizontal.short": "Raztezanje vodoravno",
|
||||
"action.stretch-vertical": "Raztegni navpično",
|
||||
"action.stretch-vertical.short": "Raztezanje navpično",
|
||||
"action.toggle-auto-size": "Preklopi samodejno velikost",
|
||||
"action.toggle-dark-mode": "Preklopi temni način",
|
||||
"action.toggle-dark-mode.menu": "Temni način",
|
||||
"action.toggle-debug-mode": "Preklopi način odpravljanja napak",
|
||||
"action.toggle-debug-mode.menu": "Način odpravljanja napak",
|
||||
"action.toggle-edge-scrolling": "Preklopi pomikanje ob robovih",
|
||||
"action.toggle-edge-scrolling.menu": "Pomikanje ob robovih",
|
||||
"action.toggle-focus-mode": "Preklopi na osredotočen način",
|
||||
"action.toggle-focus-mode.menu": "Osredotočen način",
|
||||
"action.toggle-grid": "Preklopi mrežo",
|
||||
"action.toggle-grid.menu": "Prikaži mrežo",
|
||||
"action.toggle-lock": "Zakleni / odkleni",
|
||||
"action.toggle-reduce-motion": "Preklop zmanjšanja gibanja",
|
||||
"action.toggle-reduce-motion.menu": "Zmanjšaj gibanje",
|
||||
"action.toggle-snap-mode": "Preklopi pripenjanje",
|
||||
"action.toggle-snap-mode.menu": "Vedno pripni",
|
||||
"action.toggle-tool-lock": "Preklopi zaklepanje orodja",
|
||||
"action.toggle-tool-lock.menu": "Zaklepanje orodja",
|
||||
"action.toggle-transparent": "Preklopi prosojno ozadje",
|
||||
"action.toggle-transparent.context-menu": "Prozorno",
|
||||
"action.toggle-transparent.menu": "Prozorno",
|
||||
"action.toggle-wrap-mode": "Preklopi Izberi ob zajetju",
|
||||
"action.toggle-wrap-mode.menu": "Izberi ob zajetju",
|
||||
"action.undo": "Razveljavi",
|
||||
"action.ungroup": "Razdruži",
|
||||
"action.unlock-all": "Odkleni vse",
|
||||
"action.zoom-in": "Povečaj",
|
||||
"action.zoom-out": "Pomanjšaj",
|
||||
"action.zoom-to-100": "Povečaj na 100 %",
|
||||
"action.zoom-to-fit": "Povečaj do prileganja",
|
||||
"action.zoom-to-selection": "Pomakni na izbiro",
|
||||
"actions-menu.title": "Akcije",
|
||||
"align-style.end": "Konec",
|
||||
"align-style.justify": "Poravnaj",
|
||||
"align-style.middle": "Sredina",
|
||||
"align-style.start": "Začetek",
|
||||
"arrowheadEnd-style.arrow": "Puščica",
|
||||
"arrowheadEnd-style.bar": "Črta",
|
||||
"arrowheadEnd-style.diamond": "Diamant",
|
||||
"arrowheadEnd-style.dot": "Pika",
|
||||
"arrowheadEnd-style.inverted": "Obrnjeno",
|
||||
"arrowheadEnd-style.none": "Brez",
|
||||
"arrowheadEnd-style.pipe": "Cev",
|
||||
"arrowheadEnd-style.square": "Kvadrat",
|
||||
"arrowheadEnd-style.triangle": "Trikotnik",
|
||||
"arrowheadStart-style.arrow": "Puščica",
|
||||
"arrowheadStart-style.bar": "Črta",
|
||||
"arrowheadStart-style.diamond": "Diamant",
|
||||
"arrowheadStart-style.dot": "Pika",
|
||||
"arrowheadStart-style.inverted": "Obrnjeno",
|
||||
"arrowheadStart-style.none": "Brez",
|
||||
"arrowheadStart-style.pipe": "Cev",
|
||||
"arrowheadStart-style.square": "Kvadrat",
|
||||
"arrowheadStart-style.triangle": "Trikotnik",
|
||||
"assets.files.upload-failed": "Nalaganje ni uspelo",
|
||||
"assets.url.failed": "Ni bilo mogoče naložiti predogleda URL",
|
||||
"color-style.black": "Črna",
|
||||
"color-style.blue": "Modra",
|
||||
"color-style.green": "Zelena",
|
||||
"color-style.grey": "Siva",
|
||||
"color-style.light-blue": "Svetlo modra",
|
||||
"color-style.light-green": "Svetlo zelena",
|
||||
"color-style.light-red": "Svetlo rdeča",
|
||||
"color-style.light-violet": "Svetlo vijolična",
|
||||
"color-style.orange": "Oranžna",
|
||||
"color-style.red": "Rdeča",
|
||||
"color-style.violet": "Vijolična",
|
||||
"color-style.white": "Bela",
|
||||
"color-style.yellow": "Rumena",
|
||||
"context-menu.arrange": "Preuredi",
|
||||
"context-menu.copy-as": "Kopiraj kot",
|
||||
"context-menu.export-all-as": "Izvozi vse kot",
|
||||
"context-menu.export-as": "Izvozi kot",
|
||||
"context-menu.move-to-page": "Premakni na stran",
|
||||
"context-menu.reorder": "Preuredite",
|
||||
"context.pages.new-page": "Nova stran",
|
||||
"cursor-chat.type-to-chat": "Vnesite za klepet ...",
|
||||
"dash-style.dashed": "Črtkano",
|
||||
"dash-style.dotted": "Pikčasto",
|
||||
"dash-style.draw": "Narisano",
|
||||
"dash-style.solid": "Polno",
|
||||
"debug-panel.more": "Več",
|
||||
"document.default-name": "Neimenovana",
|
||||
"edit-link-dialog.cancel": "Prekliči",
|
||||
"edit-link-dialog.clear": "Počisti",
|
||||
"edit-link-dialog.detail": "Povezave se bodo odprle v novem zavihku.",
|
||||
"edit-link-dialog.invalid-url": "Povezava mora biti veljavna",
|
||||
"edit-link-dialog.save": "Nadaljuj",
|
||||
"edit-link-dialog.title": "Uredi povezavo",
|
||||
"edit-link-dialog.url": "URL",
|
||||
"edit-pages-dialog.move-down": "Premakni navzdol",
|
||||
"edit-pages-dialog.move-up": "Premakni navzgor",
|
||||
"embed-dialog.back": "Nazaj",
|
||||
"embed-dialog.cancel": "Prekliči",
|
||||
"embed-dialog.create": "Ustvari",
|
||||
"embed-dialog.instruction": "Prilepite URL spletnega mesta, da ustvarite vdelavo.",
|
||||
"embed-dialog.invalid-url": "Iz tega URL-ja nismo mogli ustvariti vdelave.",
|
||||
"embed-dialog.title": "Ustvari vdelavo",
|
||||
"embed-dialog.url": "URL",
|
||||
"file-system.confirm-clear.cancel": "Prekliči",
|
||||
"file-system.confirm-clear.continue": "Nadaljuj",
|
||||
"file-system.confirm-clear.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?",
|
||||
"file-system.confirm-clear.dont-show-again": "Ne sprašuj znova",
|
||||
"file-system.confirm-clear.title": "Počisti trenutni projekt?",
|
||||
"file-system.confirm-open.cancel": "Prekliči",
|
||||
"file-system.confirm-open.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?",
|
||||
"file-system.confirm-open.dont-show-again": "Ne sprašuj znova",
|
||||
"file-system.confirm-open.open": "Odpri datoteko",
|
||||
"file-system.confirm-open.title": "Prepiši trenutni projekt?",
|
||||
"file-system.file-open-error.file-format-version-too-new": "Datoteka, ki ste jo poskušali odpreti, je iz novejše različice tldraw. Ponovno naložite stran in poskusite znova.",
|
||||
"file-system.file-open-error.generic-corrupted-file": "Datoteka, ki ste jo poskušali odpreti, je poškodovana.",
|
||||
"file-system.file-open-error.not-a-tldraw-file": "Datoteka, ki ste jo poskušali odpreti, ni videti kot datoteka tldraw.",
|
||||
"file-system.file-open-error.title": "Datoteke ni bilo mogoče odpreti",
|
||||
"file-system.shared-document-file-open-error.description": "Odpiranje datotek v skupnih projektih ni podprto.",
|
||||
"file-system.shared-document-file-open-error.title": "Datoteke ni bilo mogoče odpreti",
|
||||
"fill-style.none": "Brez",
|
||||
"fill-style.pattern": "Vzorec",
|
||||
"fill-style.semi": "Polovično",
|
||||
"fill-style.solid": "Polno",
|
||||
"focus-mode.toggle-focus-mode": "Preklopi na osredotočen način",
|
||||
"font-style.draw": "Draw",
|
||||
"font-style.mono": "Mono",
|
||||
"font-style.sans": "Sans",
|
||||
"font-style.serif": "Serif",
|
||||
"geo-style.arrow-down": "Puščica navzdol",
|
||||
"geo-style.arrow-left": "Puščica levo",
|
||||
"geo-style.arrow-right": "Puščica desno",
|
||||
"geo-style.arrow-up": "Puščica navzgor",
|
||||
"geo-style.check-box": "Potrditveno polje",
|
||||
"geo-style.cloud": "Oblak",
|
||||
"geo-style.diamond": "Diamant",
|
||||
"geo-style.ellipse": "Elipsa",
|
||||
"geo-style.hexagon": "Šesterokotnik",
|
||||
"geo-style.octagon": "Osmerokotnik",
|
||||
"geo-style.oval": "Oval",
|
||||
"geo-style.pentagon": "Peterokotnik",
|
||||
"geo-style.rectangle": "Pravokotnik",
|
||||
"geo-style.rhombus": "Romb",
|
||||
"geo-style.rhombus-2": "Romb 2",
|
||||
"geo-style.star": "Zvezda",
|
||||
"geo-style.trapezoid": "Trapez",
|
||||
"geo-style.triangle": "Trikotnik",
|
||||
"geo-style.x-box": "X polje",
|
||||
"help-menu.about": "O nas",
|
||||
"help-menu.discord": "Discord",
|
||||
"help-menu.github": "GitHub",
|
||||
"help-menu.keyboard-shortcuts": "Bližnjice na tipkovnici",
|
||||
"help-menu.title": "Pomoč in viri",
|
||||
"help-menu.twitter": "Twitter",
|
||||
"home-project-dialog.description": "To je vaš lokalni projekt. Namenjen je samo vam!",
|
||||
"home-project-dialog.ok": "V redu",
|
||||
"home-project-dialog.title": "Lokalni projekt",
|
||||
"menu.copy-as": "Kopiraj kot",
|
||||
"menu.edit": "Uredi",
|
||||
"menu.export-as": "Izvozi kot",
|
||||
"menu.file": "Datoteka",
|
||||
"menu.language": "Jezik",
|
||||
"menu.preferences": "Nastavitve",
|
||||
"menu.title": "Meni",
|
||||
"menu.view": "Pogled",
|
||||
"navigation-zone.toggle-minimap": "Preklopi mini zemljevid",
|
||||
"navigation-zone.zoom": "Povečava",
|
||||
"opacity-style.0.1": "10 %",
|
||||
"opacity-style.0.25": "25 %",
|
||||
"opacity-style.0.5": "50 %",
|
||||
"opacity-style.0.75": "75 %",
|
||||
"opacity-style.1": "100 %",
|
||||
"page-menu.create-new-page": "Ustvari novo stran",
|
||||
"page-menu.edit-done": "Zaključi",
|
||||
"page-menu.edit-start": "Uredi",
|
||||
"page-menu.go-to-page": "Pojdi na stran",
|
||||
"page-menu.max-page-count-reached": "Doseženo največje število strani",
|
||||
"page-menu.new-page-initial-name": "Stran 1",
|
||||
"page-menu.submenu.delete": "Izbriši",
|
||||
"page-menu.submenu.duplicate-page": "Podvoji",
|
||||
"page-menu.submenu.move-down": "Premakni navzdol",
|
||||
"page-menu.submenu.move-up": "Premakni navzgor",
|
||||
"page-menu.submenu.rename": "Preimenuj",
|
||||
"page-menu.submenu.title": "Meni",
|
||||
"page-menu.title": "Strani",
|
||||
"people-menu.change-color": "Spremeni barvo",
|
||||
"people-menu.change-name": "Spremeni ime",
|
||||
"people-menu.follow": "Sledi",
|
||||
"people-menu.following": "Sledim",
|
||||
"people-menu.invite": "Povabi ostale",
|
||||
"people-menu.leading": "Sledi vam",
|
||||
"people-menu.title": "Ljudje",
|
||||
"people-menu.user": "(Ti)",
|
||||
"rename-project-dialog.cancel": "Prekliči",
|
||||
"rename-project-dialog.rename": "Preimenuj",
|
||||
"rename-project-dialog.title": "Preimenuj projekt",
|
||||
"share-menu.copy-link": "Kopiraj povezavo",
|
||||
"share-menu.copy-link-note": "Vsakdo s povezavo si bo lahko ogledal in urejal ta projekt.",
|
||||
"share-menu.copy-readonly-link": "Kopiraj povezavo samo za branje",
|
||||
"share-menu.copy-readonly-link-note": "Vsakdo s povezavo si bo lahko ogledal (vendar ne urejal) ta projekt.",
|
||||
"share-menu.create-snapshot-link": "Ustvari povezavo do posnetka",
|
||||
"share-menu.default-project-name": "Skupni projekt",
|
||||
"share-menu.fork-note": "Na podlagi tega posnetka ustvarite nov skupni projekt.",
|
||||
"share-menu.offline-note": "Skupna raba tega projekta bo ustvarila živo kopijo na novem URL-ju. URL lahko delite z do tridesetimi drugimi osebami, s katerimi lahko skupaj gledate in urejate vsebino.",
|
||||
"share-menu.project-too-large": "Žal tega projekta ni mogoče deliti, ker je prevelik. Delamo na tem!",
|
||||
"share-menu.readonly-link": "Samo za branje",
|
||||
"share-menu.save-note": "Ta projekt prenesite na svoj računalnik kot datoteko .tldr.",
|
||||
"share-menu.share-project": "Deli ta projekt",
|
||||
"share-menu.snapshot-link-note": "Zajemite in delite ta projekt kot povezavo do posnetka samo za branje.",
|
||||
"share-menu.title": "Deli",
|
||||
"share-menu.upload-failed": "Oprostite, trenutno nismo mogli naložiti vašega projekta. Poskusite znova ali nam sporočite, če se težava ponovi.",
|
||||
"sharing.confirm-leave.cancel": "Prekliči",
|
||||
"sharing.confirm-leave.description": "Ali ste prepričani, da želite zapustiti ta skupni projekt? Nanj se lahko vrnete tako, da se ponovno vrnete na njegov URL.",
|
||||
"sharing.confirm-leave.dont-show-again": "Ne sprašuj znova",
|
||||
"sharing.confirm-leave.leave": "Zapusti",
|
||||
"sharing.confirm-leave.title": "Zapusti trenutni projekt?",
|
||||
"shortcuts-dialog.collaboration": "Sodelovanje",
|
||||
"shortcuts-dialog.edit": "Uredi",
|
||||
"shortcuts-dialog.file": "Datoteka",
|
||||
"shortcuts-dialog.preferences": "Nastavitve",
|
||||
"shortcuts-dialog.title": "Bližnjice na tipkovnici",
|
||||
"shortcuts-dialog.tools": "Orodja",
|
||||
"shortcuts-dialog.transform": "Preoblikuj",
|
||||
"shortcuts-dialog.view": "Pogled",
|
||||
"size-style.l": "Veliko",
|
||||
"size-style.m": "Srednje",
|
||||
"size-style.s": "Malo",
|
||||
"size-style.xl": "Zelo veliko",
|
||||
"spline-style.cubic": "Kubično",
|
||||
"spline-style.line": "Črta",
|
||||
"status.offline": "Brez povezave",
|
||||
"status.online": "Povezan",
|
||||
"style-panel.align": "Poravnava",
|
||||
"style-panel.arrowhead-end": "Konec",
|
||||
"style-panel.arrowhead-start": "Začetek",
|
||||
"style-panel.arrowheads": "Puščice",
|
||||
"style-panel.color": "Barva",
|
||||
"style-panel.dash": "Črtasto",
|
||||
"style-panel.fill": "Polnilo",
|
||||
"style-panel.font": "Pisava",
|
||||
"style-panel.geo": "Oblika",
|
||||
"style-panel.mixed": "Mešano",
|
||||
"style-panel.opacity": "Motnost",
|
||||
"style-panel.position": "Položaj",
|
||||
"style-panel.size": "Velikost",
|
||||
"style-panel.spline": "Krivulja",
|
||||
"style-panel.title": "Stili",
|
||||
"style-panel.vertical-align": "Navpična poravnava",
|
||||
"toast.close": "Zapri",
|
||||
"toast.error.copy-fail.desc": "Kopiranje slike ni uspelo",
|
||||
"toast.error.copy-fail.title": "Kopiranje ni uspelo",
|
||||
"toast.error.export-fail.desc": "Izvoz slike ni uspel",
|
||||
"toast.error.export-fail.title": "Izvoz ni uspel",
|
||||
"tool-panel.drawing": "Risanje",
|
||||
"tool-panel.more": "Več",
|
||||
"tool-panel.shapes": "Oblike",
|
||||
"tool.arrow": "Puščica",
|
||||
"tool.arrow-down": "Puščica navzdol",
|
||||
"tool.arrow-left": "Puščica levo",
|
||||
"tool.arrow-right": "Puščica desno",
|
||||
"tool.arrow-up": "Puščica navzgor",
|
||||
"tool.asset": "Sredstvo",
|
||||
"tool.check-box": "Potrditveno polje",
|
||||
"tool.cloud": "Oblak",
|
||||
"tool.diamond": "Diamant",
|
||||
"tool.draw": "Risanje",
|
||||
"tool.ellipse": "Elipsa",
|
||||
"tool.embed": "Vdelava",
|
||||
"tool.eraser": "Radirka",
|
||||
"tool.frame": "Okvir",
|
||||
"tool.hand": "Roka",
|
||||
"tool.hexagon": "Šesterokotnik",
|
||||
"tool.highlight": "Marker",
|
||||
"tool.laser": "Laser",
|
||||
"tool.line": "Črta",
|
||||
"tool.note": "Opomba",
|
||||
"tool.octagon": "Osmerokotnik",
|
||||
"tool.oval": "Oval",
|
||||
"tool.pentagon": "Peterokotnik",
|
||||
"tool.rectangle": "Pravokotnik",
|
||||
"tool.rhombus": "Romb",
|
||||
"tool.select": "Izbor",
|
||||
"tool.star": "Zvezda",
|
||||
"tool.text": "Besedilo",
|
||||
"tool.trapezoid": "Trapez",
|
||||
"tool.triangle": "Trikotnik",
|
||||
"tool.x-box": "X polje",
|
||||
"verticalAlign-style.end": "Dno",
|
||||
"verticalAlign-style.middle": "Sredina",
|
||||
"verticalAlign-style.start": "Vrh",
|
||||
"vscode.file-open.backup": "Varnostna kopija",
|
||||
"vscode.file-open.backup-failed": "Varnostno kopiranje ni uspelo: to ni datoteka .tldr.",
|
||||
"vscode.file-open.backup-saved": "Varnostna kopija shranjena",
|
||||
"vscode.file-open.desc": "Ta datoteka je bila ustvarjena s starejšo različico tldraw. Ali jo želite posodobiti, da bo deloval z novo različico?",
|
||||
"vscode.file-open.dont-show-again": "Ne sprašuj znova",
|
||||
"vscode.file-open.open": "Nadaljuj"
|
||||
}
|
||||
|
|
|
@ -461,6 +461,9 @@ export const DEFAULT_ANIMATION_OPTIONS: {
|
|||
easing: (t: number) => number;
|
||||
};
|
||||
|
||||
// @internal (undocumented)
|
||||
export const DEFAULT_CAMERA_OPTIONS: TLCameraOptions;
|
||||
|
||||
// @public (undocumented)
|
||||
export function DefaultBackground(): JSX_2.Element;
|
||||
|
||||
|
@ -585,16 +588,27 @@ export class Edge2d extends Geometry2d {
|
|||
|
||||
// @public (undocumented)
|
||||
export class Editor extends EventEmitter<TLEventMap> {
|
||||
constructor({ store, user, shapeUtils, tools, getContainer, initialState, inferDarkMode, }: TLEditorOptions);
|
||||
constructor({ store, user, shapeUtils, tools, getContainer, cameraOptions, initialState, inferDarkMode, }: TLEditorOptions);
|
||||
addOpenMenu(id: string): this;
|
||||
alignShapes(shapes: TLShape[] | TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this;
|
||||
animateShape(partial: null | TLShapePartial | undefined, animationOptions?: TLAnimationOptions): this;
|
||||
animateShapes(partials: (null | TLShapePartial | undefined)[], animationOptions?: Partial<{
|
||||
duration: number;
|
||||
easing: (t: number) => number;
|
||||
animateShape(partial: null | TLShapePartial | undefined, opts?: Partial<{
|
||||
animation: Partial<{
|
||||
duration: number;
|
||||
easing: (t: number) => number;
|
||||
}>;
|
||||
force: boolean;
|
||||
immediate: boolean;
|
||||
reset: boolean;
|
||||
}>): this;
|
||||
animateShapes(partials: (null | TLShapePartial | undefined)[], opts?: Partial<{
|
||||
animation: Partial<{
|
||||
duration: number;
|
||||
easing: (t: number) => number;
|
||||
}>;
|
||||
force: boolean;
|
||||
immediate: boolean;
|
||||
reset: boolean;
|
||||
}>): this;
|
||||
animateToShape(shapeId: TLShapeId, opts?: TLAnimationOptions): this;
|
||||
animateToUser(userId: string): this;
|
||||
// @internal (undocumented)
|
||||
annotateError(error: unknown, { origin, willCrashApp, tags, extras, }: {
|
||||
extras?: Record<string, unknown>;
|
||||
|
@ -611,7 +625,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
cancelDoubleClick(): void;
|
||||
// @internal (undocumented)
|
||||
capturedPointerId: null | number;
|
||||
centerOnPoint(point: VecLike, animation?: TLAnimationOptions): this;
|
||||
centerOnPoint(point: VecLike, opts?: TLCameraMoveOptions): this;
|
||||
clearOpenMenus(): this;
|
||||
// @internal
|
||||
protected _clickManager: ClickManager;
|
||||
|
@ -680,7 +694,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
getAsset(asset: TLAsset | TLAssetId): TLAsset | undefined;
|
||||
getAssetForExternalContent(info: TLExternalAssetContent): Promise<TLAsset | undefined>;
|
||||
getAssets(): (TLBookmarkAsset | TLImageAsset | TLVideoAsset)[];
|
||||
getBaseZoom(): number;
|
||||
getCamera(): TLCamera;
|
||||
getCameraOptions(): TLCameraOptions;
|
||||
getCameraState(): "idle" | "moving";
|
||||
getCanRedo(): boolean;
|
||||
getCanUndo(): boolean;
|
||||
|
@ -718,6 +734,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
getHoveredShape(): TLShape | undefined;
|
||||
getHoveredShapeId(): null | TLShapeId;
|
||||
getInitialMetaForShape(_shape: TLShape): JsonObject;
|
||||
getInitialZoom(): number;
|
||||
getInstanceState(): TLInstance;
|
||||
getIsMenuOpen(): boolean;
|
||||
getOnlySelectedShape(): null | TLShape;
|
||||
|
@ -731,7 +748,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
getPath(): string;
|
||||
getPointInParentSpace(shape: TLShape | TLShapeId, point: VecLike): Vec;
|
||||
getPointInShapeSpace(shape: TLShape | TLShapeId, point: VecLike): Vec;
|
||||
getRenderingBounds(): Box;
|
||||
getRenderingShapes(): {
|
||||
backgroundIndex: number;
|
||||
id: TLShapeId;
|
||||
|
@ -807,7 +823,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
util: ShapeUtil;
|
||||
}[];
|
||||
getViewportPageBounds(): Box;
|
||||
getViewportPageCenter(): Vec;
|
||||
getViewportScreenBounds(): Box;
|
||||
getViewportScreenCenter(): Vec;
|
||||
getZoomLevel(): number;
|
||||
|
@ -853,18 +868,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
moveShapesToPage(shapes: TLShape[] | TLShapeId[], pageId: TLPageId): this;
|
||||
nudgeShapes(shapes: TLShape[] | TLShapeId[], offset: VecLike): this;
|
||||
packShapes(shapes: TLShape[] | TLShapeId[], gap: number): this;
|
||||
pageToScreen(point: VecLike): {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
pageToViewport(point: VecLike): {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
pan(offset: VecLike, animation?: TLAnimationOptions): this;
|
||||
panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this;
|
||||
pageToScreen(point: VecLike): Vec;
|
||||
pageToViewport(point: VecLike): Vec;
|
||||
popFocusedGroupId(): this;
|
||||
putContentOntoCurrentPage(content: TLContent, options?: {
|
||||
point?: VecLike;
|
||||
|
@ -881,24 +886,20 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
type: T;
|
||||
} : TLExternalContent) => void) | null): this;
|
||||
renamePage(page: TLPage | TLPageId, name: string): this;
|
||||
renderingBoundsMargin: number;
|
||||
reparentShapes(shapes: TLShape[] | TLShapeId[], parentId: TLParentId, insertIndex?: IndexKey): this;
|
||||
resetZoom(point?: Vec, animation?: TLAnimationOptions): this;
|
||||
resetZoom(point?: Vec, opts?: TLCameraMoveOptions): this;
|
||||
resizeShape(shape: TLShape | TLShapeId, scale: VecLike, options?: TLResizeShapeOptions): this;
|
||||
readonly root: RootState;
|
||||
rotateShapesBy(shapes: TLShape[] | TLShapeId[], delta: number): this;
|
||||
screenToPage(point: VecLike): {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
screenToPage(point: VecLike): Vec;
|
||||
readonly scribbles: ScribbleManager;
|
||||
select(...shapes: TLShape[] | TLShapeId[]): this;
|
||||
selectAll(): this;
|
||||
selectNone(): this;
|
||||
sendBackward(shapes: TLShape[] | TLShapeId[]): this;
|
||||
sendToBack(shapes: TLShape[] | TLShapeId[]): this;
|
||||
setCamera(point: VecLike, animation?: TLAnimationOptions): this;
|
||||
setCamera(point: VecLike, opts?: TLCameraMoveOptions): this;
|
||||
setCameraOptions(options: Partial<TLCameraOptions>, opts?: TLCameraMoveOptions): this;
|
||||
setCroppingShape(shape: null | TLShape | TLShapeId): this;
|
||||
setCurrentPage(page: TLPage | TLPageId): this;
|
||||
setCurrentTool(id: string, info?: {}): this;
|
||||
|
@ -947,22 +948,20 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
updateDocumentSettings(settings: Partial<TLDocument>): this;
|
||||
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLHistoryBatchOptions): this;
|
||||
updatePage(partial: RequiredKeys<TLPage, 'id'>): this;
|
||||
// @internal
|
||||
updateRenderingBounds(): this;
|
||||
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined): this;
|
||||
updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[]): this;
|
||||
updateViewportScreenBounds(screenBounds: Box, center?: boolean): this;
|
||||
readonly user: UserPreferencesManager;
|
||||
visitDescendants(parent: TLPage | TLParentId | TLShape, visitor: (id: TLShapeId) => false | void): this;
|
||||
zoomIn(point?: Vec, animation?: TLAnimationOptions): this;
|
||||
zoomOut(point?: Vec, animation?: TLAnimationOptions): this;
|
||||
zoomToBounds(bounds: Box, opts?: {
|
||||
zoomIn(point?: Vec, opts?: TLCameraMoveOptions): this;
|
||||
zoomOut(point?: Vec, opts?: TLCameraMoveOptions): this;
|
||||
zoomToBounds(bounds: BoxLike, opts?: {
|
||||
inset?: number;
|
||||
targetZoom?: number;
|
||||
} & TLAnimationOptions): this;
|
||||
zoomToContent(opts?: TLAnimationOptions): this;
|
||||
zoomToFit(animation?: TLAnimationOptions): this;
|
||||
zoomToSelection(animation?: TLAnimationOptions): this;
|
||||
} & TLCameraMoveOptions): this;
|
||||
zoomToFit(opts?: TLCameraMoveOptions): this;
|
||||
zoomToSelection(opts?: TLCameraMoveOptions): this;
|
||||
zoomToUser(userId: string, opts?: TLCameraMoveOptions): this;
|
||||
}
|
||||
|
||||
// @internal (undocumented)
|
||||
|
@ -1211,9 +1210,6 @@ export function hardReset({ shouldReload }?: {
|
|||
// @public (undocumented)
|
||||
export function hardResetEditor(): void;
|
||||
|
||||
// @internal (undocumented)
|
||||
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string>;
|
||||
|
||||
// @public (undocumented)
|
||||
export class HistoryManager<R extends UnknownRecord> {
|
||||
constructor(opts: {
|
||||
|
@ -1449,12 +1445,6 @@ export const MAX_PAGES = 40;
|
|||
// @internal (undocumented)
|
||||
export const MAX_SHAPES_PER_PAGE = 2000;
|
||||
|
||||
// @internal (undocumented)
|
||||
export const MAX_ZOOM = 8;
|
||||
|
||||
// @internal (undocumented)
|
||||
export const MIN_ZOOM = 0.1;
|
||||
|
||||
// @public
|
||||
export function moveCameraWhenCloseToEdge(editor: Editor): void;
|
||||
|
||||
|
@ -1980,12 +1970,6 @@ export type TLAfterCreateHandler<R extends TLRecord> = (record: R, source: 'remo
|
|||
// @public (undocumented)
|
||||
export type TLAfterDeleteHandler<R extends TLRecord> = (record: R, source: 'remote' | 'user') => void;
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLAnimationOptions = Partial<{
|
||||
duration: number;
|
||||
easing: (t: number) => number;
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLAnyShapeUtilConstructor = TLShapeUtilConstructor<any>;
|
||||
|
||||
|
@ -2068,6 +2052,37 @@ export type TLBrushProps = {
|
|||
opacity?: number;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLCameraMoveOptions = Partial<{
|
||||
animation: Partial<{
|
||||
easing: (t: number) => number;
|
||||
duration: number;
|
||||
}>;
|
||||
force: boolean;
|
||||
immediate: boolean;
|
||||
reset: boolean;
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLCameraOptions = {
|
||||
wheelBehavior: 'none' | 'pan' | 'zoom';
|
||||
constraints?: {
|
||||
behavior: 'contain' | 'fixed' | 'free' | 'inside' | 'outside' | {
|
||||
x: 'contain' | 'fixed' | 'free' | 'inside' | 'outside';
|
||||
y: 'contain' | 'fixed' | 'free' | 'inside' | 'outside';
|
||||
};
|
||||
bounds: BoxModel;
|
||||
baseZoom: 'default' | 'fit-max-100' | 'fit-max' | 'fit-min-100' | 'fit-min' | 'fit-x-100' | 'fit-x' | 'fit-y-100' | 'fit-y';
|
||||
initialZoom: 'default' | 'fit-max-100' | 'fit-max' | 'fit-min-100' | 'fit-min' | 'fit-x-100' | 'fit-x' | 'fit-y-100' | 'fit-y';
|
||||
origin: VecLike;
|
||||
padding: VecLike;
|
||||
};
|
||||
panSpeed: number;
|
||||
zoomSpeed: number;
|
||||
zoomSteps: number[];
|
||||
isLocked: boolean;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLCancelEvent = (info: TLCancelEventInfo) => void;
|
||||
|
||||
|
@ -2140,6 +2155,7 @@ export const TldrawEditor: React_2.NamedExoticComponent<TldrawEditorProps>;
|
|||
// @public
|
||||
export interface TldrawEditorBaseProps {
|
||||
autoFocus?: boolean;
|
||||
cameraOptions?: Partial<TLCameraOptions>;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
components?: TLEditorComponents;
|
||||
|
@ -2171,6 +2187,7 @@ export type TLEditorComponents = Partial<{
|
|||
|
||||
// @public (undocumented)
|
||||
export interface TLEditorOptions {
|
||||
cameraOptions?: Partial<TLCameraOptions>;
|
||||
getContainer: () => HTMLElement;
|
||||
inferDarkMode?: boolean;
|
||||
initialState?: string;
|
||||
|
@ -3069,9 +3086,6 @@ export class WeakMapCache<T extends object, K> {
|
|||
|
||||
export { whyAmIRunning }
|
||||
|
||||
// @internal (undocumented)
|
||||
export const ZOOMS: number[];
|
||||
|
||||
|
||||
export * from "@tldraw/store";
|
||||
export * from "@tldraw/tlschema";
|
||||
|
|
|
@ -110,26 +110,18 @@ export {
|
|||
ANIMATION_SHORT_MS,
|
||||
CAMERA_SLIDE_FRICTION,
|
||||
DEFAULT_ANIMATION_OPTIONS,
|
||||
DEFAULT_CAMERA_OPTIONS,
|
||||
DOUBLE_CLICK_DURATION,
|
||||
DRAG_DISTANCE,
|
||||
GRID_STEPS,
|
||||
HASH_PATTERN_ZOOM_NAMES,
|
||||
HIT_TEST_MARGIN,
|
||||
MAX_PAGES,
|
||||
MAX_SHAPES_PER_PAGE,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
MULTI_CLICK_DURATION,
|
||||
SIDES,
|
||||
SVG_PADDING,
|
||||
ZOOMS,
|
||||
} from './lib/constants'
|
||||
export {
|
||||
Editor,
|
||||
type TLAnimationOptions,
|
||||
type TLEditorOptions,
|
||||
type TLResizeShapeOptions,
|
||||
} from './lib/editor/Editor'
|
||||
export { Editor, type TLEditorOptions, type TLResizeShapeOptions } from './lib/editor/Editor'
|
||||
export { HistoryManager } from './lib/editor/managers/HistoryManager'
|
||||
export type {
|
||||
SideEffectManager,
|
||||
|
@ -235,7 +227,12 @@ export {
|
|||
type TLExternalContent,
|
||||
type TLExternalContentSource,
|
||||
} from './lib/editor/types/external-content'
|
||||
export { type RequiredKeys, type TLSvgOptions } from './lib/editor/types/misc-types'
|
||||
export {
|
||||
type RequiredKeys,
|
||||
type TLCameraMoveOptions,
|
||||
type TLCameraOptions,
|
||||
type TLSvgOptions,
|
||||
} from './lib/editor/types/misc-types'
|
||||
export { type TLResizeHandle, type TLSelectionHandle } from './lib/editor/types/selection-types'
|
||||
export { ContainerProvider, useContainer } from './lib/hooks/useContainer'
|
||||
export { getCursor } from './lib/hooks/useCursor'
|
||||
|
|
|
@ -18,6 +18,7 @@ import { TLUser, createTLUser } from './config/createTLUser'
|
|||
import { TLAnyShapeUtilConstructor } from './config/defaultShapes'
|
||||
import { Editor } from './editor/Editor'
|
||||
import { TLStateNodeConstructor } from './editor/tools/StateNode'
|
||||
import { TLCameraOptions } from './editor/types/misc-types'
|
||||
import { ContainerProvider, useContainer } from './hooks/useContainer'
|
||||
import { useCursor } from './hooks/useCursor'
|
||||
import { useDarkMode } from './hooks/useDarkMode'
|
||||
|
@ -114,6 +115,11 @@ export interface TldrawEditorBaseProps {
|
|||
* Whether to infer dark mode from the user's OS. Defaults to false.
|
||||
*/
|
||||
inferDarkMode?: boolean
|
||||
|
||||
/**
|
||||
* Camera options for the editor.
|
||||
*/
|
||||
cameraOptions?: Partial<TLCameraOptions>
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -266,6 +272,7 @@ function TldrawEditorWithReadyStore({
|
|||
initialState,
|
||||
autoFocus = true,
|
||||
inferDarkMode,
|
||||
cameraOptions,
|
||||
}: Required<
|
||||
TldrawEditorProps & {
|
||||
store: TLStore
|
||||
|
@ -286,13 +293,14 @@ function TldrawEditorWithReadyStore({
|
|||
user,
|
||||
initialState,
|
||||
inferDarkMode,
|
||||
cameraOptions,
|
||||
})
|
||||
setEditor(editor)
|
||||
|
||||
return () => {
|
||||
editor.dispose()
|
||||
}
|
||||
}, [container, shapeUtils, tools, store, user, initialState, inferDarkMode])
|
||||
}, [container, shapeUtils, tools, store, user, initialState, inferDarkMode, cameraOptions])
|
||||
|
||||
const crashingError = useSyncExternalStore(
|
||||
useCallback(
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { TLCameraOptions } from './editor/types/misc-types'
|
||||
import { EASINGS } from './primitives/easings'
|
||||
|
||||
/** @internal */
|
||||
|
@ -11,13 +12,14 @@ export const ANIMATION_SHORT_MS = 80
|
|||
export const ANIMATION_MEDIUM_MS = 320
|
||||
|
||||
/** @internal */
|
||||
export const ZOOMS = [0.1, 0.25, 0.5, 1, 2, 4, 8]
|
||||
/** @internal */
|
||||
export const MIN_ZOOM = 0.1
|
||||
/** @internal */
|
||||
export const MAX_ZOOM = 8
|
||||
export const DEFAULT_CAMERA_OPTIONS: TLCameraOptions = {
|
||||
isLocked: false,
|
||||
wheelBehavior: 'pan',
|
||||
panSpeed: 1,
|
||||
zoomSpeed: 1,
|
||||
zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8],
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const FOLLOW_CHASE_PROPORTION = 0.5
|
||||
/** @internal */
|
||||
export const FOLLOW_CHASE_PAN_SNAP = 0.1
|
||||
|
@ -42,14 +44,6 @@ export const DRAG_DISTANCE = 16 // 4 squared
|
|||
/** @internal */
|
||||
export const SVG_PADDING = 32
|
||||
|
||||
/** @internal */
|
||||
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string> = {}
|
||||
|
||||
for (let zoom = 1; zoom <= Math.ceil(MAX_ZOOM); zoom++) {
|
||||
HASH_PATTERN_ZOOM_NAMES[zoom + '_dark'] = `hash_pattern_zoom_${zoom}_dark`
|
||||
HASH_PATTERN_ZOOM_NAMES[zoom + '_light'] = `hash_pattern_zoom_${zoom}_light`
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const DEFAULT_ANIMATION_OPTIONS = {
|
||||
duration: 0,
|
||||
|
@ -113,3 +107,8 @@ export const LONG_PRESS_DURATION = 500
|
|||
|
||||
/** @internal */
|
||||
export const TEXT_SHADOW_LOD = 0.35
|
||||
|
||||
export const LEFT_MOUSE_BUTTON = 0
|
||||
export const RIGHT_MOUSE_BUTTON = 2
|
||||
export const MIDDLE_MOUSE_BUTTON = 1
|
||||
export const STYLUS_ERASER_BUTTON = 5
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -20,8 +20,6 @@ function isShapeNotVisible(editor: Editor, id: TLShapeId, viewportPageBounds: Bo
|
|||
* @returns Incremental derivation of non visible shapes.
|
||||
*/
|
||||
export const notVisibleShapes = (editor: Editor) => {
|
||||
const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin)
|
||||
|
||||
function fromScratch(editor: Editor): Set<TLShapeId> {
|
||||
const shapes = editor.getCurrentPageShapeIds()
|
||||
const viewportPageBounds = editor.getViewportPageBounds()
|
||||
|
@ -34,8 +32,6 @@ export const notVisibleShapes = (editor: Editor) => {
|
|||
return notVisibleShapes
|
||||
}
|
||||
return computed<Set<TLShapeId>>('getCulledShapes', (prevValue) => {
|
||||
if (!isCullingOffScreenShapes) return new Set<TLShapeId>()
|
||||
|
||||
if (isUninitialized(prevValue)) {
|
||||
return fromScratch(editor)
|
||||
}
|
||||
|
|
|
@ -95,116 +95,118 @@ export class ClickManager {
|
|||
|
||||
lastPointerInfo = {} as TLPointerEventInfo
|
||||
|
||||
/**
|
||||
* Start the double click timeout.
|
||||
*
|
||||
* @param info - The event info.
|
||||
*/
|
||||
transformPointerDownEvent = (info: TLPointerEventInfo): TLPointerEventInfo | TLClickEventInfo => {
|
||||
if (!this._clickState) return info
|
||||
handlePointerEvent = (info: TLPointerEventInfo): TLPointerEventInfo | TLClickEventInfo => {
|
||||
switch (info.name) {
|
||||
case 'pointer_down': {
|
||||
if (!this._clickState) return info
|
||||
this._clickScreenPoint = Vec.From(info.point)
|
||||
|
||||
this._clickScreenPoint = Vec.From(info.point)
|
||||
|
||||
if (
|
||||
this._previousScreenPoint &&
|
||||
this._previousScreenPoint.dist(this._clickScreenPoint) > MAX_CLICK_DISTANCE
|
||||
) {
|
||||
this._clickState = 'idle'
|
||||
}
|
||||
|
||||
this._previousScreenPoint = this._clickScreenPoint
|
||||
|
||||
this.lastPointerInfo = info
|
||||
|
||||
switch (this._clickState) {
|
||||
case 'idle': {
|
||||
this._clickState = 'pendingDouble'
|
||||
this._clickTimeout = this._getClickTimeout(this._clickState)
|
||||
return info // returns the pointer event
|
||||
}
|
||||
case 'pendingDouble': {
|
||||
this._clickState = 'pendingTriple'
|
||||
this._clickTimeout = this._getClickTimeout(this._clickState)
|
||||
return {
|
||||
...info,
|
||||
type: 'click',
|
||||
name: 'double_click',
|
||||
phase: 'down',
|
||||
if (
|
||||
this._previousScreenPoint &&
|
||||
Vec.Dist2(this._previousScreenPoint, this._clickScreenPoint) > MAX_CLICK_DISTANCE ** 2
|
||||
) {
|
||||
this._clickState = 'idle'
|
||||
}
|
||||
}
|
||||
case 'pendingTriple': {
|
||||
this._clickState = 'pendingQuadruple'
|
||||
this._clickTimeout = this._getClickTimeout(this._clickState)
|
||||
return {
|
||||
...info,
|
||||
type: 'click',
|
||||
name: 'triple_click',
|
||||
phase: 'down',
|
||||
|
||||
this._previousScreenPoint = this._clickScreenPoint
|
||||
|
||||
this.lastPointerInfo = info
|
||||
|
||||
switch (this._clickState) {
|
||||
case 'pendingDouble': {
|
||||
this._clickState = 'pendingTriple'
|
||||
this._clickTimeout = this._getClickTimeout(this._clickState)
|
||||
return {
|
||||
...info,
|
||||
type: 'click',
|
||||
name: 'double_click',
|
||||
phase: 'down',
|
||||
}
|
||||
}
|
||||
case 'pendingTriple': {
|
||||
this._clickState = 'pendingQuadruple'
|
||||
this._clickTimeout = this._getClickTimeout(this._clickState)
|
||||
return {
|
||||
...info,
|
||||
type: 'click',
|
||||
name: 'triple_click',
|
||||
phase: 'down',
|
||||
}
|
||||
}
|
||||
case 'pendingQuadruple': {
|
||||
this._clickState = 'pendingOverflow'
|
||||
this._clickTimeout = this._getClickTimeout(this._clickState)
|
||||
return {
|
||||
...info,
|
||||
type: 'click',
|
||||
name: 'quadruple_click',
|
||||
phase: 'down',
|
||||
}
|
||||
}
|
||||
case 'idle': {
|
||||
this._clickState = 'pendingDouble'
|
||||
break
|
||||
}
|
||||
case 'pendingOverflow': {
|
||||
this._clickState = 'overflow'
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// overflow
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'pendingQuadruple': {
|
||||
this._clickState = 'pendingOverflow'
|
||||
this._clickTimeout = this._getClickTimeout(this._clickState)
|
||||
return {
|
||||
...info,
|
||||
type: 'click',
|
||||
name: 'quadruple_click',
|
||||
phase: 'down',
|
||||
}
|
||||
}
|
||||
case 'pendingOverflow': {
|
||||
this._clickState = 'overflow'
|
||||
this._clickTimeout = this._getClickTimeout(this._clickState)
|
||||
return info
|
||||
}
|
||||
default: {
|
||||
// overflow
|
||||
this._clickTimeout = this._getClickTimeout(this._clickState)
|
||||
return info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit click_up events on pointer up.
|
||||
*
|
||||
* @param info - The event info.
|
||||
*/
|
||||
transformPointerUpEvent = (info: TLPointerEventInfo): TLPointerEventInfo | TLClickEventInfo => {
|
||||
if (!this._clickState) return info
|
||||
|
||||
this._clickScreenPoint = Vec.From(info.point)
|
||||
|
||||
switch (this._clickState) {
|
||||
case 'pendingTriple': {
|
||||
return {
|
||||
...this.lastPointerInfo,
|
||||
type: 'click',
|
||||
name: 'double_click',
|
||||
phase: 'up',
|
||||
}
|
||||
}
|
||||
case 'pendingQuadruple': {
|
||||
return {
|
||||
...this.lastPointerInfo,
|
||||
type: 'click',
|
||||
name: 'triple_click',
|
||||
phase: 'up',
|
||||
}
|
||||
}
|
||||
case 'pendingOverflow': {
|
||||
return {
|
||||
...this.lastPointerInfo,
|
||||
type: 'click',
|
||||
name: 'quadruple_click',
|
||||
phase: 'up',
|
||||
}
|
||||
}
|
||||
default: {
|
||||
// idle, pendingDouble, overflow
|
||||
case 'pointer_up': {
|
||||
if (!this._clickState) return info
|
||||
this._clickScreenPoint = Vec.From(info.point)
|
||||
|
||||
switch (this._clickState) {
|
||||
case 'pendingTriple': {
|
||||
return {
|
||||
...this.lastPointerInfo,
|
||||
type: 'click',
|
||||
name: 'double_click',
|
||||
phase: 'up',
|
||||
}
|
||||
}
|
||||
case 'pendingQuadruple': {
|
||||
return {
|
||||
...this.lastPointerInfo,
|
||||
type: 'click',
|
||||
name: 'triple_click',
|
||||
phase: 'up',
|
||||
}
|
||||
}
|
||||
case 'pendingOverflow': {
|
||||
return {
|
||||
...this.lastPointerInfo,
|
||||
type: 'click',
|
||||
name: 'quadruple_click',
|
||||
phase: 'up',
|
||||
}
|
||||
}
|
||||
default: {
|
||||
// idle, pendingDouble, overflow
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
case 'pointer_move': {
|
||||
if (
|
||||
this._clickState !== 'idle' &&
|
||||
this._clickScreenPoint &&
|
||||
Vec.Dist2(this._clickScreenPoint, this.editor.inputs.currentScreenPoint) >
|
||||
(this.editor.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE)
|
||||
) {
|
||||
this.cancelDoubleClickTimeout()
|
||||
}
|
||||
return info
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -216,21 +218,4 @@ export class ClickManager {
|
|||
this._clickTimeout = clearTimeout(this._clickTimeout)
|
||||
this._clickState = 'idle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a move event, possibly cancelling the click timeout.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
handleMove = () => {
|
||||
// Cancel a double click event if the user has started dragging.
|
||||
if (
|
||||
this._clickState !== 'idle' &&
|
||||
this._clickScreenPoint &&
|
||||
Vec.Dist2(this._clickScreenPoint, this.editor.inputs.currentScreenPoint) >
|
||||
(this.editor.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE)
|
||||
) {
|
||||
this.cancelDoubleClickTimeout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,7 +64,7 @@ export class SnapManager {
|
|||
// TODO: make this an incremental derivation
|
||||
@computed getSnappableShapes(): Set<TLShapeId> {
|
||||
const { editor } = this
|
||||
const renderingBounds = editor.getRenderingBounds()
|
||||
const renderingBounds = editor.getViewportPageBounds()
|
||||
const selectedShapeIds = editor.getSelectedShapeIds()
|
||||
|
||||
const snappableShapes: Set<TLShapeId> = new Set()
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { BoxModel } from '@tldraw/tlschema'
|
||||
import { Box } from '../../primitives/Box'
|
||||
import { VecLike } from '../../primitives/Vec'
|
||||
|
||||
/** @public */
|
||||
export type RequiredKeys<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>
|
||||
|
@ -14,3 +16,110 @@ export type TLSvgOptions = {
|
|||
darkMode?: boolean
|
||||
preserveAspectRatio: React.SVGAttributes<SVGSVGElement>['preserveAspectRatio']
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type TLCameraMoveOptions = Partial<{
|
||||
/** Whether to move the camera immediately, rather than on the next tick. */
|
||||
immediate: boolean
|
||||
/** Whether to force the camera to move, even if the user's camera options have locked the camera. */
|
||||
force: boolean
|
||||
/** Whether to reset the camera to its default position and zoom. */
|
||||
reset: boolean
|
||||
/** An (optional) animation to use. */
|
||||
animation: Partial<{
|
||||
/** The time the animation should take to arrive at the specified camera coordinates. */
|
||||
duration: number
|
||||
/** An easing function to apply to the animation's progress from start to end. */
|
||||
easing: (t: number) => number
|
||||
}>
|
||||
}>
|
||||
|
||||
/** @public */
|
||||
export type TLCameraOptions = {
|
||||
/** Whether the camera is locked. */
|
||||
isLocked: boolean
|
||||
/** The speed of a scroll wheel / trackpad pan. Default is 1. */
|
||||
panSpeed: number
|
||||
/** The speed of a scroll wheel / trackpad zoom. Default is 1. */
|
||||
zoomSpeed: number
|
||||
/** The steps that a user can zoom between with zoom in / zoom out. The first and last value will determine the min and max zoom. */
|
||||
zoomSteps: number[]
|
||||
/** Controls whether the wheel pans or zooms.
|
||||
*
|
||||
* - `zoom`: The wheel will zoom in and out.
|
||||
* - `pan`: The wheel will pan the camera.
|
||||
* - `none`: The wheel will do nothing.
|
||||
*/
|
||||
wheelBehavior: 'zoom' | 'pan' | 'none'
|
||||
/** The camera constraints. */
|
||||
constraints?: {
|
||||
/** The bounds (in page space) of the constrained space */
|
||||
bounds: BoxModel
|
||||
/** The padding inside of the viewport (in screen space) */
|
||||
padding: VecLike
|
||||
/** The origin for placement. Used to position the bounds within the viewport when an axis is fixed or contained and zoom is below the axis fit. */
|
||||
origin: VecLike
|
||||
/** The camera's initial zoom, used also when the camera is reset.
|
||||
*
|
||||
* - `default`: Sets the initial zoom to 100%.
|
||||
* - `fit-x`: The x axis will completely fill the viewport bounds.
|
||||
* - `fit-y`: The y axis will completely fill the viewport bounds.
|
||||
* - `fit-min`: The smaller axis will completely fill the viewport bounds.
|
||||
* - `fit-max`: The larger axis will completely fill the viewport bounds.
|
||||
* - `fit-x-100`: The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
|
||||
* - `fit-y-100`: The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
|
||||
* - `fit-min-100`: The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
|
||||
* - `fit-max-100`: The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
|
||||
*/
|
||||
initialZoom:
|
||||
| 'fit-min'
|
||||
| 'fit-max'
|
||||
| 'fit-x'
|
||||
| 'fit-y'
|
||||
| 'fit-min-100'
|
||||
| 'fit-max-100'
|
||||
| 'fit-x-100'
|
||||
| 'fit-y-100'
|
||||
| 'default'
|
||||
/** The camera's base for its zoom steps.
|
||||
*
|
||||
* - `default`: Sets the initial zoom to 100%.
|
||||
* - `fit-x`: The x axis will completely fill the viewport bounds.
|
||||
* - `fit-y`: The y axis will completely fill the viewport bounds.
|
||||
* - `fit-min`: The smaller axis will completely fill the viewport bounds.
|
||||
* - `fit-max`: The larger axis will completely fill the viewport bounds.
|
||||
* - `fit-x-100`: The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
|
||||
* - `fit-y-100`: The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
|
||||
* - `fit-min-100`: The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
|
||||
* - `fit-max-100`: The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
|
||||
*/
|
||||
baseZoom:
|
||||
| 'fit-min'
|
||||
| 'fit-max'
|
||||
| 'fit-x'
|
||||
| 'fit-y'
|
||||
| 'fit-min-100'
|
||||
| 'fit-max-100'
|
||||
| 'fit-x-100'
|
||||
| 'fit-y-100'
|
||||
| 'default'
|
||||
/** The behavior for the constraints for both axes or each axis individually.
|
||||
*
|
||||
* - `free`: The bounds are ignored when moving the camera.
|
||||
* - 'fixed': The bounds will be positioned within the viewport based on the origin
|
||||
* - `contain`: The 'fixed' behavior will be used when the zoom is below the zoom level at which the bounds would fill the viewport; and when above this zoom, the bounds will use the 'inside' behavior.
|
||||
* - `inside`: The bounds will stay completely within the viewport.
|
||||
* - `outside`: The bounds will stay touching the viewport.
|
||||
*/
|
||||
behavior:
|
||||
| 'free'
|
||||
| 'fixed'
|
||||
| 'inside'
|
||||
| 'outside'
|
||||
| 'contain'
|
||||
| {
|
||||
x: 'free' | 'fixed' | 'inside' | 'outside' | 'contain'
|
||||
y: 'free' | 'fixed' | 'inside' | 'outside' | 'contain'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import { RIGHT_MOUSE_BUTTON } from '../constants'
|
||||
import {
|
||||
preventDefault,
|
||||
releasePointerCapture,
|
||||
|
@ -19,7 +20,7 @@ export function useCanvasEvents() {
|
|||
function onPointerDown(e: React.PointerEvent) {
|
||||
if ((e as any).isKilled) return
|
||||
|
||||
if (e.button === 2) {
|
||||
if (e.button === RIGHT_MOUSE_BUTTON) {
|
||||
editor.dispatch({
|
||||
type: 'pointer',
|
||||
target: 'canvas',
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo } from 'react'
|
||||
import { RIGHT_MOUSE_BUTTON } from '../constants'
|
||||
import { TLSelectionHandle } from '../editor/types/selection-types'
|
||||
import {
|
||||
loopToHtmlElement,
|
||||
|
@ -18,7 +19,7 @@ export function useSelectionEvents(handle: TLSelectionHandle) {
|
|||
const onPointerDown: React.PointerEventHandler = (e) => {
|
||||
if ((e as any).isKilled) return
|
||||
|
||||
if (e.button === 2) {
|
||||
if (e.button === RIGHT_MOUSE_BUTTON) {
|
||||
editor.dispatch({
|
||||
type: 'pointer',
|
||||
target: 'selection',
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { COARSE_POINTER_WIDTH, EDGE_SCROLL_DISTANCE, EDGE_SCROLL_SPEED } from '../constants'
|
||||
import { Editor } from '../editor/Editor'
|
||||
import { Vec } from '../primitives/Vec'
|
||||
|
||||
/**
|
||||
* Helper function to get the scroll proximity factor for a given position.
|
||||
|
@ -33,11 +34,7 @@ function getEdgeProximityFactor(
|
|||
* @public
|
||||
*/
|
||||
export function moveCameraWhenCloseToEdge(editor: Editor) {
|
||||
if (
|
||||
!editor.inputs.isDragging ||
|
||||
editor.inputs.isPanning ||
|
||||
!editor.getInstanceState().canMoveCamera
|
||||
)
|
||||
if (!editor.inputs.isDragging || editor.inputs.isPanning || editor.getCameraOptions().isLocked)
|
||||
return
|
||||
|
||||
const {
|
||||
|
@ -68,8 +65,5 @@ export function moveCameraWhenCloseToEdge(editor: Editor) {
|
|||
|
||||
const camera = editor.getCamera()
|
||||
|
||||
editor.setCamera({
|
||||
x: camera.x + scrollDeltaX,
|
||||
y: camera.y + scrollDeltaY,
|
||||
})
|
||||
editor.setCamera(new Vec(camera.x + scrollDeltaX, camera.y + scrollDeltaY, camera.z))
|
||||
}
|
||||
|
|
|
@ -11,17 +11,9 @@ export function normalizeWheel(event: WheelEvent | React.WheelEvent<HTMLElement>
|
|||
let { deltaY, deltaX } = event
|
||||
let deltaZ = 0
|
||||
|
||||
// wheeling
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
const signY = Math.sign(event.deltaY)
|
||||
const absDeltaY = Math.abs(event.deltaY)
|
||||
|
||||
let dy = deltaY
|
||||
|
||||
if (absDeltaY > MAX_ZOOM_STEP) {
|
||||
dy = MAX_ZOOM_STEP * signY
|
||||
}
|
||||
|
||||
deltaZ = dy / 100
|
||||
deltaZ = (Math.abs(deltaY) > MAX_ZOOM_STEP ? MAX_ZOOM_STEP * Math.sign(deltaY) : deltaY) / 100
|
||||
} else {
|
||||
if (event.shiftKey && !IS_DARWIN) {
|
||||
deltaX = deltaY
|
||||
|
|
|
@ -152,7 +152,9 @@ export function registerDefaultExternalContentHandlers(
|
|||
editor.registerExternalContentHandler('svg-text', async ({ point, text }) => {
|
||||
const position =
|
||||
point ??
|
||||
(editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter())
|
||||
(editor.inputs.shiftKey
|
||||
? editor.inputs.currentPagePoint
|
||||
: editor.getViewportPageBounds().center)
|
||||
|
||||
const svg = new DOMParser().parseFromString(text, 'image/svg+xml').querySelector('svg')
|
||||
if (!svg) {
|
||||
|
@ -185,7 +187,9 @@ export function registerDefaultExternalContentHandlers(
|
|||
editor.registerExternalContentHandler('embed', ({ point, url, embed }) => {
|
||||
const position =
|
||||
point ??
|
||||
(editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter())
|
||||
(editor.inputs.shiftKey
|
||||
? editor.inputs.currentPagePoint
|
||||
: editor.getViewportPageBounds().center)
|
||||
|
||||
const { width, height } = embed
|
||||
|
||||
|
@ -210,7 +214,9 @@ export function registerDefaultExternalContentHandlers(
|
|||
editor.registerExternalContentHandler('files', async ({ point, files }) => {
|
||||
const position =
|
||||
point ??
|
||||
(editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter())
|
||||
(editor.inputs.shiftKey
|
||||
? editor.inputs.currentPagePoint
|
||||
: editor.getViewportPageBounds().center)
|
||||
|
||||
const pagePoint = new Vec(position.x, position.y)
|
||||
|
||||
|
@ -266,7 +272,9 @@ export function registerDefaultExternalContentHandlers(
|
|||
editor.registerExternalContentHandler('text', async ({ point, text }) => {
|
||||
const p =
|
||||
point ??
|
||||
(editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter())
|
||||
(editor.inputs.shiftKey
|
||||
? editor.inputs.currentPagePoint
|
||||
: editor.getViewportPageBounds().center)
|
||||
|
||||
const defaultProps = editor.getShapeUtil<TLTextShape>('text').getDefaultProps()
|
||||
|
||||
|
@ -370,7 +378,9 @@ export function registerDefaultExternalContentHandlers(
|
|||
|
||||
const position =
|
||||
point ??
|
||||
(editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter())
|
||||
(editor.inputs.shiftKey
|
||||
? editor.inputs.currentPagePoint
|
||||
: editor.getViewportPageBounds().center)
|
||||
|
||||
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
|
||||
const shape = createEmptyBookmarkShape(editor, url, position)
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
HASH_PATTERN_ZOOM_NAMES,
|
||||
TLDefaultColorStyle,
|
||||
TLDefaultColorTheme,
|
||||
TLDefaultFillStyle,
|
||||
|
@ -10,6 +9,7 @@ import {
|
|||
useValue,
|
||||
} from '@tldraw/editor'
|
||||
import React from 'react'
|
||||
import { HASH_PATTERN_ZOOM_NAMES } from './defaultStyleDefs'
|
||||
|
||||
export interface ShapeFillProps {
|
||||
d: string
|
||||
|
@ -40,7 +40,7 @@ export const ShapeFill = React.memo(function ShapeFill({ theme, d, color, fill }
|
|||
}
|
||||
})
|
||||
|
||||
const PatternFill = function PatternFill({ d, color, theme }: ShapeFillProps) {
|
||||
export function PatternFill({ d, color, theme }: ShapeFillProps) {
|
||||
const editor = useEditor()
|
||||
const svgExport = useSvgExportContext()
|
||||
const zoomLevel = useValue('zoomLevel', () => editor.getZoomLevel(), [editor])
|
||||
|
|
|
@ -3,8 +3,6 @@ import {
|
|||
DefaultFontFamilies,
|
||||
DefaultFontStyle,
|
||||
FileHelpers,
|
||||
HASH_PATTERN_ZOOM_NAMES,
|
||||
MAX_ZOOM,
|
||||
SvgExportDef,
|
||||
TLDefaultFillStyle,
|
||||
TLDefaultFontStyle,
|
||||
|
@ -15,6 +13,16 @@ import {
|
|||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useDefaultColorTheme } from './ShapeFill'
|
||||
|
||||
/** @internal */
|
||||
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string> = {}
|
||||
|
||||
const HASH_PATTERN_COUNT = 6
|
||||
|
||||
for (let zoom = 1; zoom <= HASH_PATTERN_COUNT; zoom++) {
|
||||
HASH_PATTERN_ZOOM_NAMES[zoom + '_dark'] = `hash_pattern_zoom_${zoom}_dark`
|
||||
HASH_PATTERN_ZOOM_NAMES[zoom + '_light'] = `hash_pattern_zoom_${zoom}_light`
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function getFontDefForExport(fontStyle: TLDefaultFontStyle): SvgExportDef {
|
||||
return {
|
||||
|
@ -148,7 +156,7 @@ type PatternDef = { zoom: number; url: string; darkMode: boolean }
|
|||
|
||||
const getDefaultPatterns = () => {
|
||||
const defaultPatterns: PatternDef[] = []
|
||||
for (let i = 1; i <= Math.ceil(MAX_ZOOM); i++) {
|
||||
for (let i = 1; i <= HASH_PATTERN_COUNT; i++) {
|
||||
const whitePixelBlob = canvasBlob([1, 1], (ctx) => {
|
||||
ctx.fillStyle = DefaultColorThemePalette.lightMode.black.semi
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
|
@ -186,7 +194,7 @@ function usePattern() {
|
|||
|
||||
const promises: Promise<{ zoom: number; url: string; darkMode: boolean }>[] = []
|
||||
|
||||
for (let i = 1; i <= Math.ceil(MAX_ZOOM); i++) {
|
||||
for (let i = 1; i <= HASH_PATTERN_COUNT; i++) {
|
||||
promises.push(
|
||||
generateImage(dpr, i, false).then((blob) => ({
|
||||
zoom: i,
|
||||
|
|
|
@ -12,14 +12,18 @@ export class HandTool extends StateNode {
|
|||
override onDoubleClick: TLClickEvent = (info) => {
|
||||
if (info.phase === 'settle') {
|
||||
const { currentScreenPoint } = this.editor.inputs
|
||||
this.editor.zoomIn(currentScreenPoint, { duration: 220, easing: EASINGS.easeOutQuint })
|
||||
this.editor.zoomIn(currentScreenPoint, {
|
||||
animation: { duration: 220, easing: EASINGS.easeOutQuint },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override onTripleClick: TLClickEvent = (info) => {
|
||||
if (info.phase === 'settle') {
|
||||
const { currentScreenPoint } = this.editor.inputs
|
||||
this.editor.zoomOut(currentScreenPoint, { duration: 320, easing: EASINGS.easeOutQuint })
|
||||
this.editor.zoomOut(currentScreenPoint, {
|
||||
animation: { duration: 320, easing: EASINGS.easeOutQuint },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -31,9 +35,11 @@ export class HandTool extends StateNode {
|
|||
} = this.editor
|
||||
|
||||
if (zoomLevel === 1) {
|
||||
this.editor.zoomToFit({ duration: 400, easing: EASINGS.easeOutQuint })
|
||||
this.editor.zoomToFit({ animation: { duration: 400, easing: EASINGS.easeOutQuint } })
|
||||
} else {
|
||||
this.editor.resetZoom(currentScreenPoint, { duration: 320, easing: EASINGS.easeOutQuint })
|
||||
this.editor.resetZoom(currentScreenPoint, {
|
||||
animation: { duration: 320, easing: EASINGS.easeOutQuint },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -148,7 +148,9 @@ export function zoomToShapeIfOffscreen(editor: Editor) {
|
|||
y: (eb.center.y - viewportPageBounds.center.y) * 2,
|
||||
})
|
||||
editor.zoomToBounds(nextBounds, {
|
||||
duration: ANIMATION_MEDIUM_MS,
|
||||
animation: {
|
||||
duration: ANIMATION_MEDIUM_MS,
|
||||
},
|
||||
inset: 0,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -26,9 +26,9 @@ export class Pointing extends StateNode {
|
|||
private complete() {
|
||||
const { currentScreenPoint } = this.editor.inputs
|
||||
if (this.editor.inputs.altKey) {
|
||||
this.editor.zoomOut(currentScreenPoint, { duration: 220 })
|
||||
this.editor.zoomOut(currentScreenPoint, { animation: { duration: 220 } })
|
||||
} else {
|
||||
this.editor.zoomIn(currentScreenPoint, { duration: 220 })
|
||||
this.editor.zoomIn(currentScreenPoint, { animation: { duration: 220 } })
|
||||
}
|
||||
this.parent.transition('idle', this.info)
|
||||
}
|
||||
|
|
|
@ -48,13 +48,13 @@ export class ZoomBrushing extends StateNode {
|
|||
if (zoomBrush.width < threshold && zoomBrush.height < threshold) {
|
||||
const point = this.editor.inputs.currentScreenPoint
|
||||
if (this.editor.inputs.altKey) {
|
||||
this.editor.zoomOut(point, { duration: 220 })
|
||||
this.editor.zoomOut(point, { animation: { duration: 220 } })
|
||||
} else {
|
||||
this.editor.zoomIn(point, { duration: 220 })
|
||||
this.editor.zoomIn(point, { animation: { duration: 220 } })
|
||||
}
|
||||
} else {
|
||||
const targetZoom = this.editor.inputs.altKey ? this.editor.getZoomLevel() / 2 : undefined
|
||||
this.editor.zoomToBounds(zoomBrush, { targetZoom, duration: 220 })
|
||||
this.editor.zoomToBounds(zoomBrush, { targetZoom, animation: { duration: 220 } })
|
||||
}
|
||||
|
||||
this.parent.transition('idle', this.info)
|
||||
|
|
|
@ -117,7 +117,7 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
|
|||
editor.putExternalContent({
|
||||
type: 'embed',
|
||||
url,
|
||||
point: editor.getViewportPageCenter(),
|
||||
point: editor.getViewportPageBounds().center,
|
||||
embed: embedInfoForUrl.definition,
|
||||
})
|
||||
|
||||
|
|
|
@ -62,7 +62,7 @@ export function DefaultMinimap() {
|
|||
minimapRef.current.originPagePoint.setTo(clampedPoint)
|
||||
minimapRef.current.originPageCenter.setTo(editor.getViewportPageBounds().center)
|
||||
|
||||
editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
|
||||
editor.centerOnPoint(point, { animation: { duration: ANIMATION_MEDIUM_MS } })
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
|
@ -101,7 +101,7 @@ export function DefaultMinimap() {
|
|||
const pagePoint = Vec.Add(point, delta)
|
||||
minimapRef.current.originPagePoint.setTo(pagePoint)
|
||||
minimapRef.current.originPageCenter.setTo(point)
|
||||
editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
|
||||
editor.centerOnPoint(point, { animation: { duration: ANIMATION_MEDIUM_MS } })
|
||||
} else {
|
||||
const clampedPoint = minimapRef.current.minimapScreenPointToPagePoint(
|
||||
e.clientX,
|
||||
|
|
|
@ -55,7 +55,9 @@ const ZoomTriggerButton = forwardRef<HTMLButtonElement, any>(
|
|||
const msg = useTranslation()
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
editor.resetZoom(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS })
|
||||
editor.resetZoom(editor.getViewportScreenCenter(), {
|
||||
animation: { duration: ANIMATION_MEDIUM_MS },
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
|
|
|
@ -503,7 +503,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
} else {
|
||||
ids = editor.getSelectedShapeIds()
|
||||
const commonBounds = Box.Common(compact(ids.map((id) => editor.getShapePageBounds(id))))
|
||||
offset = instanceState.canMoveCamera
|
||||
offset = !editor.getCameraOptions().isLocked
|
||||
? {
|
||||
x: commonBounds.width + 20,
|
||||
y: 0,
|
||||
|
@ -1037,7 +1037,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
readonlyOk: true,
|
||||
onSelect(source) {
|
||||
trackEvent('zoom-in', { source })
|
||||
editor.zoomIn(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS })
|
||||
editor.zoomIn(undefined, {
|
||||
animation: { duration: ANIMATION_MEDIUM_MS },
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -1047,7 +1049,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
readonlyOk: true,
|
||||
onSelect(source) {
|
||||
trackEvent('zoom-out', { source })
|
||||
editor.zoomOut(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS })
|
||||
editor.zoomOut(undefined, {
|
||||
animation: { duration: ANIMATION_MEDIUM_MS },
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -1058,7 +1062,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
readonlyOk: true,
|
||||
onSelect(source) {
|
||||
trackEvent('reset-zoom', { source })
|
||||
editor.resetZoom(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS })
|
||||
editor.resetZoom(undefined, {
|
||||
animation: { duration: ANIMATION_MEDIUM_MS },
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -1068,7 +1074,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
readonlyOk: true,
|
||||
onSelect(source) {
|
||||
trackEvent('zoom-to-fit', { source })
|
||||
editor.zoomToFit({ duration: ANIMATION_MEDIUM_MS })
|
||||
editor.zoomToFit({ animation: { duration: ANIMATION_MEDIUM_MS } })
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -1081,7 +1087,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
if (mustGoBackToSelectToolFirst()) return
|
||||
|
||||
trackEvent('zoom-to-selection', { source })
|
||||
editor.zoomToSelection({ duration: ANIMATION_MEDIUM_MS })
|
||||
editor.zoomToSelection({ animation: { duration: ANIMATION_MEDIUM_MS } })
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -1288,7 +1294,12 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
readonlyOk: true,
|
||||
onSelect(source) {
|
||||
trackEvent('zoom-to-content', { source })
|
||||
editor.zoomToContent()
|
||||
const bounds = editor.getSelectionPageBounds() ?? editor.getCurrentPageBounds()
|
||||
if (!bounds) return
|
||||
editor.zoomToBounds(bounds, {
|
||||
targetZoom: Math.min(1, editor.getZoomLevel()),
|
||||
animation: { duration: 220 },
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -648,6 +648,7 @@ export function useNativeClipboardEvents() {
|
|||
let disablingMiddleClickPaste = false
|
||||
const pointerUpHandler = (e: PointerEvent) => {
|
||||
if (e.button === 1) {
|
||||
// middle mouse button
|
||||
disablingMiddleClickPaste = true
|
||||
requestAnimationFrame(() => {
|
||||
disablingMiddleClickPaste = false
|
||||
|
|
|
@ -305,7 +305,6 @@ export async function parseAndLoadDocument(
|
|||
editor.history.clear()
|
||||
// Put the old bounds back in place
|
||||
editor.updateViewportScreenBounds(initialBounds)
|
||||
editor.updateRenderingBounds()
|
||||
|
||||
const bounds = editor.getCurrentPageBounds()
|
||||
if (bounds) {
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
TLWheelEventInfo,
|
||||
Vec,
|
||||
VecLike,
|
||||
computed,
|
||||
createShapeId,
|
||||
createTLStore,
|
||||
rotateSelectionHandle,
|
||||
|
@ -143,6 +144,15 @@ export class TestEditor extends Editor {
|
|||
elm: HTMLDivElement
|
||||
bounds = { x: 0, y: 0, top: 0, left: 0, width: 1080, height: 720, bottom: 720, right: 1080 }
|
||||
|
||||
/**
|
||||
* The center of the viewport in the current page space.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
@computed getViewportPageCenter() {
|
||||
return this.getViewportPageBounds().center
|
||||
}
|
||||
|
||||
setScreenBounds(bounds: BoxModel, center = false) {
|
||||
this.bounds.x = bounds.x
|
||||
this.bounds.y = bounds.y
|
||||
|
@ -154,7 +164,6 @@ export class TestEditor extends Editor {
|
|||
this.bounds.bottom = bounds.y + bounds.h
|
||||
|
||||
this.updateViewportScreenBounds(Box.From(bounds), center)
|
||||
this.updateRenderingBounds()
|
||||
return this
|
||||
}
|
||||
|
||||
|
@ -200,12 +209,12 @@ export class TestEditor extends Editor {
|
|||
* _transformPointerDownSpy.mockRestore())
|
||||
*/
|
||||
_transformPointerDownSpy = jest
|
||||
.spyOn(this._clickManager, 'transformPointerDownEvent')
|
||||
.spyOn(this._clickManager, 'handlePointerEvent')
|
||||
.mockImplementation((info) => {
|
||||
return info
|
||||
})
|
||||
_transformPointerUpSpy = jest
|
||||
.spyOn(this._clickManager, 'transformPointerDownEvent')
|
||||
.spyOn(this._clickManager, 'handlePointerEvent')
|
||||
.mockImplementation((info) => {
|
||||
return info
|
||||
})
|
||||
|
@ -474,6 +483,16 @@ export class TestEditor extends Editor {
|
|||
return this
|
||||
}
|
||||
|
||||
pan(offset: VecLike): this {
|
||||
const { isLocked, panSpeed } = this.getCameraOptions()
|
||||
if (isLocked) return this
|
||||
const { x: cx, y: cy, z: cz } = this.getCamera()
|
||||
this.setCamera(new Vec(cx + (offset.x * panSpeed) / cz, cy + (offset.y * panSpeed) / cz, cz), {
|
||||
immediate: true,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
pinchStart = (
|
||||
x = this.inputs.currentScreenPoint.x,
|
||||
y = this.inputs.currentScreenPoint.y,
|
||||
|
|
|
@ -11,7 +11,7 @@ jest.useFakeTimers()
|
|||
it('zooms in gradually when duration is present and animtion speed is default', () => {
|
||||
expect(editor.getZoomLevel()).toBe(1)
|
||||
editor.user.updateUserPreferences({ animationSpeed: 1 }) // default
|
||||
editor.zoomIn(undefined, { duration: 100 })
|
||||
editor.zoomIn(undefined, { animation: { duration: 100 } })
|
||||
editor.emit('tick', 25) // <-- quarter way
|
||||
expect(editor.getZoomLevel()).not.toBe(2)
|
||||
editor.emit('tick', 25) // 50 <-- half way
|
||||
|
@ -23,14 +23,14 @@ it('zooms in gradually when duration is present and animtion speed is default',
|
|||
it('zooms in gradually when duration is present and animtion speed is off', () => {
|
||||
expect(editor.getZoomLevel()).toBe(1)
|
||||
editor.user.updateUserPreferences({ animationSpeed: 0 }) // none
|
||||
editor.zoomIn(undefined, { duration: 100 })
|
||||
editor.zoomIn(undefined, { animation: { duration: 100 } })
|
||||
expect(editor.getZoomLevel()).toBe(2) // <-- Should skip!
|
||||
})
|
||||
|
||||
it('zooms in gradually when duration is present and animtion speed is double', () => {
|
||||
expect(editor.getZoomLevel()).toBe(1)
|
||||
editor.user.updateUserPreferences({ animationSpeed: 2 }) // default
|
||||
editor.zoomIn(undefined, { duration: 100 })
|
||||
editor.zoomIn(undefined, { animation: { duration: 100 } })
|
||||
editor.emit('tick', 25) // <-- half way
|
||||
expect(editor.getZoomLevel()).not.toBe(2)
|
||||
editor.emit('tick', 25) // 50 <-- should finish
|
||||
|
|
|
@ -12,7 +12,7 @@ it('centers on the point', () => {
|
|||
})
|
||||
|
||||
it('centers on the point with animation', () => {
|
||||
editor.centerOnPoint({ x: 400, y: 400 }, { duration: 200 })
|
||||
editor.centerOnPoint({ x: 400, y: 400 }, { animation: { duration: 200 } })
|
||||
expect(editor.getViewportPageCenter()).not.toMatchObject({ x: 400, y: 400 })
|
||||
jest.advanceTimersByTime(100)
|
||||
expect(editor.getViewportPageCenter()).not.toMatchObject({ x: 400, y: 400 })
|
||||
|
|
89
packages/tldraw/src/test/commands/getBaseZoom.test.ts
Normal file
89
packages/tldraw/src/test/commands/getBaseZoom.test.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { TLCameraOptions } from '@tldraw/editor'
|
||||
import { TestEditor } from '../TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
})
|
||||
|
||||
describe('getBaseZoom', () => {
|
||||
it('gets initial zoom with default options', () => {
|
||||
expect(editor.getBaseZoom()).toBe(1)
|
||||
})
|
||||
|
||||
it('gets initial zoom based on constraints', () => {
|
||||
const vsb = editor.getViewportScreenBounds()
|
||||
let cameraOptions: TLCameraOptions
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
bounds: { x: 0, y: 0, w: vsb.w * 2, h: vsb.h * 4 },
|
||||
padding: { x: 0, y: 0 },
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'default',
|
||||
baseZoom: 'default',
|
||||
behavior: 'free',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getBaseZoom()).toBe(1)
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...(cameraOptions.constraints as any),
|
||||
baseZoom: 'fit-x',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getBaseZoom()).toBe(0.5)
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...(cameraOptions.constraints as any),
|
||||
baseZoom: 'fit-y',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getBaseZoom()).toBe(0.25)
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...(cameraOptions.constraints as any),
|
||||
baseZoom: 'fit-min',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getBaseZoom()).toBe(0.5)
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...(cameraOptions.constraints as any),
|
||||
baseZoom: 'fit-max',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getBaseZoom()).toBe(0.25)
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...(cameraOptions.constraints as any),
|
||||
baseZoom: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getBaseZoom()).toBe(1)
|
||||
})
|
||||
})
|
89
packages/tldraw/src/test/commands/getInitialZoom.test.ts
Normal file
89
packages/tldraw/src/test/commands/getInitialZoom.test.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { TLCameraOptions } from '@tldraw/editor'
|
||||
import { TestEditor } from '../TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
})
|
||||
|
||||
describe('getInitialZoom', () => {
|
||||
it('gets initial zoom with default options', () => {
|
||||
expect(editor.getInitialZoom()).toBe(1)
|
||||
})
|
||||
|
||||
it('gets initial zoom based on constraints', () => {
|
||||
const vsb = editor.getViewportScreenBounds()
|
||||
let cameraOptions: TLCameraOptions
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
bounds: { x: 0, y: 0, w: vsb.w * 2, h: vsb.h * 4 },
|
||||
padding: { x: 0, y: 0 },
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'default',
|
||||
baseZoom: 'default',
|
||||
behavior: 'free',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getInitialZoom()).toBe(1)
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...(cameraOptions.constraints as any),
|
||||
initialZoom: 'fit-x',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getInitialZoom()).toBe(0.5)
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...(cameraOptions.constraints as any),
|
||||
initialZoom: 'fit-y',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getInitialZoom()).toBe(0.25)
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...(cameraOptions.constraints as any),
|
||||
initialZoom: 'fit-min',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getInitialZoom()).toBe(0.5)
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...(cameraOptions.constraints as any),
|
||||
initialZoom: 'fit-max',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getInitialZoom()).toBe(0.25)
|
||||
|
||||
cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
...(cameraOptions.constraints as any),
|
||||
initialZoom: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getInitialZoom()).toBe(1)
|
||||
})
|
||||
})
|
|
@ -14,6 +14,18 @@ describe('When panning', () => {
|
|||
editor.expectCameraToBe(200, 200, 1)
|
||||
})
|
||||
|
||||
it('Updates the camera with panSpeed at 2', () => {
|
||||
editor.setCameraOptions({ panSpeed: 2 })
|
||||
editor.pan({ x: 200, y: 200 })
|
||||
editor.expectCameraToBe(400, 400, 1)
|
||||
})
|
||||
|
||||
it('Updates the camera with panSpeed', () => {
|
||||
editor.setCameraOptions({ panSpeed: 0.5 })
|
||||
editor.pan({ x: 200, y: 200 })
|
||||
editor.expectCameraToBe(100, 100, 1)
|
||||
})
|
||||
|
||||
it('Is not undoable', () => {
|
||||
editor.mark()
|
||||
editor.pan({ x: 200, y: 200 })
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
// import { TestEditor } from '../TestEditor'
|
||||
|
||||
// let editor: TestEditor
|
||||
|
||||
// beforeEach(() => {
|
||||
// editor =new TestEditor()
|
||||
// })
|
||||
|
||||
it.todo('sets the app state')
|
691
packages/tldraw/src/test/commands/setCamera.test.ts
Normal file
691
packages/tldraw/src/test/commands/setCamera.test.ts
Normal file
|
@ -0,0 +1,691 @@
|
|||
import { Box, DEFAULT_CAMERA_OPTIONS, Vec } from '@tldraw/editor'
|
||||
import { TestEditor } from '../TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900))
|
||||
})
|
||||
|
||||
const wheelEvent = {
|
||||
type: 'wheel',
|
||||
name: 'wheel',
|
||||
delta: new Vec(0, 0, 0),
|
||||
point: new Vec(0, 0),
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
} as const
|
||||
|
||||
describe('With default options', () => {
|
||||
beforeEach(() => {
|
||||
editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS })
|
||||
})
|
||||
|
||||
it.todo('pans')
|
||||
it.todo('zooms in')
|
||||
it.todo('zooms out')
|
||||
it.todo('resets zoom')
|
||||
it.todo('pans with wheel')
|
||||
})
|
||||
|
||||
it('Sets the camera options', () => {
|
||||
const optionsA = { ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2 }
|
||||
editor.setCameraOptions(optionsA)
|
||||
expect(editor.getCameraOptions()).toMatchObject(optionsA)
|
||||
})
|
||||
|
||||
describe('CameraOptions.wheelBehavior', () => {
|
||||
it('Pans when wheel behavior is pan', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'pan' })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(5, 10),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 })
|
||||
})
|
||||
|
||||
it('Zooms when wheel behavior is zoom', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'zoom' })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(0, 0, 0),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ z: 1 })
|
||||
editor
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(0, 1, 0),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ z: 1.01 })
|
||||
editor
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(0, -1, 0),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ z: 0.9999 }) // zooming is non-linear
|
||||
})
|
||||
|
||||
it('When wheelBehavior is pan, ctrl key zooms', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'pan' })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(0, 5, 0.01),
|
||||
ctrlKey: true, // zooms
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ z: 1.01 })
|
||||
})
|
||||
|
||||
it('When wheelBehavior is zoom, ctrl key pans', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'zoom' })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(0, 5, 0.01),
|
||||
ctrlKey: true, // zooms
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 5, z: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('CameraOptions.panSpeed', () => {
|
||||
it('Effects wheel panning (2x)', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2, wheelBehavior: 'pan' })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(5, 10),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 10, y: 20, z: 1 })
|
||||
})
|
||||
|
||||
it('Effects wheel panning (.5x)', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 0.5, wheelBehavior: 'pan' })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(5, 10),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 2.5, y: 5, z: 1 })
|
||||
})
|
||||
|
||||
it('Does not effect zoom mouse wheeling', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2, wheelBehavior: 'zoom' })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(0, 1, 0),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1.01 }) // 1 + 1
|
||||
})
|
||||
|
||||
it.todo('hand tool panning')
|
||||
it.todo('spacebar panning')
|
||||
it.todo('edge scroll panning')
|
||||
})
|
||||
|
||||
describe('CameraOptions.zoomSpeed', () => {
|
||||
it('Effects wheel zooming (2x)', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 2, wheelBehavior: 'zoom' })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(0, 1, 0),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1.02 }) // 1 + (.01 * 2)
|
||||
})
|
||||
|
||||
it('Effects wheel zooming (.5x)', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 0.5, wheelBehavior: 'zoom' })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(0, 1, 0),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1.005 }) // 1 + (.01 * .5)
|
||||
})
|
||||
|
||||
it('Does not effect mouse wheel panning', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 0.5, wheelBehavior: 'pan' })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(5, 10),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 })
|
||||
})
|
||||
|
||||
it.todo('zoom method')
|
||||
it.todo('zoom tool zooming')
|
||||
it.todo('pinch zooming')
|
||||
})
|
||||
|
||||
describe('CameraOptions.isLocked', () => {
|
||||
it('Pans when unlocked', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: false })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(5, 10),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 })
|
||||
editor.pan(new Vec(10, 10))
|
||||
expect(editor.getCamera()).toMatchObject({ x: 15, y: 20, z: 1 })
|
||||
})
|
||||
|
||||
it('Does not pan when locked', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: true })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(5, 10),
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
|
||||
editor.pan(new Vec(10, 10))
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
|
||||
})
|
||||
|
||||
it('Zooms when unlocked', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: false })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(0, 0, 0.01),
|
||||
ctrlKey: true,
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ z: 1.01 })
|
||||
editor.zoomIn(undefined, { immediate: true })
|
||||
expect(editor.getCamera()).toMatchObject({ z: 2 })
|
||||
})
|
||||
|
||||
it('Does not zoom when locked', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: true })
|
||||
.dispatch({
|
||||
...wheelEvent,
|
||||
delta: new Vec(0, 0, 0.01),
|
||||
ctrlKey: true,
|
||||
})
|
||||
.forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ z: 1 })
|
||||
editor.zoomIn(undefined, { immediate: true })
|
||||
expect(editor.getCamera()).toMatchObject({ z: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
// zoom steps are tested in zoom in / zoom out method
|
||||
|
||||
describe('CameraOptions.zoomSteps', () => {
|
||||
it('Does not zoom past max zoom step', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSteps: [0.5, 1, 2] })
|
||||
.setCamera(new Vec(0, 0, 100), { immediate: true })
|
||||
expect(editor.getZoomLevel()).toBe(2)
|
||||
editor.zoomIn(undefined, { immediate: true })
|
||||
expect(editor.getZoomLevel()).toBe(2)
|
||||
})
|
||||
|
||||
it('Does not zoom below min zoom step', () => {
|
||||
editor
|
||||
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSteps: [0.5, 1, 2] })
|
||||
.setCamera(new Vec(0, 0, 0), { immediate: true })
|
||||
expect(editor.getZoomLevel()).toBe(0.5)
|
||||
editor.zoomOut(undefined, { immediate: true })
|
||||
expect(editor.getZoomLevel()).toBe(0.5)
|
||||
})
|
||||
|
||||
it('Zooms between zoom steps', () => {
|
||||
editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSteps: [0.5, 1, 2] })
|
||||
expect(editor.getZoomLevel()).toBe(1)
|
||||
editor.zoomIn(undefined, { immediate: true })
|
||||
expect(editor.getZoomLevel()).toBe(2)
|
||||
editor.zoomOut(undefined, { immediate: true })
|
||||
expect(editor.getZoomLevel()).toBe(1)
|
||||
editor.zoomOut(undefined, { immediate: true })
|
||||
expect(editor.getZoomLevel()).toBe(0.5)
|
||||
editor.zoomIn(undefined, { immediate: true })
|
||||
expect(editor.getZoomLevel()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// constraints?: {
|
||||
// /** The bounds (in page space) of the constrained space */
|
||||
// bounds: BoxModel
|
||||
// /** The padding inside of the viewport (in screen space) */
|
||||
// padding: VecLike
|
||||
// /** The origin for placement. Used to position the bounds within the viewport when an axis is fixed or contained and zoom is below the axis fit. */
|
||||
// origin: VecLike
|
||||
// /** The camera's initial zoom, used also when the camera is reset. */
|
||||
// initialZoom: 'fit-min' | 'fit-max' | 'fit-x' | 'fit-y' | 'default'
|
||||
// /** The camera's base for its zoom steps. */
|
||||
// baseZoom: 'fit-min' | 'fit-max' | 'fit-x' | 'fit-y' | 'default'
|
||||
// /** The behavior for the constraints on the x axis. */
|
||||
// behavior:
|
||||
// | 'free'
|
||||
// | 'contain'
|
||||
// | 'inside'
|
||||
// | 'outside'
|
||||
// | 'fixed'
|
||||
// | {
|
||||
// x: 'contain' | 'inside' | 'outside' | 'fixed' | 'free'
|
||||
// y: 'contain' | 'inside' | 'outside' | 'fixed' | 'free'
|
||||
// }
|
||||
// }
|
||||
|
||||
const DEFAULT_CONSTRAINTS = {
|
||||
bounds: { x: 0, y: 0, w: 1200, h: 800 },
|
||||
padding: { x: 0, y: 0 },
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'default',
|
||||
baseZoom: 'default',
|
||||
behavior: 'free',
|
||||
} as const
|
||||
|
||||
describe('When constraints are free', () => {
|
||||
beforeEach(() => {
|
||||
editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900))
|
||||
editor.setCameraOptions({
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'free',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('starts at 1 zoom', () => {
|
||||
expect(editor.getZoomLevel()).toBe(1)
|
||||
})
|
||||
|
||||
it('pans freely', () => {
|
||||
editor.pan(new Vec(100, 100))
|
||||
expect(editor.getCamera()).toMatchObject({ x: 100, y: 100, z: 1 })
|
||||
editor.pan(new Vec(5000, 5000))
|
||||
expect(editor.getCamera()).toMatchObject({ x: 5100, y: 5100, z: 1 })
|
||||
})
|
||||
|
||||
it('zooms onto mouse position', () => {
|
||||
editor.pointerMove(100, 100)
|
||||
expect(editor.inputs.currentPagePoint).toMatchObject({ x: 100, y: 100 })
|
||||
editor.zoomIn(editor.inputs.currentScreenPoint, { immediate: true })
|
||||
expect(editor.inputs.currentPagePoint).toMatchObject({ x: 100, y: 100 })
|
||||
editor.zoomOut(editor.inputs.currentScreenPoint, { immediate: true })
|
||||
expect(editor.inputs.currentPagePoint).toMatchObject({ x: 100, y: 100 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('When constraints are contain', () => {
|
||||
beforeEach(() => {
|
||||
editor.setCameraOptions({
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('resets zoom to 1', () => {
|
||||
editor.zoomIn(undefined, { immediate: true })
|
||||
editor.zoomIn(undefined, { immediate: true })
|
||||
editor.resetZoom()
|
||||
expect(editor.getZoomLevel()).toBe(1)
|
||||
})
|
||||
|
||||
it('does not pan when below the fit zoom', () => {
|
||||
editor.pan(new Vec(100, 100))
|
||||
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
|
||||
editor.pan(new Vec(5000, 5000))
|
||||
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zoom reset positions based on origin', () => {
|
||||
it('Default .5, .5 origin', () => {
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'default',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
|
||||
})
|
||||
|
||||
it('0 0 origin', () => {
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0, y: 0 },
|
||||
initialZoom: 'default',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
|
||||
})
|
||||
|
||||
it('1 1 origin', () => {
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 1, y: 1 },
|
||||
initialZoom: 'default',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
expect(editor.getCamera()).toMatchObject({ x: 400, y: 100, z: 1 })
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 400, y: 100, z: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('CameraOptions.constraints.initialZoom + behavior', () => {
|
||||
it('When fit is default', () => {
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'default',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
|
||||
})
|
||||
|
||||
it('When fit is fit-max', () => {
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-max',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
// y should be 0 because the viewport width is bigger than the height
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
|
||||
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
bounds: { x: 0, y: 0, w: 800, h: 1200 },
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-max',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
// y should be 0 because the viewport width is bigger than the height
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 666.66, y: 0, z: 0.75 }, 2)
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 666.66, y: 0, z: 0.75 }, 2)
|
||||
|
||||
// The proportions of the bounds don't matter, it's the proportion of the viewport that decides which axis gets fit
|
||||
editor.updateViewportScreenBounds(new Box(0, 0, 900, 1600))
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-max',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: 666.66, z: 0.75 }, 2)
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: 666.66, z: 0.75 }, 2)
|
||||
})
|
||||
|
||||
it('When fit is fit-min', () => {
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-min',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
// x should be 0 because the viewport width is bigger than the height
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -62.5, z: 1.333 }, 2)
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -62.5, z: 1.333 }, 2)
|
||||
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
bounds: { x: 0, y: 0, w: 800, h: 1200 },
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-min',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
// x should be 0 because the viewport width is bigger than the height
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -375, z: 2 }, 2)
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -375, z: 2 }, 2)
|
||||
|
||||
// The proportions of the bounds don't matter, it's the proportion of the viewport that decides which axis gets fit
|
||||
editor.updateViewportScreenBounds(new Box(0, 0, 900, 1600))
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-min',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: -375, y: 0, z: 2 }, 2)
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: -375, y: 0, z: 2 }, 2)
|
||||
})
|
||||
|
||||
it('When fit is fit-min-100', () => {
|
||||
editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900))
|
||||
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-min-100',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
// Max 1 on initial / reset
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 200, y: 50, z: 1 }, 2)
|
||||
|
||||
// Min is regular
|
||||
editor.updateViewportScreenBounds(new Box(0, 0, 800, 450))
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-min-100',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -62.5, z: 0.66 }, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Padding', () => {
|
||||
it('sets when padding is zero', () => {
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
padding: { x: 0, y: 0 },
|
||||
initialZoom: 'fit-max',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
// y should be 0 because the viewport width is bigger than the height
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
|
||||
})
|
||||
|
||||
it('sets when padding is 100, 0', () => {
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
padding: { x: 100, y: 0 },
|
||||
initialZoom: 'fit-max',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
// no change because the horizontal axis has extra space available
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
|
||||
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
padding: { x: 200, y: 0 },
|
||||
initialZoom: 'fit-max',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
// now we're pinching
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 200, y: 50, z: 1 }, 2)
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 200, y: 50, z: 1 }, 2)
|
||||
})
|
||||
|
||||
it('sets when padding is 0 x 100', () => {
|
||||
editor.setCameraOptions(
|
||||
{
|
||||
...DEFAULT_CAMERA_OPTIONS,
|
||||
constraints: {
|
||||
...DEFAULT_CONSTRAINTS,
|
||||
behavior: 'contain',
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
padding: { x: 0, y: 100 },
|
||||
initialZoom: 'fit-max',
|
||||
},
|
||||
},
|
||||
{ reset: true }
|
||||
)
|
||||
|
||||
// y should be 0 because the viewport width is bigger than the height
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 314.28, y: 114.28, z: 0.875 }, 2)
|
||||
editor.zoomIn().resetZoom().forceTick()
|
||||
expect(editor.getCamera()).toCloselyMatchObject({ x: 314.28, y: 114.28, z: 0.875 }, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Contain behavior', () => {
|
||||
it.todo(
|
||||
'Locks axis until the bounds are bigger than the padded viewport, then allows "inside" panning'
|
||||
)
|
||||
})
|
||||
|
||||
describe('Inside behavior', () => {
|
||||
it.todo('Allows panning that keeps the bounds inside of the padded viewport')
|
||||
})
|
||||
|
||||
describe('Outside behavior', () => {
|
||||
it.todo('Allows panning that keeps the bounds adjacent to the padded viewport')
|
||||
})
|
||||
|
||||
describe('Allows mixed values for x and y', () => {
|
||||
it.todo('Allows different values to be set for x and y axes')
|
||||
})
|
|
@ -1,4 +1,4 @@
|
|||
import { ZOOMS } from '@tldraw/editor'
|
||||
import { DEFAULT_CAMERA_OPTIONS } from '@tldraw/editor'
|
||||
import { TestEditor } from '../TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
@ -8,28 +8,28 @@ beforeEach(() => {
|
|||
})
|
||||
|
||||
it('zooms by increments', () => {
|
||||
// Starts at 1
|
||||
expect(editor.getZoomLevel()).toBe(1)
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
|
||||
// zooms in
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
|
||||
editor.zoomIn()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[4])
|
||||
editor.zoomIn()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[5])
|
||||
editor.zoomIn()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[6])
|
||||
const cameraOptions = DEFAULT_CAMERA_OPTIONS
|
||||
|
||||
// does not zoom in past max
|
||||
// Starts at 1
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3])
|
||||
editor.zoomIn()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[6])
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[4])
|
||||
editor.zoomIn()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[5])
|
||||
editor.zoomIn()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[6])
|
||||
// does not zoom out past min
|
||||
editor.zoomIn()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[6])
|
||||
})
|
||||
|
||||
it('is ignored by undo/redo', () => {
|
||||
const cameraOptions = editor.getCameraOptions()
|
||||
|
||||
editor.mark()
|
||||
editor.zoomIn()
|
||||
editor.undo()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[4])
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[4])
|
||||
})
|
||||
|
||||
it('preserves the screen center', () => {
|
||||
|
@ -55,18 +55,24 @@ it('preserves the screen center when offset', () => {
|
|||
})
|
||||
|
||||
it('zooms to from B to D when B >= (C - A)/2, else zooms from B to C', () => {
|
||||
editor.setCamera({ x: 0, y: 0, z: (ZOOMS[2] + ZOOMS[3]) / 2 })
|
||||
const cameraOptions = DEFAULT_CAMERA_OPTIONS
|
||||
|
||||
editor.setCamera({ x: 0, y: 0, z: (cameraOptions.zoomSteps[2] + cameraOptions.zoomSteps[3]) / 2 })
|
||||
editor.zoomIn()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[4])
|
||||
editor.setCamera({ x: 0, y: 0, z: (ZOOMS[2] + ZOOMS[3]) / 2 - 0.1 })
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[4])
|
||||
editor.setCamera({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: (cameraOptions.zoomSteps[2] + cameraOptions.zoomSteps[3]) / 2 - 0.1,
|
||||
})
|
||||
editor.zoomIn()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3])
|
||||
})
|
||||
|
||||
it('does not zoom when camera is frozen', () => {
|
||||
editor.setCamera({ x: 0, y: 0, z: 1 })
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
|
||||
editor.updateInstanceState({ canMoveCamera: false })
|
||||
editor.setCameraOptions({ isLocked: true })
|
||||
editor.zoomIn()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
|
||||
})
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { ZOOMS } from '@tldraw/editor'
|
||||
import { TestEditor } from '../TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
@ -7,32 +6,119 @@ beforeEach(() => {
|
|||
editor = new TestEditor()
|
||||
})
|
||||
|
||||
it('zooms by increments', () => {
|
||||
it('zooms out and in by increments', () => {
|
||||
const cameraOptions = editor.getCameraOptions()
|
||||
|
||||
// Starts at 1
|
||||
expect(editor.getZoomLevel()).toBe(1)
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3])
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[2])
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[2])
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[1])
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[1])
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[0])
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0])
|
||||
// does not zoom out past min
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[0])
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0])
|
||||
})
|
||||
|
||||
it('is ignored by undo/redo', () => {
|
||||
const cameraOptions = editor.getCameraOptions()
|
||||
|
||||
editor.mark()
|
||||
editor.zoomOut()
|
||||
editor.undo()
|
||||
expect(editor.getZoomLevel()).toBe(ZOOMS[2])
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[2])
|
||||
})
|
||||
|
||||
it('does not zoom out when camera is frozen', () => {
|
||||
editor.setCamera({ x: 0, y: 0, z: 1 })
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
|
||||
editor.updateInstanceState({ canMoveCamera: false })
|
||||
editor.setCameraOptions({ isLocked: true })
|
||||
editor.zoomOut()
|
||||
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
|
||||
})
|
||||
|
||||
it('zooms out and in by increments when the camera options have constraints but no base zoom', () => {
|
||||
const cameraOptions = editor.getCameraOptions()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
bounds: { x: 0, y: 0, w: 1600, h: 900 },
|
||||
padding: { x: 0, y: 0 },
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'default',
|
||||
baseZoom: 'default',
|
||||
behavior: 'free',
|
||||
},
|
||||
})
|
||||
// Starts at 1
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3])
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[2])
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[1])
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0])
|
||||
// does not zoom out past min
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0])
|
||||
})
|
||||
|
||||
it('zooms out and in by increments when the camera options have constraints and a base zoom', () => {
|
||||
const cameraOptions = editor.getCameraOptions()
|
||||
const vsb = editor.getViewportScreenBounds()
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
bounds: { x: 0, y: 0, w: vsb.w * 2, h: vsb.h * 4 },
|
||||
padding: { x: 0, y: 0 },
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-x',
|
||||
baseZoom: 'fit-x',
|
||||
behavior: 'free',
|
||||
},
|
||||
})
|
||||
// And reset the zoom to its initial value
|
||||
editor.resetZoom()
|
||||
|
||||
expect(editor.getInitialZoom()).toBe(0.5) // fitting the x axis
|
||||
// Starts at 1
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3] * 0.5)
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[2] * 0.5)
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[1] * 0.5)
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0] * 0.5)
|
||||
// does not zoom out past min
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0] * 0.5)
|
||||
|
||||
editor.setCameraOptions({
|
||||
...cameraOptions,
|
||||
constraints: {
|
||||
bounds: { x: 0, y: 0, w: vsb.w * 2, h: vsb.h * 4 },
|
||||
padding: { x: 0, y: 0 },
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
initialZoom: 'fit-y',
|
||||
baseZoom: 'fit-y',
|
||||
behavior: 'free',
|
||||
},
|
||||
})
|
||||
// And reset the zoom to its initial value
|
||||
editor.resetZoom()
|
||||
|
||||
expect(editor.getInitialZoom()).toBe(0.25) // fitting the y axis
|
||||
// Starts at 1
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3] * 0.25)
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[2] * 0.25)
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[1] * 0.25)
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0] * 0.25)
|
||||
// does not zoom out past min
|
||||
editor.zoomOut()
|
||||
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0] * 0.25)
|
||||
})
|
||||
|
|
|
@ -44,7 +44,7 @@ it('does not zoom past min', () => {
|
|||
it('does not zoom to bounds when camera is frozen', () => {
|
||||
editor.setScreenBounds({ x: 0, y: 0, w: 1000, h: 1000 })
|
||||
expect(editor.getViewportPageCenter().toJson()).toCloselyMatchObject({ x: 500, y: 500 })
|
||||
editor.updateInstanceState({ canMoveCamera: false })
|
||||
editor.setCameraOptions({ isLocked: true })
|
||||
editor.zoomToBounds(new Box(200, 300, 300, 300))
|
||||
expect(editor.getViewportPageCenter().toJson()).toCloselyMatchObject({ x: 500, y: 500 })
|
||||
})
|
||||
|
|
|
@ -14,7 +14,7 @@ it('converts correctly', () => {
|
|||
|
||||
it('does not zoom to bounds when camera is frozen', () => {
|
||||
const cameraBefore = { ...editor.getCamera() }
|
||||
editor.updateInstanceState({ canMoveCamera: false })
|
||||
editor.setCameraOptions({ isLocked: true })
|
||||
editor.zoomToFit()
|
||||
expect(editor.getCamera()).toMatchObject(cameraBefore)
|
||||
})
|
||||
|
|
|
@ -35,7 +35,7 @@ it('does not zoom past min', () => {
|
|||
|
||||
it('does not zoom to selection when camera is frozen', () => {
|
||||
const cameraBefore = { ...editor.getCamera() }
|
||||
editor.updateInstanceState({ canMoveCamera: false })
|
||||
editor.setCameraOptions({ isLocked: true })
|
||||
editor.setSelectedShapes([ids.box1, ids.box2])
|
||||
editor.zoomToSelection()
|
||||
expect(editor.getCamera()).toMatchObject(cameraBefore)
|
||||
|
|
|
@ -7,7 +7,6 @@ let editor: TestEditor
|
|||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
editor.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 })
|
||||
editor.renderingBoundsMargin = 100
|
||||
})
|
||||
|
||||
function createShapes() {
|
||||
|
|
|
@ -419,7 +419,6 @@ describe('When pasting into frames...', () => {
|
|||
.bringToFront(editor.getSelectedShapeIds())
|
||||
|
||||
editor.setCamera({ x: -2000, y: -2000, z: 1 })
|
||||
editor.updateRenderingBounds()
|
||||
|
||||
// Copy box 1 (should be out of viewport)
|
||||
editor.select(ids.box1).copy()
|
||||
|
|
|
@ -34,7 +34,6 @@ function normalizeIndexes(
|
|||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
editor.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 })
|
||||
editor.renderingBoundsMargin = 100
|
||||
})
|
||||
|
||||
function createShapes() {
|
||||
|
@ -48,18 +47,6 @@ function createShapes() {
|
|||
])
|
||||
}
|
||||
|
||||
it('updates the rendering viewport when the camera stops moving', () => {
|
||||
const ids = createShapes()
|
||||
|
||||
editor.updateRenderingBounds = jest.fn(editor.updateRenderingBounds)
|
||||
editor.pan({ x: -201, y: -201 })
|
||||
jest.advanceTimersByTime(500)
|
||||
|
||||
expect(editor.updateRenderingBounds).toHaveBeenCalledTimes(1)
|
||||
expect(editor.getRenderingBounds()).toMatchObject({ x: 201, y: 201, w: 1800, h: 900 })
|
||||
expect(editor.getShapePageBounds(ids.A)).toMatchObject({ x: 100, y: 100, w: 100, h: 100 })
|
||||
})
|
||||
|
||||
it('lists shapes in viewport sorted by id with correct indexes & background indexes', () => {
|
||||
const ids = createShapes()
|
||||
// Expect the results to be sorted correctly by id
|
||||
|
|
|
@ -1059,8 +1059,6 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
|
|||
// (undocumented)
|
||||
brush: BoxModel | null;
|
||||
// (undocumented)
|
||||
canMoveCamera: boolean;
|
||||
// (undocumented)
|
||||
chatMessage: string;
|
||||
// (undocumented)
|
||||
currentPageId: TLPageId;
|
||||
|
|
|
@ -1549,6 +1549,18 @@ describe('Add font size adjustment to notes', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('removes can move camera', () => {
|
||||
const { up, down } = getTestMigration(instanceVersions.RemoveCanMoveCamera)
|
||||
|
||||
test('up works as expected', () => {
|
||||
expect(up({ canMoveCamera: true })).toStrictEqual({})
|
||||
})
|
||||
|
||||
test('down works as expected', () => {
|
||||
expect(down({})).toStrictEqual({ canMoveCamera: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add text align to text shapes', () => {
|
||||
const { up, down } = getTestMigration(textShapeVersions.AddTextAlign)
|
||||
|
||||
|
|
|
@ -43,7 +43,6 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
|
|||
isChatting: boolean
|
||||
isPenMode: boolean
|
||||
isGridMode: boolean
|
||||
canMoveCamera: boolean
|
||||
isFocused: boolean
|
||||
devicePixelRatio: number
|
||||
/**
|
||||
|
@ -106,7 +105,6 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
|
|||
chatMessage: T.string,
|
||||
isChatting: T.boolean,
|
||||
highlightedUserIds: T.arrayOf(T.string),
|
||||
canMoveCamera: T.boolean,
|
||||
isFocused: T.boolean,
|
||||
devicePixelRatio: T.number,
|
||||
isCoarsePointer: T.boolean,
|
||||
|
@ -150,7 +148,6 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
|
|||
chatMessage: true,
|
||||
isChatting: true,
|
||||
highlightedUserIds: true,
|
||||
canMoveCamera: true,
|
||||
isFocused: true,
|
||||
devicePixelRatio: true,
|
||||
isCoarsePointer: true,
|
||||
|
@ -183,7 +180,6 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
|
|||
chatMessage: '',
|
||||
isChatting: false,
|
||||
highlightedUserIds: [],
|
||||
canMoveCamera: true,
|
||||
isFocused: false,
|
||||
devicePixelRatio: typeof window === 'undefined' ? 1 : window.devicePixelRatio,
|
||||
isCoarsePointer: false,
|
||||
|
@ -223,6 +219,7 @@ export const instanceVersions = createMigrationIds('com.tldraw.instance', {
|
|||
AddScribbles: 22,
|
||||
AddInset: 23,
|
||||
AddDuplicateProps: 24,
|
||||
RemoveCanMoveCamera: 25,
|
||||
} as const)
|
||||
|
||||
// TODO: rewrite these to use mutation
|
||||
|
@ -465,6 +462,17 @@ export const instanceMigrations = createRecordMigrationSequence({
|
|||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: instanceVersions.RemoveCanMoveCamera,
|
||||
up: ({ canMoveCamera: _, ...record }: any) => {
|
||||
return {
|
||||
...record,
|
||||
}
|
||||
},
|
||||
down: (instance) => {
|
||||
return { ...instance, canMoveCamera: true }
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in a new issue