From bbee7bc2b26c677978372cf004cb2d3699696b6c Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Tue, 21 Sep 2021 16:47:04 +0100 Subject: [PATCH] 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 --- packages/core/package.json | 6 +- .../src/components/shape/rendered-shape.tsx | 2 + packages/dev/src/components/editor.tsx | 2 +- packages/dev/src/embedded.tsx | 1 + .../tldraw/src/components/tldraw/tldraw.tsx | 52 ++++- .../components/tools-panel/tools-panel.tsx | 10 +- .../tldraw/src/hooks/useKeyboardShortcuts.tsx | 116 ++++++----- .../text/__snapshots__/text.spec.tsx.snap | 4 +- .../tldraw/src/shape/shapes/text/text.tsx | 7 - .../command/rotate/rotate.command.spec.ts | 4 + packages/tldraw/src/state/notes.md | 45 ----- .../sessions/rotate/rotate.session.spec.ts | 27 +++ packages/tldraw/src/state/tlstate.ts | 188 +++++++++++------- packages/tldraw/src/styles/stitches.config.ts | 6 +- .../tldraw/src/test/renderWithContext.tsx | 8 +- packages/www/components/editor.tsx | 2 +- packages/www/styles/stitches.config.ts | 6 +- yarn.lock | 4 +- 18 files changed, 284 insertions(+), 206 deletions(-) delete mode 100644 packages/tldraw/src/state/notes.md diff --git a/packages/core/package.json b/packages/core/package.json index 134badac6..22ec393e6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" -} +} \ No newline at end of file diff --git a/packages/core/src/components/shape/rendered-shape.tsx b/packages/core/src/components/shape/rendered-shape.tsx index 184056088..b1cb6f201 100644 --- a/packages/core/src/components/shape/rendered-shape.tsx +++ b/packages/core/src/components/shape/rendered-shape.tsx @@ -24,6 +24,8 @@ export const RenderedShape = React.memo( }: RenderedShapeProps) => { const ref = utils.getRef(shape) + // consider using layout effect to update bounds cache if the ref is filled + return ( - + ) } diff --git a/packages/dev/src/embedded.tsx b/packages/dev/src/embedded.tsx index f797d78d7..a60326aed 100644 --- a/packages/dev/src/embedded.tsx +++ b/packages/dev/src/embedded.tsx @@ -10,6 +10,7 @@ export default function Embedded(): JSX.Element { width: 'auto', height: '500px', overflow: 'hidden', + marginBottom: '32px', }} > diff --git a/packages/tldraw/src/components/tldraw/tldraw.tsx b/packages/tldraw/src/components/tldraw/tldraw.tsx index ad768c14a..1f6a9d385 100644 --- a/packages/tldraw/src/components/tldraw/tldraw.tsx +++ b/packages/tldraw/src/components/tldraw/tldraw.tsx @@ -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 ( - + ) @@ -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(null) const page = useSelector(pageSelector) @@ -146,7 +163,8 @@ function InnerTldraw({ }, [currentPageId, tlstate]) return ( -
+
+ }) => { + 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', diff --git a/packages/tldraw/src/components/tools-panel/tools-panel.tsx b/packages/tldraw/src/components/tools-panel/tools-panel.tsx index 6611c9000..e17f3bd3e 100644 --- a/packages/tldraw/src/components/tools-panel/tools-panel.tsx +++ b/packages/tldraw/src/components/tools-panel/tools-panel.tsx @@ -116,7 +116,9 @@ export const ToolsPanel = React.memo((): JSX.Element => {
-
+
*: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, }, }, }, diff --git a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx index 18e11805a..0ce9e41a9 100644 --- a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx +++ b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx @@ -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) { 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] diff --git a/packages/tldraw/src/shape/shapes/text/__snapshots__/text.spec.tsx.snap b/packages/tldraw/src/shape/shapes/text/__snapshots__/text.spec.tsx.snap index 27ee54455..26ebd7bbc 100644 --- a/packages/tldraw/src/shape/shapes/text/__snapshots__/text.spec.tsx.snap +++ b/packages/tldraw/src/shape/shapes/text/__snapshots__/text.spec.tsx.snap @@ -7,8 +7,8 @@ Object { "name": "Text", "parentId": "page", "point": Array [ - -0.5, - -0.5, + 0, + 0, ], "rotation": 0, "style": Object { diff --git a/packages/tldraw/src/shape/shapes/text/text.tsx b/packages/tldraw/src/shape/shapes/text/text.tsx index e1e784bf9..290c763ac 100644 --- a/packages/tldraw/src/shape/shapes/text/text.tsx +++ b/packages/tldraw/src/shape/shapes/text/text.tsx @@ -74,13 +74,6 @@ export const Text = new ShapeUtil(() => ( 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 diff --git a/packages/tldraw/src/state/command/rotate/rotate.command.spec.ts b/packages/tldraw/src/state/command/rotate/rotate.command.spec.ts index 39295f8ac..a85af9a07 100644 --- a/packages/tldraw/src/state/command/rotate/rotate.command.spec.ts +++ b/packages/tldraw/src/state/command/rotate/rotate.command.spec.ts @@ -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.') }) diff --git a/packages/tldraw/src/state/notes.md b/packages/tldraw/src/state/notes.md deleted file mode 100644 index fbc30612c..000000000 --- a/packages/tldraw/src/state/notes.md +++ /dev/null @@ -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`. - -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`. 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. diff --git a/packages/tldraw/src/state/session/sessions/rotate/rotate.session.spec.ts b/packages/tldraw/src/state/session/sessions/rotate/rotate.session.spec.ts index fdc2c3219..86c11c844 100644 --- a/packages/tldraw/src/state/session/sessions/rotate/rotate.session.spec.ts +++ b/packages/tldraw/src/state/session/sessions/rotate/rotate.session.spec.ts @@ -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) + }) + }) }) diff --git a/packages/tldraw/src/state/tlstate.ts b/packages/tldraw/src/state/tlstate.ts index b237e0875..b236254d7 100644 --- a/packages/tldraw/src/state/tlstate.ts +++ b/packages/tldraw/src/state/tlstate.ts @@ -119,6 +119,11 @@ export class TLDrawState extends StateManager { 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 { * @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 { /** * 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 { * @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 { .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 { pages: { [this.currentPageId]: { shapes: { - [id]: utils.create({ - id, - parentId: this.currentPageId, - childIndex, - point: pagePoint, - style: { ...this.appState.currentStyle }, - }), + [id]: newShape, }, }, }, diff --git a/packages/tldraw/src/styles/stitches.config.ts b/packages/tldraw/src/styles/stitches.config.ts index d987aee0f..9dbb60815 100644 --- a/packages/tldraw/src/styles/stitches.config.ts +++ b/packages/tldraw/src/styles/stitches.config.ts @@ -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 { diff --git a/packages/tldraw/src/test/renderWithContext.tsx b/packages/tldraw/src/test/renderWithContext.tsx index 49f722972..ca10b40e0 100644 --- a/packages/tldraw/src/test/renderWithContext.tsx +++ b/packages/tldraw/src/test/renderWithContext.tsx @@ -11,7 +11,9 @@ export const Wrapper: React.FC = ({ children }) => { return { tlstate, useSelector: tlstate.useStore } }) - useKeyboardShortcuts() + const rWrapper = React.useRef(null) + + useKeyboardShortcuts(rWrapper) React.useEffect(() => { if (!document) return @@ -20,7 +22,9 @@ export const Wrapper: React.FC = ({ children }) => { return ( - {children} + +
{children}
+
) } diff --git a/packages/www/components/editor.tsx b/packages/www/components/editor.tsx index 816542303..9ac170546 100644 --- a/packages/www/components/editor.tsx +++ b/packages/www/components/editor.tsx @@ -14,7 +14,7 @@ export default function Editor({ id = 'home' }: EditorProps) { return (
- +
) } diff --git a/packages/www/styles/stitches.config.ts b/packages/www/styles/stitches.config.ts index 53a6c3455..31119f514 100644 --- a/packages/www/styles/stitches.config.ts +++ b/packages/www/styles/stitches.config.ts @@ -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 { diff --git a/yarn.lock b/yarn.lock index bd3a081b5..13ab1f6f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==