Feature copy and paste (#99)

* adds copy and paste, scopes keyboard events to focused elements

* Fix tools panel bug, adds copy across documents

* Makes autofocus default
This commit is contained in:
Steve Ruiz 2021-09-21 16:47:04 +01:00 committed by GitHub
parent eabaf2f30e
commit bbee7bc2b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 284 additions and 206 deletions

View file

@ -44,8 +44,8 @@
"esbuild": "^0.12.24",
"eslint": "^7.32.0",
"lerna": "^4.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react": ">=16.8",
"react-dom": "^16.8 || ^17.0",
"ts-node": "^10.2.1",
"tsconfig-replace-paths": "^0.0.5",
"tslib": "^2.3.1",
@ -62,4 +62,4 @@
"@use-gesture/react": "^10.0.0-beta.24"
},
"gitHead": "5cb031ddc264846ec6732d7179511cddea8ef034"
}
}

View file

@ -24,6 +24,8 @@ export const RenderedShape = React.memo(
}: RenderedShapeProps<T, E, M>) => {
const ref = utils.getRef(shape)
// consider using layout effect to update bounds cache if the ref is filled
return (
<utils._Component
ref={ref}

View file

@ -14,7 +14,7 @@ export default function Editor(props: TLDrawProps): JSX.Element {
return (
<div className="tldraw">
<TLDraw id="tldraw" {...props} onMount={handleMount} />
<TLDraw id="tldraw" {...props} onMount={handleMount} autofocus />
</div>
)
}

View file

@ -10,6 +10,7 @@ export default function Embedded(): JSX.Element {
width: 'auto',
height: '500px',
overflow: 'hidden',
marginBottom: '32px',
}}
>
<TLDraw id="small1" />

View file

@ -40,6 +40,11 @@ export interface TLDrawProps {
* (optional) The current page id.
*/
currentPageId?: string
/**
* (optional) Whether the editor should immediately receive focus. Defaults to true.
*/
autofocus?: boolean
/**
* (optional) A callback to run when the component mounts.
*/
@ -50,7 +55,14 @@ export interface TLDrawProps {
onChange?: TLDrawState['_onChange']
}
export function TLDraw({ id, document, currentPageId, onMount, onChange }: TLDrawProps) {
export function TLDraw({
id,
document,
currentPageId,
autofocus = true,
onMount,
onChange,
}: TLDrawProps) {
const [sId, setSId] = React.useState(id)
const [tlstate, setTlstate] = React.useState(() => new TLDrawState(id, onChange, onMount))
@ -70,7 +82,12 @@ export function TLDraw({ id, document, currentPageId, onMount, onChange }: TLDra
return (
<TLDrawContext.Provider value={context}>
<IdProvider>
<InnerTldraw key={sId || 'tldraw'} currentPageId={currentPageId} document={document} />
<InnerTldraw
key={sId || 'tldraw'}
currentPageId={currentPageId}
document={document}
autofocus={autofocus}
/>
</IdProvider>
</TLDrawContext.Provider>
)
@ -78,16 +95,16 @@ export function TLDraw({ id, document, currentPageId, onMount, onChange }: TLDra
function InnerTldraw({
currentPageId,
autofocus,
document,
}: {
currentPageId?: string
autofocus?: boolean
document?: TLDrawDocument
}) {
const { tlstate, useSelector } = useTLDrawContext()
useCustomFonts()
useKeyboardShortcuts()
const rWrapper = React.useRef<HTMLDivElement>(null)
const page = useSelector(pageSelector)
@ -146,7 +163,8 @@ function InnerTldraw({
}, [currentPageId, tlstate])
return (
<div className={layout()}>
<div ref={rWrapper} className={layout()} tabIndex={0}>
<OneOff rWrapper={rWrapper} autofocus={autofocus} />
<ContextMenu>
<Renderer
page={page}
@ -216,6 +234,21 @@ function InnerTldraw({
)
}
const OneOff = React.memo(
({ rWrapper, autofocus }: { autofocus?: boolean; rWrapper: React.RefObject<HTMLDivElement> }) => {
useKeyboardShortcuts(rWrapper)
useCustomFonts()
React.useEffect(() => {
if (autofocus) {
rWrapper.current?.focus()
}
}, [autofocus])
return null
}
)
const layout = css({
position: 'absolute',
height: '100%',
@ -230,9 +263,14 @@ const layout = css({
alignItems: 'flex-start',
justifyContent: 'flex-start',
boxSizing: 'border-box',
outline: 'none',
pointerEvents: 'none',
outline: 'none',
zIndex: 1,
border: '1px solid rgba(0,0,0,.1)',
'&:focus': {
border: '1px solid rgba(0,0,0,.2)',
},
'& > *': {
pointerEvents: 'all',

View file

@ -116,7 +116,9 @@ export const ToolsPanel = React.memo((): JSX.Element => {
</PrimaryButton>
</div>
</div>
<div className={rightWrap({ size: { '@initial': 'mobile', '@sm': 'small' } })}>
<div
className={rightWrap({ size: { '@initial': 'mobile', '@micro': 'micro', '@sm': 'small' } })}
>
<div className={floatingContainer()}>
<SecondaryButton
kbd={'7'}
@ -203,8 +205,12 @@ const rightWrap = css({
gridColumn: 3,
display: 'flex',
paddingRight: '$3',
opacity: 1,
variants: {
size: {
micro: {
opacity: 0,
},
mobile: {
flexDirection: 'column-reverse',
justifyContent: 'flex-end',
@ -212,6 +218,7 @@ const rightWrap = css({
'& > *:nth-of-type(2)': {
marginBottom: '8px',
},
opacity: 1,
},
small: {
flexDirection: 'row',
@ -220,6 +227,7 @@ const rightWrap = css({
'& > *:nth-of-type(2)': {
marginBottom: '0px',
},
opacity: 1,
},
},
},

View file

@ -3,16 +3,21 @@ import { useHotkeys } from 'react-hotkeys-hook'
import { TLDrawShapeType } from '~types'
import { useTLDrawContext } from '~hooks'
export function useKeyboardShortcuts() {
export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
const { tlstate } = useTLDrawContext()
const canHandleEvent = React.useCallback(() => {
const elm = ref.current
return elm && (document.activeElement === elm || elm.contains(document.activeElement))
}, [ref])
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
tlstate.onKeyDown(e.key)
if (canHandleEvent()) tlstate.onKeyDown(e.key)
}
const handleKeyUp = (e: KeyboardEvent) => {
tlstate.onKeyUp(e.key)
if (canHandleEvent()) tlstate.onKeyUp(e.key)
}
window.addEventListener('keydown', handleKeyDown)
@ -29,16 +34,15 @@ export function useKeyboardShortcuts() {
useHotkeys(
'v,1',
() => {
tlstate.selectTool('select')
if (canHandleEvent()) tlstate.selectTool('select')
},
undefined,
[tlstate]
[tlstate, ref.current]
)
useHotkeys(
'd,2',
() => {
tlstate.selectTool(TLDrawShapeType.Draw)
if (canHandleEvent()) tlstate.selectTool(TLDrawShapeType.Draw)
},
undefined,
[tlstate]
@ -47,7 +51,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'r,3',
() => {
tlstate.selectTool(TLDrawShapeType.Rectangle)
if (canHandleEvent()) tlstate.selectTool(TLDrawShapeType.Rectangle)
},
undefined,
[tlstate]
@ -56,7 +60,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'e,4',
() => {
tlstate.selectTool(TLDrawShapeType.Ellipse)
if (canHandleEvent()) tlstate.selectTool(TLDrawShapeType.Ellipse)
},
undefined,
[tlstate]
@ -65,7 +69,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'a,5',
() => {
tlstate.selectTool(TLDrawShapeType.Arrow)
if (canHandleEvent()) tlstate.selectTool(TLDrawShapeType.Arrow)
},
undefined,
[tlstate]
@ -74,7 +78,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
't,6',
() => {
tlstate.selectTool(TLDrawShapeType.Text)
if (canHandleEvent()) tlstate.selectTool(TLDrawShapeType.Text)
},
undefined,
[tlstate]
@ -87,7 +91,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'ctrl+s,command+s',
() => {
tlstate.saveProject()
if (canHandleEvent()) tlstate.saveProject()
},
undefined,
[tlstate]
@ -98,7 +102,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'command+z,ctrl+z',
() => {
tlstate.undo()
if (canHandleEvent()) tlstate.undo()
},
undefined,
[tlstate]
@ -107,7 +111,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'ctrl+shift-z,command+shift+z',
() => {
tlstate.redo()
if (canHandleEvent()) tlstate.redo()
},
undefined,
[tlstate]
@ -118,7 +122,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'command+u,ctrl+u',
() => {
tlstate.undoSelect()
if (canHandleEvent()) tlstate.undoSelect()
},
undefined,
[tlstate]
@ -127,7 +131,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'ctrl+shift-u,command+shift+u',
() => {
tlstate.redoSelect()
if (canHandleEvent()) tlstate.redoSelect()
},
undefined,
[tlstate]
@ -140,8 +144,10 @@ export function useKeyboardShortcuts() {
useHotkeys(
'ctrl+=,command+=',
(e) => {
tlstate.zoomIn()
e.preventDefault()
if (canHandleEvent()) {
tlstate.zoomIn()
e.preventDefault()
}
},
undefined,
[tlstate]
@ -150,8 +156,10 @@ export function useKeyboardShortcuts() {
useHotkeys(
'ctrl+-,command+-',
(e) => {
tlstate.zoomOut()
e.preventDefault()
if (canHandleEvent()) {
tlstate.zoomOut()
e.preventDefault()
}
},
undefined,
[tlstate]
@ -160,7 +168,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+1',
() => {
tlstate.zoomToFit()
if (canHandleEvent()) tlstate.zoomToFit()
},
undefined,
[tlstate]
@ -169,7 +177,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+2',
() => {
tlstate.zoomToSelection()
if (canHandleEvent()) tlstate.zoomToSelection()
},
undefined,
[tlstate]
@ -178,7 +186,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+0',
() => {
tlstate.zoomToActual()
if (canHandleEvent()) tlstate.zoomToActual()
},
undefined,
[tlstate]
@ -189,8 +197,10 @@ export function useKeyboardShortcuts() {
useHotkeys(
'ctrl+d,command+d',
(e) => {
tlstate.duplicate()
e.preventDefault()
if (canHandleEvent()) {
tlstate.duplicate()
e.preventDefault()
}
},
undefined,
[tlstate]
@ -201,7 +211,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+h',
() => {
tlstate.flipHorizontal()
if (canHandleEvent()) tlstate.flipHorizontal()
},
undefined,
[tlstate]
@ -210,7 +220,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+v',
() => {
tlstate.flipVertical()
if (canHandleEvent()) tlstate.flipVertical()
},
undefined,
[tlstate]
@ -221,7 +231,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'escape',
() => {
tlstate.cancel()
if (canHandleEvent()) tlstate.cancel()
},
undefined,
[tlstate]
@ -232,7 +242,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'backspace',
() => {
tlstate.delete()
if (canHandleEvent()) tlstate.delete()
},
undefined,
[tlstate]
@ -243,7 +253,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'command+a,ctrl+a',
() => {
tlstate.selectAll()
if (canHandleEvent()) tlstate.selectAll()
},
undefined,
[tlstate]
@ -254,7 +264,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'up',
() => {
tlstate.nudge([0, -1], false)
if (canHandleEvent()) tlstate.nudge([0, -1], false)
},
undefined,
[tlstate]
@ -263,7 +273,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'right',
() => {
tlstate.nudge([1, 0], false)
if (canHandleEvent()) tlstate.nudge([1, 0], false)
},
undefined,
[tlstate]
@ -272,7 +282,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'down',
() => {
tlstate.nudge([0, 1], false)
if (canHandleEvent()) tlstate.nudge([0, 1], false)
},
undefined,
[tlstate]
@ -281,7 +291,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'left',
() => {
tlstate.nudge([-1, 0], false)
if (canHandleEvent()) tlstate.nudge([-1, 0], false)
},
undefined,
[tlstate]
@ -290,7 +300,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+up',
() => {
tlstate.nudge([0, -1], true)
if (canHandleEvent()) tlstate.nudge([0, -1], true)
},
undefined,
[tlstate]
@ -299,7 +309,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+right',
() => {
tlstate.nudge([1, 0], true)
if (canHandleEvent()) tlstate.nudge([1, 0], true)
},
undefined,
[tlstate]
@ -308,7 +318,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+down',
() => {
tlstate.nudge([0, 1], true)
if (canHandleEvent()) tlstate.nudge([0, 1], true)
},
undefined,
[tlstate]
@ -317,7 +327,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+left',
() => {
tlstate.nudge([-1, 0], true)
if (canHandleEvent()) tlstate.nudge([-1, 0], true)
},
undefined,
[tlstate]
@ -328,7 +338,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'command+c,ctrl+c',
() => {
tlstate.copy()
if (canHandleEvent()) tlstate.copy()
},
undefined,
[tlstate]
@ -337,7 +347,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'command+v,ctrl+v',
() => {
tlstate.paste()
if (canHandleEvent()) tlstate.paste()
},
undefined,
[tlstate]
@ -348,8 +358,10 @@ export function useKeyboardShortcuts() {
useHotkeys(
'command+g,ctrl+g',
(e) => {
tlstate.group()
e.preventDefault()
if (canHandleEvent()) {
tlstate.group()
e.preventDefault()
}
},
undefined,
[tlstate]
@ -358,8 +370,10 @@ export function useKeyboardShortcuts() {
useHotkeys(
'command+shift+g,ctrl+shift+g',
(e) => {
tlstate.ungroup()
e.preventDefault()
if (canHandleEvent()) {
tlstate.ungroup()
e.preventDefault()
}
},
undefined,
[tlstate]
@ -370,7 +384,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'[',
() => {
tlstate.moveBackward()
if (canHandleEvent()) tlstate.moveBackward()
},
undefined,
[tlstate]
@ -379,7 +393,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
']',
() => {
tlstate.moveForward()
if (canHandleEvent()) tlstate.moveForward()
},
undefined,
[tlstate]
@ -388,7 +402,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+[',
() => {
tlstate.moveToBack()
if (canHandleEvent()) tlstate.moveToBack()
},
undefined,
[tlstate]
@ -397,7 +411,7 @@ export function useKeyboardShortcuts() {
useHotkeys(
'shift+]',
() => {
tlstate.moveToFront()
if (canHandleEvent()) tlstate.moveToFront()
},
undefined,
[tlstate]
@ -406,8 +420,10 @@ export function useKeyboardShortcuts() {
useHotkeys(
'command+shift+backspace',
(e) => {
tlstate.resetDocument()
e.preventDefault()
if (canHandleEvent()) {
tlstate.resetDocument()
e.preventDefault()
}
},
undefined,
[tlstate]

View file

@ -7,8 +7,8 @@ Object {
"name": "Text",
"parentId": "page",
"point": Array [
-0.5,
-0.5,
0,
0,
],
"rotation": 0,
"style": Object {

View file

@ -74,13 +74,6 @@ export const Text = new ShapeUtil<TextShape, HTMLDivElement, TLDrawMeta>(() => (
style: defaultStyle,
},
create(props) {
const shape = { ...this.defaultProps, ...props }
const bounds = this.getBounds(shape)
shape.point = Vec.sub(shape.point, [bounds.width / 2, bounds.height / 2])
return shape
},
shouldRender(prev, next): boolean {
return (
next.text !== prev.text || next.style.scale !== prev.style.scale || next.style !== prev.style

View file

@ -35,4 +35,8 @@ describe('Rotate command', () => {
expect(tlstate.getShape('rect1').rotation).toBe(Math.PI * (6 / 4))
})
it.todo('Rotates several shapes at once.')
it.todo('Rotates shapes with handles.')
})

View file

@ -1,45 +0,0 @@
# Notes
- [x] Remap style panel
- [x] Remap zoom panel
- [x] Remap undo / redo panel
- [x] Remap tool panel
- [x] Migrate commands
- [x] Migrate sessions
## History
The app's history is an array of [Command](#command) objects, together with a pointer indicating the current position in the stack. If the pointer is above the lowest (zeroth) position, a user may _undo_ to move the pointer down. If the pointer is below the highest position, a user may _redo_ to move the pointer up. When the pointer changes to a new position, it will either _redo_ the command at that position if moving up or _undo_ the command at its previous position if moving down.
## Commands
Commands are entries in the app's [History](#history) stack. have two methods: `do` and `undo`. Each method should return a `Partial<TLDrawState>`.
The `do` method is called under two circumstances: first, when executing a command for the first time; and second, when executing a "redo". The method receives a boolean (`isRedo`) as its second argument indiciating whether it is being called as a "do" or a "redo".
## Sessions
Sessions have two methods: `start`, `update`, `cancel` and `complete`. The `start`, `update`, and `cancel` methods should return a `Partial<TLDrawState>`. The `complete` method should return a [Command](#commands).
## Mutations
When we mutate shapes inside of a command, we:
- Gather a unique set of all shapes that _will_ be mutated: the initial shapes directly effected by the change, plus their descendants (if any), plus their parents (if any), plus other shapes that are "bound to" the shapes / parents. Repeat this check until an iteration returns the same size set as the previous iteration, indicating that we've found all shapes effected by the mutation.
- Serialize a snapshot of the mutation. This data will be used to perform the "undo".
- Using a reducer that returns the `Data` object, iterate through the initial shapes, mutating first the shape, then its bindings, and then the shape's parents beginning with the direct parent and moving upward toward the root (a page). If _n_ shapes share the same parent, then the parent will be updated _n_ times. If the initial set of shapes includes _n_ shapes that are bound to eachother, then the binding will be updated _n_ times.
- Finally, serialize a snapshot of all effected shapes. This data will be used to perform the "redo".
- Return both the "undo" and "redo" data. This should be saved to the history stack. It can also be saved to storage as part of the document.
- When the history "does" the command, merge the "redo" data into the current `Data`.
- When the history "undoes" the command, merge the "undo" data into the current `Data`.
- When the history "redoes" the command, merge the "redo" data into the current `Data`.
## onChange Events
When something changes in the state, we need to produce an onChange event that is compatible with multiplayer implementations. This still requires some research, however at minimum we want to include:
- The current user's id
- The current document id
- The event patch (what's changed)
The first step would be to implement onChange events for commands. These are already set up as patches and always produce a history entry.

View file

@ -1,3 +1,5 @@
import Vec from '@tldraw/vec'
import Utils from '~../../core/src/utils'
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { TLDrawStatus } from '~types'
@ -56,4 +58,29 @@ describe('Rotate session', () => {
expect(tlstate.getShape('rect1').point).toStrictEqual([0, 0])
})
it.todo('rotates handles only on shapes with handles')
describe('when rotating multiple shapes', () => {
it('keeps the center', () => {
tlstate.loadDocument(mockDocument).select('rect1', 'rect2')
const centerBefore = Vec.round(
Utils.getBoundsCenter(
Utils.getCommonBounds(tlstate.selectedIds.map((id) => tlstate.getShapeBounds(id)))
)
)
tlstate.startTransformSession([50, 0], 'rotate').updateTransformSession([100, 50])
const centerAfter = Vec.round(
Utils.getBoundsCenter(
Utils.getCommonBounds(tlstate.selectedIds.map((id) => tlstate.getShapeBounds(id)))
)
)
expect(tlstate.getShape('rect1').rotation)
expect(centerBefore).toStrictEqual(centerAfter)
})
})
})

View file

@ -119,6 +119,11 @@ export class TLDrawState extends StateManager<Data> {
selectedGroupId?: string
private pasteInfo = {
center: [0, 0],
offset: [0, 0],
}
constructor(
id?: string,
onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void,
@ -809,17 +814,31 @@ export class TLDrawState extends StateManager<Data> {
* @param ids The ids of the shapes to copy.
*/
copy = (ids = this.selectedIds): this => {
this.clipboard = ids
const clones = ids
.flatMap((id) => TLDR.getDocumentBranch(this.state, id, this.currentPageId))
.map((id) => {
const shape = this.getShape(id, this.currentPageId)
.map((id) => this.getShape(id, this.currentPageId))
return {
...shape,
id: Utils.uniqueId(),
childIndex: TLDR.getChildIndexAbove(this.state, id, this.currentPageId),
if (clones.length === 0) return this
this.clipboard = clones
try {
const text = JSON.stringify({ type: 'tldr/clipboard', shapes: clones })
navigator.clipboard.writeText(text).then(
() => {
// success
},
() => {
// failure
}
})
)
} catch (e) {
// Browser does not support copying to clipboard
}
this.pasteInfo.offset = [0, 0]
this.pasteInfo.center = [0, 0]
return this
}
@ -827,82 +846,92 @@ export class TLDrawState extends StateManager<Data> {
/**
* Paste shapes (or text) from clipboard to a certain point.
* @param point
* @param string
*/
paste = (point?: number[], string?: string): this => {
if (string) {
// Parse shapes from string
try {
const jsonShapes: TLDrawShape[] = JSON.parse(string)
paste = (point?: number[]) => {
const pasteInCurrentPage = (shapes: TLDrawShape[]) => {
const idsMap = Object.fromEntries(
shapes.map((shape: TLDrawShape) => [shape.id, Utils.uniqueId()])
)
jsonShapes.forEach((shape) => {
if (shape.parentId !== this.currentPageId) {
shape.parentId = this.currentPageId
}
})
const shapesToPaste = shapes.map((shape: TLDrawShape) => ({
...shape,
id: idsMap[shape.id],
parentId: idsMap[shape.parentId] || this.currentPageId,
}))
this.create(...jsonShapes)
} catch (e) {
// Create text shape
const childIndex =
this.getShapes().sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
const commonBounds = Utils.getCommonBounds(shapesToPaste.map(TLDR.getBounds))
const shape = TLDR.getShapeUtils(TLDrawShapeType.Text).create({
id: Utils.uniqueId(),
parentId: this.appState.currentPageId,
childIndex,
point: this.getPagePoint([window.innerWidth / 2, window.innerHeight / 2]),
style: { ...this.appState.currentStyle },
})
const boundsCenter = Utils.centerBounds(
TLDR.getShapeUtils(shape).getBounds(shape),
this.getPagePoint([window.innerWidth / 2, window.innerHeight / 2])
let center = Vec.round(
this.getPagePoint(
point ||
(this.inputs
? [this.inputs.size[0] / 2, this.inputs.size[1] / 2]
: [window.innerWidth / 2, window.innerHeight / 2])
)
)
this.create(
TLDR.getShapeUtils(TLDrawShapeType.Text).create({
id: Utils.uniqueId(),
parentId: this.appState.currentPageId,
childIndex,
point: [boundsCenter.minX, boundsCenter.minY],
})
)
if (
Vec.dist(center, this.pasteInfo.center) < 2 ||
Vec.dist(center, Vec.round(Utils.getBoundsCenter(commonBounds))) < 2
) {
this.pasteInfo.offset = Vec.add(this.pasteInfo.offset, [16, 16])
center = Vec.add(center, this.pasteInfo.offset)
} else {
this.pasteInfo.center = center
this.pasteInfo.offset = [0, 0]
}
return this
const centeredBounds = Utils.centerBounds(commonBounds, center)
const delta = Vec.sub(
Utils.getBoundsCenter(centeredBounds),
Utils.getBoundsCenter(commonBounds)
)
this.createShapes(
...shapesToPaste.map((shape) => ({
...shape,
point: Vec.round(Vec.add(shape.point, delta)),
}))
)
}
if (!this.clipboard) return this
try {
navigator.clipboard.readText().then((result) => {
try {
const data: { type: string; shapes: TLDrawShape[] } = JSON.parse(result)
const idsMap = Object.fromEntries(this.clipboard.map((shape) => [shape.id, Utils.uniqueId()]))
if (data.type !== 'tldr/clipboard') {
throw Error('The pasted string was not from the tldraw clipboard.')
}
const shapesToPaste = this.clipboard.map((shape) => ({
...shape,
id: idsMap[shape.id],
parentId: idsMap[shape.parentId] || this.currentPageId,
}))
pasteInCurrentPage(data.shapes)
} catch (e) {
const shapeId = Utils.uniqueId()
const commonBounds = Utils.getCommonBounds(shapesToPaste.map(TLDR.getBounds))
this.createShapes({
id: shapeId,
type: TLDrawShapeType.Text,
parentId: this.appState.currentPageId,
text: result,
point: this.getPagePoint(
[window.innerWidth / 2, window.innerHeight / 2],
this.currentPageId
),
style: { ...this.appState.currentStyle },
})
const centeredBounds = Utils.centerBounds(
commonBounds,
this.getPagePoint(point || [window.innerWidth / 2, window.innerHeight / 2])
)
let delta = Vec.sub(Utils.getBoundsCenter(centeredBounds), Utils.getBoundsCenter(commonBounds))
if (Vec.isEqual(delta, [0, 0])) {
delta = [16, 16]
this.select(shapeId)
}
})
} catch {
// Navigator does not support clipboard. Note that this fallback will
// not support pasting from one document to another.
if (this.clipboard) {
pasteInCurrentPage(this.clipboard)
}
}
this.create(
...shapesToPaste.map((shape) => ({
...shape,
point: Vec.round(Vec.add(shape.point, delta)),
}))
)
return this
}
@ -1183,6 +1212,8 @@ export class TLDrawState extends StateManager<Data> {
* @param push Whether to add the ids to the current selection instead.
*/
private setSelectedIds = (ids: string[], push = false): this => {
// Also clear any pasted center
return this.patchState(
{
appState: {
@ -2078,6 +2109,19 @@ export class TLDrawState extends StateManager<Data> {
.filter((shape) => shape.parentId === this.currentPageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
const newShape = utils.create({
id,
parentId: this.currentPageId,
childIndex,
point: pagePoint,
style: { ...this.appState.currentStyle },
})
if (newShape.type === TLDrawShapeType.Text) {
const bounds = utils.getBounds(newShape)
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
}
this.patchState(
{
appState: {
@ -2090,13 +2134,7 @@ export class TLDrawState extends StateManager<Data> {
pages: {
[this.currentPageId]: {
shapes: {
[id]: utils.create({
id,
parentId: this.currentPageId,
childIndex,
point: pagePoint,
style: { ...this.appState.currentStyle },
}),
[id]: newShape,
},
},
},

View file

@ -76,15 +76,11 @@ const { css, createTheme, getCssText } = createStitches({
transitions: {},
},
media: {
micro: '(max-width: 370px)',
sm: '(min-width: 640px)',
md: '(min-width: 768px)',
},
utils: {
zDash: () => (value: number) => {
return {
strokeDasharray: `calc(${value}px / var(--camera-zoom)) calc(${value}px / var(--camera-zoom))`,
}
},
zStrokeWidth: () => (value: number | number[]) => {
if (Array.isArray(value)) {
return {

View file

@ -11,7 +11,9 @@ export const Wrapper: React.FC = ({ children }) => {
return { tlstate, useSelector: tlstate.useStore }
})
useKeyboardShortcuts()
const rWrapper = React.useRef<HTMLDivElement>(null)
useKeyboardShortcuts(rWrapper)
React.useEffect(() => {
if (!document) return
@ -20,7 +22,9 @@ export const Wrapper: React.FC = ({ children }) => {
return (
<TLDrawContext.Provider value={context}>
<IdProvider>{children}</IdProvider>
<IdProvider>
<div ref={rWrapper}>{children}</div>
</IdProvider>
</TLDrawContext.Provider>
)
}

View file

@ -14,7 +14,7 @@ export default function Editor({ id = 'home' }: EditorProps) {
return (
<div className="tldraw">
<TLDraw id={id} onMount={handleMount} />
<TLDraw id={id} onMount={handleMount} autofocus />
</div>
)
}

View file

@ -76,15 +76,11 @@ const { css, globalCss, createTheme, getCssText } = createStitches({
transitions: {},
},
media: {
micro: '(min-width: 0px)',
sm: '(min-width: 640px)',
md: '(min-width: 768px)',
},
utils: {
zDash: () => (value: number) => {
return {
strokeDasharray: `calc(${value}px / var(--camera-zoom)) calc(${value}px / var(--camera-zoom))`,
}
},
zStrokeWidth: () => (value: number | number[]) => {
if (Array.isArray(value)) {
return {

View file

@ -11129,7 +11129,7 @@ raw-body@2.4.1:
iconv-lite "0.4.24"
unpipe "1.0.0"
react-dom@17.0.2, "react-dom@^16.8 || ^17.0", react-dom@^17.0.2:
react-dom@17.0.2, "react-dom@^16.8 || ^17.0":
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
@ -11217,7 +11217,7 @@ react-style-singleton@^2.1.0:
invariant "^2.2.4"
tslib "^1.0.0"
react@17.0.2, react@>=16.8, react@^17.0.2:
react@17.0.2, react@>=16.8:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==