├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── README.md ├── apps └── extension │ ├── .github │ └── workflows │ │ └── build.yml │ ├── .gitignore │ ├── .vscode │ ├── settings.json │ └── tasks.json │ ├── LICENSE │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── public │ ├── icon.png │ ├── manifest.json │ ├── options.html │ └── popup.html │ ├── src │ ├── background │ │ └── index.ts │ ├── content │ │ ├── Content.tsx │ │ ├── hooks │ │ │ ├── index.ts │ │ │ └── usePan.ts │ │ ├── index.tsx │ │ └── utils │ │ │ ├── index.ts │ │ │ └── pan.ts │ ├── options │ │ ├── Options.tsx │ │ └── index.tsx │ └── popup │ │ ├── Popup.tsx │ │ ├── index.tsx │ │ └── styles.css │ ├── tsconfig.json │ └── webpack │ ├── webpack.common.js │ ├── webpack.dev.js │ └── webpack.prod.js ├── lerna.json ├── package.json ├── packages └── tldraw │ ├── CHANGELOG.md │ ├── LICENSE.md │ ├── README.md │ ├── card-repo.png │ ├── package.json │ ├── scripts │ ├── build.js │ └── dev.js │ ├── src │ ├── Tldraw.spec.tsx │ ├── Tldraw.tsx │ ├── components │ │ ├── ContextMenu │ │ │ ├── ContextMenu.test.tsx │ │ │ ├── ContextMenu.tsx │ │ │ └── index.ts │ │ ├── FocusButton │ │ │ ├── FocusButton.tsx │ │ │ └── index.ts │ │ ├── Loading │ │ │ ├── Loading.tsx │ │ │ └── index.ts │ │ ├── Primitives │ │ │ ├── Divider │ │ │ │ ├── Divider.tsx │ │ │ │ └── index.ts │ │ │ ├── DropdownMenu │ │ │ │ ├── DMArrow.tsx │ │ │ │ ├── DMCheckboxItem.tsx │ │ │ │ ├── DMContent.tsx │ │ │ │ ├── DMDivider.tsx │ │ │ │ ├── DMItem.tsx │ │ │ │ ├── DMRadioItem.tsx │ │ │ │ ├── DMSubMenu.tsx │ │ │ │ ├── DMTriggerIcon.tsx │ │ │ │ └── index.tsx │ │ │ ├── IconButton │ │ │ │ ├── IconButton.tsx │ │ │ │ └── index.ts │ │ │ ├── Kbd │ │ │ │ ├── Kbd.tsx │ │ │ │ └── index.ts │ │ │ ├── MenuContent │ │ │ │ ├── MenuContent.ts │ │ │ │ └── index.ts │ │ │ ├── Panel │ │ │ │ ├── Panel.tsx │ │ │ │ └── index.ts │ │ │ ├── RowButton │ │ │ │ ├── RowButton.tsx │ │ │ │ └── index.ts │ │ │ ├── SmallIcon │ │ │ │ ├── SmallIcon.tsx │ │ │ │ └── index.ts │ │ │ ├── ToolButton │ │ │ │ ├── ToolButton.tsx │ │ │ │ └── index.ts │ │ │ ├── Tooltip │ │ │ │ ├── Tooltip.tsx │ │ │ │ └── index.ts │ │ │ └── icons │ │ │ │ ├── BoxIcon.tsx │ │ │ │ ├── CircleIcon.tsx │ │ │ │ ├── DashDashedIcon.tsx │ │ │ │ ├── DashDottedIcon.tsx │ │ │ │ ├── DashDrawIcon.tsx │ │ │ │ ├── DashSolidIcon.tsx │ │ │ │ ├── DiscordIcon.tsx │ │ │ │ ├── EraserIcon.tsx │ │ │ │ ├── HeartIcon.tsx │ │ │ │ ├── IsFilledIcon.tsx │ │ │ │ ├── LineIcon.tsx │ │ │ │ ├── MultiplayerIcon.tsx │ │ │ │ ├── RedoIcon.tsx │ │ │ │ ├── SizeLargeIcon.tsx │ │ │ │ ├── SizeMediumIcon.tsx │ │ │ │ ├── SizeSmallIcon.tsx │ │ │ │ ├── TrashIcon.tsx │ │ │ │ ├── UndoIcon.tsx │ │ │ │ └── index.ts │ │ ├── ToolsPanel │ │ │ ├── ActionButton.tsx │ │ │ ├── BackToContent.tsx │ │ │ ├── DeleteButton.tsx │ │ │ ├── LockButton.tsx │ │ │ ├── PenMenu.tsx │ │ │ ├── PrimaryTools.tsx │ │ │ ├── ShapesMenu.tsx │ │ │ ├── StatusBar.tsx │ │ │ ├── ToolsPanel.test.tsx │ │ │ ├── ToolsPanel.tsx │ │ │ └── index.ts │ │ ├── TopPanel │ │ │ ├── Menu │ │ │ │ ├── Menu.tsx │ │ │ │ └── index.ts │ │ │ ├── MultiplayerMenu │ │ │ │ ├── MultiplayerMenu.tsx │ │ │ │ └── index.ts │ │ │ ├── PageMenu │ │ │ │ ├── PageMenu.tsx │ │ │ │ └── index.ts │ │ │ ├── PageOptionsDialog │ │ │ │ ├── PageOptionsDialog.tsx │ │ │ │ └── index.ts │ │ │ ├── PreferencesMenu │ │ │ │ ├── PreferencesMenu.tsx │ │ │ │ └── index.ts │ │ │ ├── StyleMenu │ │ │ │ ├── StyleMenu.test.tsx │ │ │ │ ├── StyleMenu.tsx │ │ │ │ └── index.ts │ │ │ ├── TopPanel.tsx │ │ │ ├── ZoomMenu │ │ │ │ ├── ZoomMenu.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── breakpoints.tsx │ │ ├── preventEvent.ts │ │ └── stopPropagation.ts │ ├── constants.ts │ ├── hooks │ │ ├── index.ts │ │ ├── useFileSystem.ts │ │ ├── useFileSystemHandlers.ts │ │ ├── useKeyboardShortcuts.tsx │ │ ├── useStylesheet.ts │ │ ├── useTheme.ts │ │ └── useTldrawApp.tsx │ ├── index.ts │ ├── state │ │ ├── StateManager │ │ │ ├── StateManager.ts │ │ │ ├── copy.ts │ │ │ └── index.ts │ │ ├── TLDR.ts │ │ ├── TldrawApp.spec.ts │ │ ├── TldrawApp.ts │ │ ├── __snapshots__ │ │ │ └── TldrawApp.spec.ts.snap │ │ ├── commands │ │ │ ├── alignShapes │ │ │ │ ├── alignShapes.spec.ts │ │ │ │ ├── alignShapes.ts │ │ │ │ └── index.ts │ │ │ ├── changePage │ │ │ │ ├── changePage.spec.ts │ │ │ │ ├── changePage.ts │ │ │ │ └── index.ts │ │ │ ├── createPage │ │ │ │ ├── createPage.spec.ts │ │ │ │ ├── createPage.ts │ │ │ │ └── index.ts │ │ │ ├── createShapes │ │ │ │ ├── createShapes.spec.ts │ │ │ │ ├── createShapes.ts │ │ │ │ └── index.ts │ │ │ ├── deletePage │ │ │ │ ├── deletePage.spec.ts │ │ │ │ ├── deletePage.ts │ │ │ │ └── index.ts │ │ │ ├── deleteShapes │ │ │ │ ├── deleteShapes.spec.ts │ │ │ │ ├── deleteShapes.ts │ │ │ │ └── index.ts │ │ │ ├── distributeShapes │ │ │ │ ├── distributeShapes.spec.ts │ │ │ │ ├── distributeShapes.ts │ │ │ │ └── index.ts │ │ │ ├── duplicatePage │ │ │ │ ├── duplicatePage.spec.ts │ │ │ │ ├── duplicatePage.ts │ │ │ │ └── index.ts │ │ │ ├── duplicateShapes │ │ │ │ ├── duplicateShapes.spec.ts │ │ │ │ ├── duplicateShapes.ts │ │ │ │ └── index.ts │ │ │ ├── flipShapes │ │ │ │ ├── flipShapes.spec.ts │ │ │ │ ├── flipShapes.ts │ │ │ │ └── index.ts │ │ │ ├── groupShapes │ │ │ │ ├── groupShapes.spec.ts │ │ │ │ ├── groupShapes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── moveShapesToPage │ │ │ │ ├── index.ts │ │ │ │ ├── moveShapesToPage.spec.ts │ │ │ │ └── moveShapesToPage.ts │ │ │ ├── renamePage │ │ │ │ ├── index.ts │ │ │ │ ├── renamePage.spec.ts │ │ │ │ └── renamePage.ts │ │ │ ├── reorderShapes │ │ │ │ ├── index.ts │ │ │ │ ├── reorderShapes.spec.ts │ │ │ │ └── reorderShapes.ts │ │ │ ├── resetBounds │ │ │ │ ├── index.ts │ │ │ │ ├── resetBounds.spec.ts │ │ │ │ └── resetBounds.ts │ │ │ ├── rotateShapes │ │ │ │ ├── index.ts │ │ │ │ ├── rotateShapes.spec.ts │ │ │ │ └── rotateShapes.ts │ │ │ ├── setShapesProps │ │ │ │ ├── index.ts │ │ │ │ ├── setShapesProps.spec.ts │ │ │ │ └── setShapesProps.ts │ │ │ ├── shared │ │ │ │ └── removeShapesFromPage.ts │ │ │ ├── stretchShapes │ │ │ │ ├── index.ts │ │ │ │ ├── stretchShapes.spec.ts │ │ │ │ └── stretchShapes.ts │ │ │ ├── styleShapes │ │ │ │ ├── index.ts │ │ │ │ ├── styleShapes.spec.ts │ │ │ │ └── styleShapes.ts │ │ │ ├── toggleShapesDecoration │ │ │ │ ├── index.ts │ │ │ │ ├── toggleShapesDecoration.spec.ts │ │ │ │ └── toggleShapesDecoration.ts │ │ │ ├── toggleShapesProp │ │ │ │ ├── index.ts │ │ │ │ ├── toggleShapesProp.spec.ts │ │ │ │ └── toggleShapesProp.ts │ │ │ ├── translateShapes │ │ │ │ ├── index.ts │ │ │ │ ├── translateShapes.spec.ts │ │ │ │ └── translateShapes.ts │ │ │ ├── ungroupShapes │ │ │ │ ├── index.ts │ │ │ │ ├── ungroupShapes.spec.ts │ │ │ │ └── ungroupShapes.ts │ │ │ └── updateShapes │ │ │ │ ├── index.ts │ │ │ │ ├── updateShapes.spec.ts │ │ │ │ └── updateShapes.ts │ │ ├── data │ │ │ ├── browser-fs-access │ │ │ │ ├── directory-open.js │ │ │ │ ├── file-open.js │ │ │ │ ├── file-save.js │ │ │ │ ├── fs-access │ │ │ │ │ ├── directory-open.js │ │ │ │ │ ├── file-open.js │ │ │ │ │ └── file-save.js │ │ │ │ ├── index.d.ts │ │ │ │ ├── index.js │ │ │ │ ├── legacy │ │ │ │ │ ├── directory-open.js │ │ │ │ │ ├── file-open.js │ │ │ │ │ └── file-save.js │ │ │ │ └── supported.js │ │ │ ├── filesystem.spec.ts │ │ │ ├── filesystem.ts │ │ │ ├── index.ts │ │ │ ├── migrate.spec.ts │ │ │ └── migrate.ts │ │ ├── index.ts │ │ ├── internal.ts │ │ ├── sessions │ │ │ ├── ArrowSession │ │ │ │ ├── ArrowSession.spec.ts │ │ │ │ ├── ArrowSession.ts │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── ArrowSession.spec.ts.snap │ │ │ │ ├── arrows.tldr │ │ │ │ └── index.ts │ │ │ ├── BaseSession.ts │ │ │ ├── BrushSession │ │ │ │ ├── BrushSession.spec.ts │ │ │ │ ├── BrushSession.ts │ │ │ │ └── index.ts │ │ │ ├── DrawSession │ │ │ │ ├── DrawSession.spec.ts │ │ │ │ ├── DrawSession.ts │ │ │ │ └── index.ts │ │ │ ├── EraseSession │ │ │ │ ├── EraseSession.spec.ts │ │ │ │ ├── EraseSession.ts │ │ │ │ └── index.ts │ │ │ ├── GridSession │ │ │ │ ├── GridSession.spec.ts │ │ │ │ ├── GridSession.ts │ │ │ │ └── index.ts │ │ │ ├── HandleSession │ │ │ │ ├── HandleSession.spec.ts │ │ │ │ ├── HandleSession.ts │ │ │ │ └── index.ts │ │ │ ├── RotateSession │ │ │ │ ├── RotateSession.spec.ts │ │ │ │ ├── RotateSession.ts │ │ │ │ └── index.ts │ │ │ ├── TransformSession │ │ │ │ ├── TransformSession.spec.ts │ │ │ │ ├── TransformSession.ts │ │ │ │ └── index.ts │ │ │ ├── TransformSingleSession │ │ │ │ ├── TransformSingleSession.spec.ts │ │ │ │ ├── TransformSingleSession.ts │ │ │ │ └── index.ts │ │ │ ├── TranslateLabelSession │ │ │ │ ├── TranslateLabelSession.spec.ts │ │ │ │ ├── TranslateLabelSession.ts │ │ │ │ └── index.ts │ │ │ ├── TranslateSession │ │ │ │ ├── TranslateSession.spec.ts │ │ │ │ ├── TranslateSession.ts │ │ │ │ └── index.ts │ │ │ ├── about-sessions.md │ │ │ └── index.ts │ │ ├── shapes │ │ │ ├── ArrowUtil │ │ │ │ ├── ArrowUtil.spec.tsx │ │ │ │ ├── ArrowUtil.tsx │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── ArrowUtil.spec.tsx.snap │ │ │ │ ├── arrowHelpers.ts │ │ │ │ ├── components │ │ │ │ │ ├── ArrowHead.tsx │ │ │ │ │ ├── CurvedArrow.tsx.tsx │ │ │ │ │ └── StraightArrow.tsx │ │ │ │ └── index.ts │ │ │ ├── DrawUtil │ │ │ │ ├── DrawUtil.spec.tsx │ │ │ │ ├── DrawUtil.tsx │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── DrawUtil.spec.tsx.snap │ │ │ │ ├── drawHelpers.ts │ │ │ │ └── index.ts │ │ │ ├── EllipseUtil │ │ │ │ ├── EllipseUtil.spec.tsx │ │ │ │ ├── EllipseUtil.tsx │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── EllipseUtil.spec.tsx.snap │ │ │ │ ├── components │ │ │ │ │ ├── DashedEllipse.tsx │ │ │ │ │ └── DrawEllipse.tsx │ │ │ │ ├── ellipseHelpers.ts │ │ │ │ └── index.ts │ │ │ ├── GroupUtil │ │ │ │ ├── GroupUtil.spec.tsx │ │ │ │ ├── GroupUtil.tsx │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── GroupUtil.spec.tsx.snap │ │ │ │ └── index.ts │ │ │ ├── ImageUtil │ │ │ │ ├── ImageUtil.spec.tsx │ │ │ │ ├── ImageUtil.tsx │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── ImageUtil.spec.tsx.snap │ │ │ │ └── index.ts │ │ │ ├── RectangleUtil │ │ │ │ ├── RectangleUtil.spec.tsx │ │ │ │ ├── RectangleUtil.tsx │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── RectangleUtil.spec.tsx.snap │ │ │ │ ├── components │ │ │ │ │ ├── BindingIndicator.tsx │ │ │ │ │ ├── DashedRectangle.tsx │ │ │ │ │ └── DrawRectangle.tsx │ │ │ │ ├── index.ts │ │ │ │ └── rectangleHelpers.ts │ │ │ ├── StickyUtil │ │ │ │ ├── StickyUtil.spec.tsx │ │ │ │ ├── StickyUtil.tsx │ │ │ │ └── index.ts │ │ │ ├── TDShapeUtil.tsx │ │ │ ├── TextUtil │ │ │ │ ├── TextUtil.spec.tsx │ │ │ │ ├── TextUtil.tsx │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── TextUtil.spec.tsx.snap │ │ │ │ └── index.ts │ │ │ ├── TriangleUtil │ │ │ │ ├── TriangleUtil.spec.tsx │ │ │ │ ├── TriangleUtil.tsx │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── TriangleUtil.spec.tsx.snap │ │ │ │ ├── components │ │ │ │ │ ├── DashedTriangle.tsx │ │ │ │ │ ├── DrawTriangle.tsx │ │ │ │ │ └── TriangleBindingIndicator.tsx │ │ │ │ ├── index.ts │ │ │ │ └── triangleHelpers.ts │ │ │ ├── VideoUtil │ │ │ │ ├── VideoUtil.spec.tsx │ │ │ │ ├── VideoUtil.tsx │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── VideoUtil.spec.tsx.snap │ │ │ │ └── index.ts │ │ │ ├── about-shape-utils.md │ │ │ ├── index.ts │ │ │ └── shared │ │ │ │ ├── LabelMask.tsx │ │ │ │ ├── PolygonUtils.ts │ │ │ │ ├── TextAreaUtils.ts │ │ │ │ ├── TextLabel.tsx │ │ │ │ ├── getBoundsRectangle.ts │ │ │ │ ├── getTextAlign.ts │ │ │ │ ├── getTextSize.ts │ │ │ │ ├── getTextSvgElement.ts │ │ │ │ ├── index.ts │ │ │ │ ├── shape-styles.ts │ │ │ │ ├── transformRectangle.ts │ │ │ │ ├── transformSingleRectangle.ts │ │ │ │ └── useTextKeyboardEvents.ts │ │ └── tools │ │ │ ├── ArrowTool │ │ │ ├── ArrowTool.spec.ts │ │ │ ├── ArrowTool.ts │ │ │ └── index.ts │ │ │ ├── BaseTool.ts │ │ │ ├── DrawTool │ │ │ ├── DrawTool.spec.ts │ │ │ ├── DrawTool.ts │ │ │ └── index.ts │ │ │ ├── EllipseTool │ │ │ ├── EllipseTool.spec.ts │ │ │ ├── EllipseTool.ts │ │ │ └── index.ts │ │ │ ├── EraseTool │ │ │ ├── EraseTool.spec.ts │ │ │ ├── EraseTool.ts │ │ │ └── index.ts │ │ │ ├── LineTool │ │ │ ├── LineTool.spec.ts │ │ │ ├── LineTool.ts │ │ │ └── index.ts │ │ │ ├── RectangleTool │ │ │ ├── RectangleTool.spec.ts │ │ │ ├── RectangleTool.ts │ │ │ └── index.ts │ │ │ ├── SelectTool │ │ │ ├── SelectTool.spec.ts │ │ │ ├── SelectTool.ts │ │ │ └── index.ts │ │ │ ├── StickyTool │ │ │ ├── StickyTool.spec.ts │ │ │ ├── StickyTool.ts │ │ │ └── index.ts │ │ │ ├── TextTool │ │ │ ├── TextTool.spec.ts │ │ │ ├── TextTool.ts │ │ │ └── index.ts │ │ │ ├── TriangleTool │ │ │ ├── TriangleTool.spec.ts │ │ │ ├── TriangleTool.ts │ │ │ └── index.ts │ │ │ ├── about-tools.md │ │ │ └── index.ts │ ├── styles │ │ ├── index.ts │ │ └── stitches.config.ts │ ├── test │ │ ├── TldrawTestApp.tsx │ │ ├── documents │ │ │ ├── old-doc-2.ts │ │ │ └── old-doc.ts │ │ ├── index.ts │ │ ├── mockDocument.tsx │ │ └── renderWithContext.tsx │ └── types.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── screenshots └── screenshot.png ├── tsconfig.base.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/* 2 | **/out/* 3 | **/.next/* -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "overrides": [ 12 | { 13 | // enable the rule specifically for TypeScript files 14 | "files": ["*.ts", "*.tsx"], 15 | "rules": { 16 | "@typescript-eslint/explicit-module-boundary-types": [0] 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | lib/ 4 | dist/ 5 | docs/ 6 | .idea/* 7 | 8 | .DS_Store 9 | coverage 10 | *.log 11 | 12 | .vercel 13 | .next 14 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "semi": false, 5 | "printWidth": 100 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tldrawe 2 | 3 | A [chrome](https://chrome.google.com/webstore/detail/mhkmpnjdjhckmcejgmajnjhbmclmkdnd) / [firefox](https://addons.mozilla.org/addon/tldrawe/) extension to draw on any webpage with tldraw. 4 | 5 | ![A screenshot of the tldrawe extension](./screenshots/screenshot.png) 6 | 7 | # Development 8 | 9 | From the root folder: 10 | 11 | - Run `yarn` to install dependencies. 12 | 13 | - Run `yarn build` to build the extension and the tldraw package. 14 | 15 | - Chrome: 16 | 17 | - Go to `chrome://extensions/` in your browser. 18 | - Enable developer mode and select `Load Unpacked` and load the `dist` folder. 19 | 20 | - Mozilla: 21 | 22 | - Go to `about:debugging#/runtime/this-firefox` in your browser 23 | - Select `Load Temporary Add-on...` and select any file inside the `dist` folder. 24 | 25 | - Navigate to a website and use the keyboard shortcut `CMD/CTRL + SHIFT + e` to enable the extension. 26 | -------------------------------------------------------------------------------- /apps/extension/.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: build 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x, 15.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /apps/extension/.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules/ 3 | dist/ 4 | tmp/ -------------------------------------------------------------------------------- /apps/extension/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "files.eol": "\n", 4 | "json.schemas": [ 5 | { 6 | "fileMatch": [ 7 | "/manifest.json" 8 | ], 9 | "url": "http://json.schemastore.org/chrome-manifest" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /apps/extension/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "command": "npm", 6 | "tasks": [ 7 | { 8 | "label": "install", 9 | "type": "shell", 10 | "command": "npm", 11 | "args": ["install"] 12 | }, 13 | { 14 | "label": "update", 15 | "type": "shell", 16 | "command": "npm", 17 | "args": ["update"] 18 | }, 19 | { 20 | "label": "test", 21 | "type": "shell", 22 | "command": "npm", 23 | "args": ["run", "test"] 24 | }, 25 | { 26 | "label": "build", 27 | "type": "shell", 28 | "group": "build", 29 | "command": "npm", 30 | "args": ["run", "watch"] 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /apps/extension/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tomofumi Chiba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/extension/README.md: -------------------------------------------------------------------------------- 1 | # Chrome Extension TypeScript Starter 2 | 3 | ![build](https://github.com/chibat/chrome-extension-typescript-starter/workflows/build/badge.svg) 4 | 5 | Chrome Extension, TypeScript and Visual Studio Code 6 | 7 | ## Prerequisites 8 | 9 | * [node + npm](https://nodejs.org/) (Current Version) 10 | 11 | ## Option 12 | 13 | * [Visual Studio Code](https://code.visualstudio.com/) 14 | 15 | ## Includes the following 16 | 17 | * TypeScript 18 | * Webpack 19 | * React 20 | * Jest 21 | * Example Code 22 | * Chrome Storage 23 | * Options Version 2 24 | * content script 25 | * count up badge number 26 | * background 27 | 28 | ## Project Structure 29 | 30 | * src/typescript: TypeScript source files 31 | * src/assets: static files 32 | * dist: Chrome Extension directory 33 | * dist/js: Generated JavaScript files 34 | 35 | ## Setup 36 | 37 | ``` 38 | npm install 39 | ``` 40 | 41 | ## Import as Visual Studio Code project 42 | 43 | ... 44 | 45 | ## Build 46 | 47 | ``` 48 | npm run build 49 | ``` 50 | 51 | ## Build in watch mode 52 | 53 | ### terminal 54 | 55 | ``` 56 | npm run watch 57 | ``` 58 | 59 | ### Visual Studio Code 60 | 61 | Run watch mode. 62 | 63 | type `Ctrl + Shift + B` 64 | 65 | ## Load extension to chrome 66 | 67 | Load `dist` directory 68 | 69 | ## Test 70 | `npx jest` or `npm run test` 71 | -------------------------------------------------------------------------------- /apps/extension/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "src" 4 | ], 5 | "transform": { 6 | "^.+\\.ts$": "ts-jest" 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /apps/extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tldrawe/extension", 3 | "version": "0.0.1", 4 | "description": "Draw on any webpage with tldraw", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack --config webpack/webpack.dev.js --watch", 8 | "build": "webpack --config webpack/webpack.prod.js", 9 | "clean": "rimraf dist", 10 | "test": "npx jest", 11 | "style": "prettier --write \"src/**/*.{ts,tsx}\"" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/chibat/chrome-extension-typescript-starter.git" 18 | }, 19 | "dependencies": { 20 | "@tldraw/vec": "^1.4.3", 21 | "@tldrawe/tldraw": "^1.6.1", 22 | "mobx": "^6.3.13", 23 | "react": "^17.0.1", 24 | "react-dom": "^17.0.1", 25 | "is-hotkey": "0.2.0", 26 | "webextension-polyfill": "^0.8.0" 27 | }, 28 | "devDependencies": { 29 | "@tldraw/core": "^1.6.1", 30 | "@types/chrome": "^0.0.178", 31 | "@types/webextension-polyfill": "^0.8.0", 32 | "@types/jest": "^27.4.0", 33 | "@types/mocha": "^9.1.0", 34 | "@types/react": "^17.0.0", 35 | "@types/react-dom": "^17.0.0", 36 | "@types/is-hotkey": "0.1.7", 37 | "copy-webpack-plugin": "^9.0.1", 38 | "glob": "^7.1.6", 39 | "jest": "^27.2.1", 40 | "prettier": "^2.2.1", 41 | "rimraf": "^3.0.2 ", 42 | "ts-jest": "^27.0.5", 43 | "ts-loader": "^8.0.0", 44 | "css-loader": "^5.2.7", 45 | "style-loader": "^3.1.0", 46 | "typescript": "^4.4.3 ", 47 | "webpack": "^5.0.0", 48 | "webpack-cli": "^4.0.0", 49 | "webpack-merge": "^5.0.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apps/extension/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimeshnayaju/tldrawe/c96676b75bebdf30aa2f9db01fa7f4af4e594099/apps/extension/public/icon.png -------------------------------------------------------------------------------- /apps/extension/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | 4 | "name": "tldrawe", 5 | "description": "Draw on any webpage with tldraw", 6 | "version": "0.2", 7 | 8 | "options_ui": { 9 | "page": "options.html" 10 | }, 11 | 12 | "browser_action": { 13 | "default_icon": "icon.png", 14 | "default_popup": "popup.html" 15 | }, 16 | 17 | "content_scripts": [ 18 | { 19 | "matches": [""], 20 | "js": ["js/vendor.js", "js/content.js"] 21 | } 22 | ], 23 | 24 | "background": { 25 | "scripts": ["js/background.js"] 26 | }, 27 | 28 | "browser_specific_settings": { 29 | "gecko": { 30 | "id": "{8ae89444-3b6c-44cb-bea4-c3119d0fe179}" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/extension/public/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | My Test Extension Options 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /apps/extension/public/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Getting Started Extension's Popup 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/extension/src/background/index.ts: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill' 2 | 3 | const handleZoomChange = (info: browser.Tabs.OnZoomChangeZoomChangeInfoType) => { 4 | browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { 5 | const tab = tabs[0] 6 | if (tab.id === info.tabId) { 7 | browser.tabs 8 | .sendMessage(tab.id, { 9 | zoom: info.newZoomFactor, 10 | }) 11 | .catch((err) => { 12 | console.warn(err) 13 | }) 14 | } 15 | }) 16 | } 17 | 18 | const handleMessage = (message: any) => { 19 | if (message.type === 'zoom') { 20 | return browser.tabs.getZoom() 21 | } 22 | } 23 | 24 | browser.runtime.onMessage.addListener(handleMessage) 25 | 26 | browser.tabs.onZoomChange.addListener(handleZoomChange) 27 | -------------------------------------------------------------------------------- /apps/extension/src/content/Content.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Tldraw } from '@tldrawe/tldraw' 3 | import isHotkey from 'is-hotkey' 4 | import browser from 'webextension-polyfill' 5 | import { usePan } from './hooks' 6 | 7 | const TOGGLE_OVERLAY = 'mod+shift+e' 8 | 9 | const Content = () => { 10 | const [showOverlay, setShowOverlay] = React.useState(false) 11 | const { setZoom, onMount, onPan, onChangePage } = usePan() 12 | 13 | /** 14 | * Register an event listener to listen to keyboard events to toggle overlay 15 | */ 16 | React.useEffect(() => { 17 | const displayOverlayKeyHandler = (e: KeyboardEvent) => { 18 | if (isHotkey(TOGGLE_OVERLAY, e)) { 19 | setShowOverlay((showOverlay) => !showOverlay) 20 | } 21 | } 22 | 23 | window.addEventListener('keydown', displayOverlayKeyHandler, false) 24 | 25 | return () => { 26 | window.addEventListener('keydown', displayOverlayKeyHandler, false) 27 | } 28 | }, []) 29 | 30 | /** 31 | * Register an event listener to listen to messages from an extension process 32 | */ 33 | React.useEffect(() => { 34 | const onReceiveMessage = (message: any) => { 35 | if (message.toggle) { 36 | setShowOverlay((showOverlay) => !showOverlay) 37 | } 38 | if (message.zoom) { 39 | setZoom(message.zoom) 40 | } 41 | } 42 | 43 | browser.runtime.onMessage.addListener(onReceiveMessage) 44 | 45 | return () => { 46 | browser.runtime.onMessage.removeListener(onReceiveMessage) 47 | } 48 | }, []) 49 | 50 | return ( 51 | <> 52 | {showOverlay && ( 53 |
62 | 71 |
72 | )} 73 | 74 | ) 75 | } 76 | 77 | export default Content 78 | -------------------------------------------------------------------------------- /apps/extension/src/content/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './usePan' 2 | -------------------------------------------------------------------------------- /apps/extension/src/content/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Content from './Content' 4 | 5 | const container = document.createElement('div') 6 | document.documentElement.prepend(container) 7 | 8 | ReactDOM.render(, container) 9 | -------------------------------------------------------------------------------- /apps/extension/src/content/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pan' 2 | -------------------------------------------------------------------------------- /apps/extension/src/content/utils/pan.ts: -------------------------------------------------------------------------------- 1 | import Vec from '@tldraw/vec' 2 | 3 | /** 4 | * Normalizes the delta w.r.t to the window height and zoom level 5 | */ 6 | export const normalizeDelta = (delta: number[], zoom: number): number[] => { 7 | // Get the delta (taking into consideration the zoom level) 8 | const normalizedDelta = Vec.div(delta, zoom) 9 | 10 | // Disable horizontal panning (for now) 11 | normalizedDelta[0] = 0 12 | 13 | // Normalize delta value for vertical panning (y-axis) 14 | if (normalizedDelta[1] > 0) { 15 | // Get the maximum height that can be scrolled downwards 16 | const maxScrollYDelta = Math.floor(getScrollHeight() - window.innerHeight - window.scrollY) 17 | normalizedDelta[1] = Math.min(normalizedDelta[1], maxScrollYDelta) 18 | } else { 19 | // Get the maximum height that can be scrolled upwards 20 | const maxScrollYDelta = -1 * window.scrollY 21 | normalizedDelta[1] = Math.max(normalizedDelta[1], maxScrollYDelta) 22 | } 23 | return normalizedDelta 24 | } 25 | 26 | /** 27 | * @returns (scroll) height of the document 28 | */ 29 | const getScrollHeight = (): number => { 30 | return Math.max( 31 | document.body.scrollHeight, 32 | document.documentElement.scrollHeight, 33 | document.body.offsetHeight, 34 | document.documentElement.offsetHeight, 35 | document.body.clientHeight, 36 | document.documentElement.clientHeight 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /apps/extension/src/options/Options.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Options = () => { 4 | return ( 5 | <> 6 |

7 | If you like and/or use the extension, please star the project on{' '} 8 | 9 | Github 10 | {' '} 11 | and consider sponsoring{' '} 12 | 13 | @steveruiz 14 | 15 | , who created the tldraw library. 16 |

17 | 18 | ) 19 | } 20 | 21 | export default Options 22 | -------------------------------------------------------------------------------- /apps/extension/src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Options from './Options' 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById('root') 10 | ) 11 | -------------------------------------------------------------------------------- /apps/extension/src/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import browser from 'webextension-polyfill' 3 | import './styles.css' 4 | 5 | const Popup = () => { 6 | const onToggle = React.useCallback(() => { 7 | browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { 8 | const tab = tabs[0] 9 | if (tab.id) { 10 | browser.tabs.sendMessage(tab.id, { 11 | toggle: true, 12 | }) 13 | } 14 | }) 15 | }, []) 16 | 17 | return ( 18 |
19 | 29 |
30 | ) 31 | } 32 | 33 | export default Popup 34 | -------------------------------------------------------------------------------- /apps/extension/src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Popup from './Popup' 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById('root') 10 | ) 11 | -------------------------------------------------------------------------------- /apps/extension/src/popup/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 15px; 7 | font-weight: 500; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 9 | 'Open Sans', 'Helvetica Neue', sans-serif; 10 | } 11 | 12 | .popup-panel { 13 | position: relative; 14 | overflow: hidden; 15 | user-select: none; 16 | display: flex; 17 | flex-direction: column; 18 | min-width: 180px; 19 | background-color: #fefefe; 20 | border-radius: 4px; 21 | border: 1px solid rgba(0, 0, 0, 0.1); 22 | box-shadow: rgb(0 0 0 / 2%) 0 1px 3px 0; 23 | padding: 4px; 24 | } 25 | 26 | .popup-btn { 27 | position: relative; 28 | width: 100%; 29 | background: none; 30 | border: none; 31 | cursor: pointer; 32 | height: 32px; 33 | outline: none; 34 | color: #333333; 35 | font-weight: 500; 36 | font-size: 13px; 37 | border-radius: 4px; 38 | user-select: none; 39 | margin: 0px; 40 | padding: 0px; 41 | } 42 | 43 | .popup-btn-inner { 44 | height: 100%; 45 | width: 100%; 46 | background-color: #fefefe; 47 | border-radius: inherit; 48 | display: flex; 49 | gap: 3px; 50 | flex-direction: row; 51 | align-items: center; 52 | padding: 0 8px; 53 | justify-content: space-between; 54 | border: 1px solid transparent; 55 | } 56 | 57 | .popup-btn-inner:focus, 58 | .popup-btn-inner:hover { 59 | background-color: #ececec; 60 | } 61 | 62 | .popup-btn-inner .popup-btn-kbd { 63 | margin-left: 8px; 64 | text-shadow: 0px 1px 1px rgba(0, 0, 0, 0.14); 65 | text-align: center; 66 | font-size: 10px; 67 | color: #333332; 68 | background: none; 69 | font-weight: 500; 70 | gap: 3px; 71 | display: flex; 72 | align-items: center; 73 | } 74 | -------------------------------------------------------------------------------- /apps/extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "commonjs", 5 | "target": "es6", 6 | "esModuleInterop": true, 7 | "sourceMap": false, 8 | "rootDir": "src", 9 | "outDir": "dist/js", 10 | "noEmitOnError": true, 11 | "jsx": "react", 12 | "baseUrl": ".", 13 | "paths": { 14 | "*": ["./*"], 15 | "@tldrawe/tldraw": ["../../packages/tldraw"] 16 | } 17 | }, 18 | "references": [{ "path": "../../packages/tldraw" }] 19 | } 20 | -------------------------------------------------------------------------------- /apps/extension/webpack/webpack.common.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const CopyPlugin = require('copy-webpack-plugin') 4 | const srcDir = path.join(__dirname, '..', 'src') 5 | 6 | module.exports = { 7 | entry: { 8 | popup: path.join(srcDir, 'popup'), 9 | options: path.join(srcDir, 'options'), 10 | background: path.join(srcDir, 'background'), 11 | content: path.join(srcDir, 'content'), 12 | }, 13 | output: { 14 | path: path.join(__dirname, '../dist/js'), 15 | filename: '[name].js', 16 | }, 17 | optimization: { 18 | splitChunks: { 19 | name: 'vendor', 20 | chunks(chunk) { 21 | return chunk.name !== 'background' 22 | }, 23 | }, 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.css$/i, 29 | use: ['style-loader', 'css-loader'], 30 | }, 31 | { 32 | test: /\.tsx?$/, 33 | use: 'ts-loader', 34 | exclude: /node_modules/, 35 | }, 36 | ], 37 | }, 38 | resolve: { 39 | extensions: ['.ts', '.tsx', '.js'], 40 | }, 41 | plugins: [ 42 | new CopyPlugin({ 43 | patterns: [{ from: '.', to: '../', context: 'public' }], 44 | options: {}, 45 | }), 46 | ], 47 | } 48 | -------------------------------------------------------------------------------- /apps/extension/webpack/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | devtool: 'inline-source-map', 6 | mode: 'development' 7 | }); -------------------------------------------------------------------------------- /apps/extension/webpack/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'production' 6 | }); -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "independent", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tldrawe/monorepo", 3 | "workspaces": [ 4 | "packages/tldraw", 5 | "apps/extension" 6 | ], 7 | "scripts": { 8 | "build:extension": "yarn build:packages && cd apps/extension && yarn build", 9 | "build:packages": "lerna run build:packages --stream", 10 | "build:apps": "lerna run build:apps", 11 | "start": "yarn build:packages && lerna run start --stream --parallel", 12 | "start:all": "yarn build:packages && lerna run start:all --stream --parallel", 13 | "start:extension": "yarn build:packages && cd apps/extension && yarn start", 14 | "clean": "lerna run clean --parallel", 15 | "publish:patch": "yarn build:packages && yarn test && lerna publish", 16 | "fix:style": "yarn run prettier ./packages/tldraw/src --write", 17 | "lerna": "lerna", 18 | "test": "lerna run test --stream", 19 | "test:ci": "lerna run test:ci --stream", 20 | "test:watch": "lerna run test:watch --stream", 21 | "docs": "lerna run typedoc", 22 | "docs:watch": "lerna run typedoc --watch" 23 | }, 24 | "version": "0.0.1", 25 | "repository": "git@github.com:nimeshnayaju/tldrawe.git", 26 | "author": "nimeshnayaju ", 27 | "license": "MIT", 28 | "private": true, 29 | "devDependencies": { 30 | "@types/chrome": "^0.0.178", 31 | "@types/jest": "^27.4.0", 32 | "@types/mocha": "^9.1.0", 33 | "@types/react": "^17.0.38", 34 | "@types/react-dom": "^17.0.11", 35 | "@typescript-eslint/eslint-plugin": "^5.9.1", 36 | "@typescript-eslint/parser": "^5.9.1", 37 | "cross-env": "^7.0.3", 38 | "eslint": "^8.6.0", 39 | "jest": "^27.4.7", 40 | "lerna": "^4.0.0", 41 | "prettier": "^2.5.1", 42 | "typescript": "^4.5.4" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/tldraw/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Stephen Ruiz Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/tldraw/card-repo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimeshnayaju/tldrawe/c96676b75bebdf30aa2f9db01fa7f4af4e594099/packages/tldraw/card-repo.png -------------------------------------------------------------------------------- /packages/tldraw/scripts/build.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const fs = require('fs') 3 | const esbuild = require('esbuild') 4 | const { gzip } = require('zlib') 5 | const pkg = require('../package.json') 6 | 7 | const { log: jslog } = console 8 | 9 | async function main() { 10 | if (fs.existsSync('./dist')) { 11 | fs.rmSync('./dist', { recursive: true }, (e) => { 12 | if (e) { 13 | throw e 14 | } 15 | }) 16 | } 17 | 18 | try { 19 | esbuild.buildSync({ 20 | entryPoints: ['./src/index.ts'], 21 | outdir: 'dist/cjs', 22 | minify: false, 23 | bundle: true, 24 | format: 'cjs', 25 | target: 'es6', 26 | jsxFactory: 'React.createElement', 27 | jsxFragment: 'React.Fragment', 28 | tsconfig: './tsconfig.json', 29 | external: Object.keys(pkg.dependencies).concat(Object.keys(pkg.peerDependencies)), 30 | metafile: true, 31 | sourcemap: true, 32 | define: { 33 | 'process.env.NODE_ENV': '"production"', 34 | }, 35 | }) 36 | 37 | const esmResult = esbuild.buildSync({ 38 | entryPoints: ['./src/index.ts'], 39 | outdir: 'dist/esm', 40 | minify: false, 41 | bundle: true, 42 | format: 'esm', 43 | target: 'es6', 44 | tsconfig: './tsconfig.build.json', 45 | jsxFactory: 'React.createElement', 46 | jsxFragment: 'React.Fragment', 47 | external: Object.keys(pkg.dependencies).concat(Object.keys(pkg.peerDependencies)), 48 | metafile: true, 49 | sourcemap: true, 50 | define: { 51 | 'process.env.NODE_ENV': '"production"', 52 | }, 53 | }) 54 | 55 | const esmSize = Object.values(esmResult.metafile.outputs).reduce( 56 | (acc, { bytes }) => acc + bytes, 57 | 0 58 | ) 59 | 60 | fs.readFile('./dist/esm/index.js', (_err, data) => { 61 | gzip(data, (_err, result) => { 62 | jslog( 63 | `✔ ${pkg.name}: Built pkg. ${(esmSize / 1000).toFixed(2)}kb (${( 64 | result.length / 1000 65 | ).toFixed(2)}kb minified)` 66 | ) 67 | }) 68 | }) 69 | } catch (e) { 70 | jslog(`× ${pkg.name}: Build failed due to an error.`) 71 | jslog(e) 72 | } 73 | } 74 | 75 | main() 76 | -------------------------------------------------------------------------------- /packages/tldraw/scripts/dev.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const esbuild = require('esbuild') 3 | const pkg = require('../package.json') 4 | 5 | const { log: jslog } = console 6 | 7 | async function main() { 8 | esbuild.build({ 9 | entryPoints: ['./src/index.ts'], 10 | outdir: 'dist/esm', 11 | minify: false, 12 | bundle: true, 13 | format: 'esm', 14 | target: 'es6', 15 | jsxFactory: 'React.createElement', 16 | jsxFragment: 'React.Fragment', 17 | tsconfig: './tsconfig.build.json', 18 | external: Object.keys(pkg.dependencies).concat(Object.keys(pkg.peerDependencies)), 19 | sourcemap: true, 20 | incremental: true, 21 | watch: { 22 | onRebuild(error) { 23 | if (error) { 24 | jslog(`× ${pkg.name}: An error in prevented the rebuild.`) 25 | return 26 | } 27 | jslog(`✔ ${pkg.name}: Rebuilt.`) 28 | }, 29 | }, 30 | }) 31 | } 32 | 33 | main() 34 | -------------------------------------------------------------------------------- /packages/tldraw/src/Tldraw.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { render, waitFor } from '@testing-library/react' 3 | import { Tldraw } from './Tldraw' 4 | 5 | describe('Tldraw', () => { 6 | test('mounts component and calls onMount', async () => { 7 | const onMount = jest.fn() 8 | render() 9 | await waitFor(onMount) 10 | }) 11 | 12 | test('mounts component and calls onMount when id is present', async () => { 13 | const onMount = jest.fn() 14 | render() 15 | await waitFor(onMount) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/ContextMenu/ContextMenu.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ContextMenu } from './ContextMenu' 3 | import { renderWithContext } from '~test' 4 | 5 | describe('context menu', () => { 6 | test('mounts component without crashing', () => { 7 | renderWithContext( 8 | 9 |
Hello
10 |
11 | ) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/ContextMenu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ContextMenu' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/FocusButton/FocusButton.tsx: -------------------------------------------------------------------------------- 1 | import { DotFilledIcon } from '@radix-ui/react-icons' 2 | import * as React from 'react' 3 | import { IconButton } from '~components/Primitives/IconButton/IconButton' 4 | import { styled } from '~styles' 5 | 6 | interface FocusButtonProps { 7 | onSelect: () => void 8 | } 9 | 10 | export function FocusButton({ onSelect }: FocusButtonProps) { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ) 18 | } 19 | 20 | const StyledButtonContainer = styled('div', { 21 | opacity: 1, 22 | zIndex: 100, 23 | backgroundColor: 'transparent', 24 | 25 | '& svg': { 26 | color: '$text', 27 | }, 28 | 29 | '&:hover svg': { 30 | color: '$text', 31 | }, 32 | }) 33 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/FocusButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FocusButton' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Panel } from '~components/Primitives/Panel' 3 | import { useTldrawApp } from '~hooks' 4 | import { styled } from '~styles' 5 | import type { TDSnapshot } from '~types' 6 | 7 | const loadingSelector = (s: TDSnapshot) => s.appState.isLoading 8 | 9 | export function Loading() { 10 | const app = useTldrawApp() 11 | const isLoading = app.useStore(loadingSelector) 12 | 13 | return 14 | } 15 | 16 | const StyledLoadingPanelContainer = styled('div', { 17 | position: 'absolute', 18 | top: 0, 19 | left: '50%', 20 | transform: `translate(-50%, 0)`, 21 | borderBottomLeftRadius: '12px', 22 | borderBottomRightRadius: '12px', 23 | padding: '8px 16px', 24 | fontFamily: 'var(--fonts-ui)', 25 | fontSize: 'var(--fontSizes-1)', 26 | boxShadow: 'var(--shadows-panel)', 27 | backgroundColor: 'white', 28 | zIndex: 200, 29 | pointerEvents: 'none', 30 | '& > div > *': { 31 | pointerEvents: 'all', 32 | }, 33 | variants: { 34 | transform: { 35 | hidden: { 36 | transform: `translate(-50%, 100%)`, 37 | }, 38 | visible: { 39 | transform: `translate(-50%, 0%)`, 40 | }, 41 | }, 42 | }, 43 | }) 44 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Loading/index.ts: -------------------------------------------------------------------------------- 1 | export { Loading } from './Loading' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/Divider/Divider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { styled } from '~styles' 3 | 4 | export const Divider = styled('hr', { 5 | height: 1, 6 | marginTop: '$1', 7 | marginRight: '-$2', 8 | marginBottom: '$1', 9 | marginLeft: '-$2', 10 | border: 'none', 11 | borderBottom: '1px solid $hover', 12 | }) 13 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/Divider/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Divider' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/DropdownMenu/DMArrow.tsx: -------------------------------------------------------------------------------- 1 | import { Arrow } from '@radix-ui/react-dropdown-menu' 2 | import { breakpoints } from '~components/breakpoints' 3 | import { styled } from '~styles/stitches.config' 4 | 5 | export const DMArrow = styled(Arrow, { fill: '$panel', bp: breakpoints }) 6 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/DropdownMenu/DMCheckboxItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { CheckboxItem } from '@radix-ui/react-dropdown-menu' 3 | import { RowButton, RowButtonProps } from '~components/Primitives/RowButton' 4 | import { preventEvent } from '~components/preventEvent' 5 | 6 | interface DMCheckboxItemProps { 7 | checked: boolean 8 | disabled?: boolean 9 | onCheckedChange: (isChecked: boolean) => void 10 | children: React.ReactNode 11 | variant?: RowButtonProps['variant'] 12 | kbd?: string 13 | id?: string 14 | } 15 | 16 | export function DMCheckboxItem({ 17 | checked, 18 | disabled = false, 19 | variant, 20 | onCheckedChange, 21 | kbd, 22 | id, 23 | children, 24 | }: DMCheckboxItemProps): JSX.Element { 25 | return ( 26 | 35 | 36 | {children} 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/DropdownMenu/DMContent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Content } from '@radix-ui/react-dropdown-menu' 3 | import { styled } from '~styles/stitches.config' 4 | import { MenuContent } from '~components/Primitives/MenuContent' 5 | import { stopPropagation } from '~components/stopPropagation' 6 | 7 | export interface DMContentProps { 8 | variant?: 'menu' | 'horizontal' 9 | align?: 'start' | 'center' | 'end' 10 | sideOffset?: number 11 | children: React.ReactNode 12 | id?: string 13 | } 14 | 15 | export function DMContent({ 16 | sideOffset = 8, 17 | children, 18 | align, 19 | variant, 20 | id, 21 | }: DMContentProps): JSX.Element { 22 | return ( 23 | 31 | {children} 32 | 33 | ) 34 | } 35 | 36 | export const StyledContent = styled(MenuContent, { 37 | width: 'fit-content', 38 | height: 'fit-content', 39 | minWidth: 0, 40 | variants: { 41 | variant: { 42 | horizontal: { 43 | flexDirection: 'row', 44 | }, 45 | menu: { 46 | minWidth: 128, 47 | }, 48 | }, 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/DropdownMenu/DMDivider.tsx: -------------------------------------------------------------------------------- 1 | import { Separator } from '@radix-ui/react-dropdown-menu' 2 | import { styled } from '~styles/stitches.config' 3 | 4 | export const DMDivider = styled(Separator, { 5 | backgroundColor: '$hover', 6 | height: 1, 7 | marginTop: '$2', 8 | marginRight: '-$2', 9 | marginBottom: '$2', 10 | marginLeft: '-$2', 11 | }) 12 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/DropdownMenu/DMItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Item } from '@radix-ui/react-dropdown-menu' 3 | import { RowButton, RowButtonProps } from '~components/Primitives/RowButton' 4 | 5 | export function DMItem({ 6 | onSelect, 7 | id, 8 | ...rest 9 | }: RowButtonProps & { onSelect?: (event: Event) => void; id?: string }): JSX.Element { 10 | return ( 11 | 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/DropdownMenu/DMRadioItem.tsx: -------------------------------------------------------------------------------- 1 | import { RadioItem } from '@radix-ui/react-dropdown-menu' 2 | import { styled } from '~styles/stitches.config' 3 | 4 | export const DMRadioItem = styled(RadioItem, { 5 | height: '32px', 6 | width: '32px', 7 | backgroundColor: '$panel', 8 | borderRadius: '4px', 9 | padding: '0', 10 | margin: '0', 11 | display: 'flex', 12 | alignItems: 'center', 13 | justifyContent: 'center', 14 | outline: 'none', 15 | border: 'none', 16 | pointerEvents: 'all', 17 | cursor: 'pointer', 18 | 19 | variants: { 20 | isActive: { 21 | true: { 22 | backgroundColor: '$selected', 23 | color: '$panel', 24 | }, 25 | false: {}, 26 | }, 27 | bp: { 28 | mobile: {}, 29 | small: {}, 30 | }, 31 | }, 32 | 33 | compoundVariants: [ 34 | { 35 | isActive: false, 36 | bp: 'small', 37 | css: { 38 | '&:focus': { 39 | backgroundColor: '$hover', 40 | }, 41 | '&:hover:not(:disabled)': { 42 | backgroundColor: '$hover', 43 | }, 44 | }, 45 | }, 46 | ], 47 | }) 48 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/DropdownMenu/DMSubMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Root, TriggerItem, Content, Arrow } from '@radix-ui/react-dropdown-menu' 3 | import { RowButton } from '~components/Primitives/RowButton' 4 | import { MenuContent } from '~components/Primitives/MenuContent' 5 | 6 | export interface DMSubMenuProps { 7 | label: string 8 | size?: 'small' 9 | disabled?: boolean 10 | children: React.ReactNode 11 | id?: string 12 | } 13 | 14 | export function DMSubMenu({ 15 | children, 16 | size, 17 | disabled = false, 18 | label, 19 | id, 20 | }: DMSubMenuProps): JSX.Element { 21 | return ( 22 | 23 | 24 | 25 | 26 | {label} 27 | 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/DropdownMenu/DMTriggerIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Trigger } from '@radix-ui/react-dropdown-menu' 3 | import { ToolButton, ToolButtonProps } from '~components/Primitives/ToolButton' 4 | 5 | interface DMTriggerIconProps extends ToolButtonProps { 6 | children: React.ReactNode 7 | id?: string 8 | } 9 | 10 | export function DMTriggerIcon({ id, children, ...rest }: DMTriggerIconProps) { 11 | return ( 12 | 13 | {children} 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/DropdownMenu/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './DMArrow' 2 | export * from './DMItem' 3 | export * from './DMCheckboxItem' 4 | export * from './DMContent' 5 | export * from './DMDivider' 6 | export * from './DMRadioItem' 7 | export * from './DMSubMenu' 8 | export * from './DMTriggerIcon' 9 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/IconButton/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '~styles' 2 | 3 | export const IconButton = styled('button', { 4 | position: 'relative', 5 | height: '32px', 6 | width: '32px', 7 | backgroundColor: '$panel', 8 | borderRadius: '4px', 9 | padding: '0', 10 | margin: '0', 11 | outline: 'none', 12 | border: 'none', 13 | pointerEvents: 'all', 14 | fontSize: '$0', 15 | color: '$text', 16 | cursor: 'pointer', 17 | display: 'grid', 18 | alignItems: 'center', 19 | justifyContent: 'center', 20 | 21 | '& > *': { 22 | gridRow: 1, 23 | gridColumn: 1, 24 | }, 25 | 26 | '&:disabled': { 27 | opacity: '0.5', 28 | }, 29 | 30 | '& > span': { 31 | width: '100%', 32 | height: '100%', 33 | display: 'flex', 34 | alignItems: 'center', 35 | }, 36 | 37 | variants: { 38 | bp: { 39 | mobile: { 40 | backgroundColor: 'transparent', 41 | }, 42 | small: { 43 | '&:hover:not(:disabled)': { 44 | backgroundColor: '$hover', 45 | }, 46 | }, 47 | }, 48 | size: { 49 | small: { 50 | height: 32, 51 | width: 32, 52 | '& svg:nth-of-type(1)': { 53 | height: '16px', 54 | width: '16px', 55 | }, 56 | }, 57 | medium: { 58 | height: 44, 59 | width: 44, 60 | '& svg:nth-of-type(1)': { 61 | height: '18px', 62 | width: '18px', 63 | }, 64 | }, 65 | large: { 66 | height: 44, 67 | width: 44, 68 | '& svg:nth-of-type(1)': { 69 | height: '20px', 70 | width: '20px', 71 | }, 72 | }, 73 | }, 74 | isActive: { 75 | true: { 76 | color: '$selected', 77 | }, 78 | }, 79 | }, 80 | }) 81 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/IconButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IconButton' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/Kbd/Kbd.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { styled } from '~styles' 3 | import { Utils } from '@tldraw/core' 4 | 5 | /* -------------------------------------------------- */ 6 | /* Keyboard Shortcut */ 7 | /* -------------------------------------------------- */ 8 | 9 | const commandKey = () => (Utils.isDarwin() ? '⌘' : 'Ctrl') 10 | 11 | export function Kbd({ 12 | variant, 13 | children, 14 | }: { 15 | variant: 'tooltip' | 'menu' 16 | children: string 17 | }): JSX.Element | null { 18 | return ( 19 | 20 | {children.split('').map((k, i) => { 21 | return {k.replace('#', commandKey())} 22 | })} 23 | 24 | ) 25 | } 26 | 27 | export const StyledKbd = styled('kbd', { 28 | marginLeft: '$3', 29 | textShadow: '$2', 30 | textAlign: 'center', 31 | fontSize: '$0', 32 | fontFamily: '$ui', 33 | color: '$text', 34 | background: 'none', 35 | fontWeight: 400, 36 | gap: '$1', 37 | display: 'flex', 38 | alignItems: 'center', 39 | 40 | '& > span': { 41 | padding: '$0', 42 | borderRadius: '$0', 43 | display: 'flex', 44 | alignItems: 'center', 45 | justifyContent: 'center', 46 | }, 47 | 48 | variants: { 49 | variant: { 50 | tooltip: { 51 | '& > span': { 52 | color: '$tooltipContrast', 53 | background: '$overlayContrast', 54 | boxShadow: '$key', 55 | width: '20px', 56 | height: '20px', 57 | }, 58 | }, 59 | menu: {}, 60 | }, 61 | }, 62 | }) 63 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/Kbd/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Kbd' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/MenuContent/MenuContent.ts: -------------------------------------------------------------------------------- 1 | import { styled } from '~styles' 2 | 3 | export const MenuContent = styled('div', { 4 | position: 'relative', 5 | overflow: 'hidden', 6 | userSelect: 'none', 7 | display: 'flex', 8 | flexDirection: 'column', 9 | zIndex: 180, 10 | minWidth: 180, 11 | pointerEvents: 'all', 12 | backgroundColor: '$panel', 13 | boxShadow: '$panel', 14 | padding: '$2 $2', 15 | borderRadius: '$3', 16 | font: '$ui', 17 | variants: { 18 | size: { 19 | small: { 20 | minWidth: 72, 21 | }, 22 | }, 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/MenuContent/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MenuContent' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/Panel/Panel.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '~styles/stitches.config' 2 | 3 | export const Panel = styled('div', { 4 | backgroundColor: '$panel', 5 | display: 'flex', 6 | flexDirection: 'row', 7 | boxShadow: '$panel', 8 | padding: '$2', 9 | border: '1px solid $panelContrast', 10 | gap: 0, 11 | variants: { 12 | side: { 13 | center: { 14 | borderRadius: '$4', 15 | }, 16 | left: { 17 | padding: 0, 18 | borderTop: 0, 19 | borderLeft: 0, 20 | borderTopRightRadius: '$1', 21 | borderBottomRightRadius: '$3', 22 | borderBottomLeftRadius: '$1', 23 | }, 24 | right: { 25 | padding: 0, 26 | borderTop: 0, 27 | borderRight: 0, 28 | borderTopLeftRadius: '$1', 29 | borderBottomLeftRadius: '$3', 30 | borderBottomRightRadius: '$1', 31 | }, 32 | }, 33 | }, 34 | }) 35 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/Panel/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Panel' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/RowButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RowButton' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/SmallIcon/SmallIcon.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '~styles' 2 | 3 | export const SmallIcon = styled('div', { 4 | height: '100%', 5 | borderRadius: '4px', 6 | marginRight: '1px', 7 | width: 'fit-content', 8 | display: 'grid', 9 | alignItems: 'center', 10 | justifyContent: 'center', 11 | outline: 'none', 12 | border: 'none', 13 | pointerEvents: 'all', 14 | cursor: 'pointer', 15 | color: 'currentColor', 16 | 17 | '& svg': { 18 | height: 16, 19 | width: 16, 20 | strokeWidth: 1, 21 | }, 22 | 23 | '& > *': { 24 | gridRow: 1, 25 | gridColumn: 1, 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/SmallIcon/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SmallIcon' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/ToolButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ToolButton' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/Tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as RadixTooltip from '@radix-ui/react-tooltip' 2 | import * as React from 'react' 3 | import { Kbd } from '~components/Primitives/Kbd' 4 | import { styled } from '~styles' 5 | 6 | /* -------------------------------------------------- */ 7 | /* Tooltip */ 8 | /* -------------------------------------------------- */ 9 | 10 | interface TooltipProps { 11 | children: React.ReactNode 12 | label: string 13 | kbd?: string 14 | id?: string 15 | side?: 'bottom' | 'left' | 'right' | 'top' 16 | } 17 | 18 | export function Tooltip({ 19 | children, 20 | label, 21 | kbd: kbdProp, 22 | id, 23 | side = 'top', 24 | }: TooltipProps): JSX.Element { 25 | return ( 26 | 27 | 28 | 29 | {children} 30 | 31 | 32 | {label} 33 | {kbdProp ? {kbdProp} : null} 34 | 35 | 36 | 37 | 38 | ) 39 | } 40 | 41 | const StyledContent = styled(RadixTooltip.Content, { 42 | borderRadius: 3, 43 | padding: '$3 $3 $3 $3', 44 | fontSize: '$1', 45 | backgroundColor: '$tooltip', 46 | color: '$tooltipContrast', 47 | boxShadow: '$3', 48 | display: 'flex', 49 | alignItems: 'center', 50 | fontFamily: '$ui', 51 | userSelect: 'none', 52 | }) 53 | 54 | const StyledArrow = styled(RadixTooltip.Arrow, { 55 | fill: '$tooltip', 56 | margin: '0 8px', 57 | }) 58 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/Tooltip/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Tooltip' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/BoxIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function BoxIcon({ 4 | fill = 'none', 5 | stroke = 'currentColor', 6 | strokeWidth = 2, 7 | }: { 8 | fill?: string 9 | stroke?: string 10 | strokeWidth?: number 11 | }): JSX.Element { 12 | return ( 13 | 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/CircleIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function CircleIcon( 4 | props: Pick, 'strokeWidth' | 'stroke' | 'fill'> & { 5 | size: number 6 | } 7 | ) { 8 | const { size = 16, ...rest } = props 9 | return ( 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/DashDashedIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function DashDashedIcon(): JSX.Element { 4 | return ( 5 | 6 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/DashDottedIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | const dottedDasharray = `${50.26548 * 0.025} ${50.26548 * 0.1}` 4 | 5 | export function DashDottedIcon(): JSX.Element { 6 | return ( 7 | 8 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/DashDrawIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function DashDrawIcon(): JSX.Element { 4 | return ( 5 | 13 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/DashSolidIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function DashSolidIcon(): JSX.Element { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/DiscordIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function DiscordIcon() { 4 | return ( 5 | 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/EraserIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function EraserIcon(): JSX.Element { 4 | return ( 5 | 6 | 10 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/HeartIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function HeartIcon() { 4 | return ( 5 | 6 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/IsFilledIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function IsFilledIcon(): JSX.Element { 4 | return ( 5 | 6 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/LineIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function LineIcon() { 4 | return ( 5 | 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/MultiplayerIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function MultiplayerIcon(): JSX.Element { 4 | return ( 5 | 6 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/RedoIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function RedoIcon(props: React.SVGProps): JSX.Element { 4 | return ( 5 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/SizeLargeIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function SizeLargeIcon(props: React.SVGProps): JSX.Element { 4 | return ( 5 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/SizeMediumIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function SizeMediumIcon(props: React.SVGProps): JSX.Element { 4 | return ( 5 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/SizeSmallIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function SizeSmallIcon(props: React.SVGProps): JSX.Element { 4 | return ( 5 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/TrashIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function TrashIcon(props: React.SVGProps): JSX.Element { 4 | return ( 5 | 13 | 18 | 23 | 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/UndoIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function UndoIcon(props: React.SVGProps): JSX.Element { 4 | return ( 5 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/Primitives/icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BoxIcon' 2 | export * from './CircleIcon' 3 | export * from './DashDashedIcon' 4 | export * from './DashDottedIcon' 5 | export * from './DashDrawIcon' 6 | export * from './DashSolidIcon' 7 | export * from './IsFilledIcon' 8 | export * from './RedoIcon' 9 | export * from './TrashIcon' 10 | export * from './UndoIcon' 11 | export * from './SizeSmallIcon' 12 | export * from './SizeMediumIcon' 13 | export * from './SizeLargeIcon' 14 | export * from './EraserIcon' 15 | export * from './MultiplayerIcon' 16 | export * from './DiscordIcon' 17 | export * from './LineIcon' 18 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/ToolsPanel/BackToContent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { styled } from '~styles' 3 | import type { TDSnapshot } from '~types' 4 | import { useTldrawApp } from '~hooks' 5 | import { RowButton } from '~components/Primitives/RowButton' 6 | import { MenuContent } from '~components/Primitives/MenuContent' 7 | 8 | const isEmptyCanvasSelector = (s: TDSnapshot) => 9 | Object.keys(s.document.pages[s.appState.currentPageId].shapes).length > 0 && 10 | s.appState.isEmptyCanvas 11 | 12 | export const BackToContent = React.memo(function BackToContent() { 13 | const app = useTldrawApp() 14 | 15 | const isEmptyCanvas = app.useStore(isEmptyCanvasSelector) 16 | 17 | if (!isEmptyCanvas) return null 18 | 19 | return ( 20 | 21 | Back to content 22 | 23 | ) 24 | }) 25 | 26 | const BackToContentContainer = styled(MenuContent, { 27 | pointerEvents: 'all', 28 | width: 'fit-content', 29 | minWidth: 0, 30 | gridRow: 1, 31 | flexGrow: 2, 32 | display: 'block', 33 | }) 34 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/ToolsPanel/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Tooltip } from '~components/Primitives/Tooltip' 3 | import { useTldrawApp } from '~hooks' 4 | import { ToolButton } from '~components/Primitives/ToolButton' 5 | import { TrashIcon } from '~components/Primitives/icons' 6 | 7 | export function DeleteButton(): JSX.Element { 8 | const app = useTldrawApp() 9 | 10 | const handleDelete = React.useCallback(() => { 11 | app.delete() 12 | }, [app]) 13 | 14 | const hasSelection = app.useStore( 15 | (s) => 16 | s.appState.status === 'idle' && 17 | s.document.pageStates[s.appState.currentPageId].selectedIds.length > 0 18 | ) 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/ToolsPanel/LockButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { LockClosedIcon, LockOpen1Icon } from '@radix-ui/react-icons' 3 | import { Tooltip } from '~components/Primitives/Tooltip' 4 | import { useTldrawApp } from '~hooks' 5 | import { ToolButton } from '~components/Primitives/ToolButton' 6 | import type { TDSnapshot } from '~types' 7 | 8 | const isToolLockedSelector = (s: TDSnapshot) => s.appState.isToolLocked 9 | 10 | export function LockButton(): JSX.Element { 11 | const app = useTldrawApp() 12 | 13 | const isToolLocked = app.useStore(isToolLockedSelector) 14 | 15 | return ( 16 | 17 | 18 | {isToolLocked ? : } 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/ToolsPanel/StatusBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useTldrawApp } from '~hooks' 3 | import type { TDSnapshot } from '~types' 4 | import { styled } from '~styles' 5 | import { breakpoints } from '~components/breakpoints' 6 | 7 | const statusSelector = (s: TDSnapshot) => s.appState.status 8 | const activeToolSelector = (s: TDSnapshot) => s.appState.activeTool 9 | 10 | export function StatusBar(): JSX.Element | null { 11 | const app = useTldrawApp() 12 | const status = app.useStore(statusSelector) 13 | const activeTool = app.useStore(activeToolSelector) 14 | 15 | return ( 16 | 17 | 18 | {activeTool} | {status} 19 | 20 | 21 | ) 22 | } 23 | 24 | const StyledStatusBar = styled('div', { 25 | height: 40, 26 | userSelect: 'none', 27 | borderTop: '1px solid $panelContrast', 28 | gridArea: 'status', 29 | display: 'flex', 30 | color: '$text', 31 | justifyContent: 'space-between', 32 | alignItems: 'center', 33 | backgroundColor: '$panel', 34 | gap: 8, 35 | fontFamily: '$ui', 36 | fontSize: '$0', 37 | padding: '0 16px', 38 | 39 | variants: { 40 | bp: { 41 | small: { 42 | fontSize: '$1', 43 | }, 44 | }, 45 | }, 46 | }) 47 | 48 | const StyledSection = styled('div', { 49 | whiteSpace: 'nowrap', 50 | overflow: 'hidden', 51 | }) 52 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/ToolsPanel/ToolsPanel.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ToolsPanel } from './ToolsPanel' 3 | import { renderWithContext } from '~test' 4 | 5 | describe('tools panel', () => { 6 | test('mounts component without crashing', () => { 7 | renderWithContext( void null} />) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/ToolsPanel/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ToolsPanel' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/TopPanel/Menu/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimeshnayaju/tldrawe/c96676b75bebdf30aa2f9db01fa7f4af4e594099/packages/tldraw/src/components/TopPanel/Menu/index.ts -------------------------------------------------------------------------------- /packages/tldraw/src/components/TopPanel/MultiplayerMenu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MultiplayerMenu' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/TopPanel/PageMenu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './PageMenu' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/TopPanel/PageOptionsDialog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './PageOptionsDialog' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/TopPanel/PreferencesMenu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './PreferencesMenu' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/TopPanel/StyleMenu/StyleMenu.test.tsx: -------------------------------------------------------------------------------- 1 | describe('the style menu', () => { 2 | test.todo('Correctly sets the style properties when shapes are selected') 3 | test.todo('Correctly sets the style properties when nothing is selected') 4 | }) 5 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/TopPanel/StyleMenu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './StyleMenu' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/TopPanel/ZoomMenu/ZoomMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useTldrawApp } from '~hooks' 3 | import type { TDSnapshot } from '~types' 4 | import { styled } from '~styles' 5 | import * as DropdownMenu from '@radix-ui/react-dropdown-menu' 6 | import { DMItem, DMContent } from '~components/Primitives/DropdownMenu' 7 | import { ToolButton } from '~components/Primitives/ToolButton' 8 | import { preventEvent } from '~components/preventEvent' 9 | 10 | const zoomSelector = (s: TDSnapshot) => s.document.pageStates[s.appState.currentPageId].camera.zoom 11 | 12 | export const ZoomMenu = React.memo(function ZoomMenu() { 13 | const app = useTldrawApp() 14 | 15 | const zoom = app.useStore(zoomSelector) 16 | 17 | return ( 18 | 19 | 20 | 21 | {Math.round(zoom * 100)}% 22 | 23 | 24 | 25 | 26 | Zoom In 27 | 28 | 29 | Zoom Out 30 | 31 | 32 | To 100% 33 | 34 | 35 | To Fit 36 | 37 | 43 | To Selection 44 | 45 | 46 | 47 | ) 48 | }) 49 | 50 | const FixedWidthToolButton = styled(ToolButton, { 51 | minWidth: 56, 52 | }) 53 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/TopPanel/ZoomMenu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ZoomMenu' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/TopPanel/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TopPanel' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/breakpoints.tsx: -------------------------------------------------------------------------------- 1 | /* -------------------------------------------------- */ 2 | /* Breakpoints */ 3 | /* -------------------------------------------------- */ 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | export const breakpoints: any = { 7 | '@initial': 'mobile', 8 | '@micro': 'micro', 9 | '@sm': 'small', 10 | '@md': 'medium', 11 | '@lg': 'large', 12 | } 13 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/preventEvent.ts: -------------------------------------------------------------------------------- 1 | export const preventEvent = (e: Event) => e.preventDefault() 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/components/stopPropagation.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react' 2 | 3 | export const stopPropagation = (e: KeyboardEvent | React.SyntheticEvent) => 4 | e.stopPropagation() 5 | -------------------------------------------------------------------------------- /packages/tldraw/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useKeyboardShortcuts' 2 | export * from './useTldrawApp' 3 | export * from './useTheme' 4 | export * from './useStylesheet' 5 | export * from './useFileSystemHandlers' 6 | export * from './useFileSystem' 7 | -------------------------------------------------------------------------------- /packages/tldraw/src/hooks/useFileSystem.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type { TldrawApp } from '~state' 3 | 4 | export function useFileSystem() { 5 | const promptSaveBeforeChange = React.useCallback(async (app: TldrawApp) => { 6 | if (app.isDirty) { 7 | if (app.fileSystemHandle) { 8 | if (window.confirm('Do you want to save changes to your current project?')) { 9 | await app.saveProject() 10 | } 11 | } else { 12 | if (window.confirm('Do you want to save your current project?')) { 13 | await app.saveProject() 14 | } 15 | } 16 | } 17 | }, []) 18 | 19 | const onNewProject = React.useCallback( 20 | async (app: TldrawApp) => { 21 | if (window.confirm('Do you want to create a new project?')) { 22 | await promptSaveBeforeChange(app) 23 | app.newProject() 24 | } 25 | }, 26 | [promptSaveBeforeChange] 27 | ) 28 | 29 | const onSaveProject = React.useCallback((app: TldrawApp) => { 30 | app.saveProject() 31 | }, []) 32 | 33 | const onSaveProjectAs = React.useCallback((app: TldrawApp) => { 34 | app.saveProjectAs() 35 | }, []) 36 | 37 | const onOpenProject = React.useCallback( 38 | async (app: TldrawApp) => { 39 | await promptSaveBeforeChange(app) 40 | app.openProject() 41 | }, 42 | [promptSaveBeforeChange] 43 | ) 44 | 45 | const onOpenMedia = React.useCallback(async (app: TldrawApp) => { 46 | app.openAsset?.() 47 | }, []) 48 | 49 | return { 50 | onNewProject, 51 | onSaveProject, 52 | onSaveProjectAs, 53 | onOpenProject, 54 | onOpenMedia, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/tldraw/src/hooks/useFileSystemHandlers.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useTldrawApp } from '~hooks' 3 | 4 | export function useFileSystemHandlers() { 5 | const app = useTldrawApp() 6 | 7 | const onNewProject = React.useCallback( 8 | async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => { 9 | if (e && app.callbacks.onOpenProject) e.preventDefault() 10 | app.callbacks.onNewProject?.(app) 11 | }, 12 | [app] 13 | ) 14 | 15 | const onSaveProject = React.useCallback( 16 | (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => { 17 | if (e && app.callbacks.onOpenProject) e.preventDefault() 18 | app.callbacks.onSaveProject?.(app) 19 | }, 20 | [app] 21 | ) 22 | 23 | const onSaveProjectAs = React.useCallback( 24 | (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => { 25 | if (e && app.callbacks.onOpenProject) e.preventDefault() 26 | app.callbacks.onSaveProjectAs?.(app) 27 | }, 28 | [app] 29 | ) 30 | 31 | const onOpenProject = React.useCallback( 32 | async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => { 33 | if (e && app.callbacks.onOpenProject) e.preventDefault() 34 | app.callbacks.onOpenProject?.(app) 35 | }, 36 | [app] 37 | ) 38 | 39 | const onOpenMedia = React.useCallback( 40 | async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => { 41 | if (e && app.callbacks.onOpenMedia) e.preventDefault() 42 | app.callbacks.onOpenMedia?.(app) 43 | }, 44 | [app] 45 | ) 46 | 47 | return { 48 | onNewProject, 49 | onSaveProject, 50 | onSaveProjectAs, 51 | onOpenProject, 52 | onOpenMedia, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/tldraw/src/hooks/useStylesheet.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | const styles = new Map() 4 | 5 | const UID = `Tldraw-fonts` 6 | const CSS = ` 7 | @import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Crimson+Pro&display=block'); 8 | ` 9 | 10 | export function useStylesheet() { 11 | React.useLayoutEffect(() => { 12 | if (styles.get(UID)) return 13 | const style = document.createElement('style') 14 | style.innerHTML = CSS 15 | style.setAttribute('id', UID) 16 | document.head.appendChild(style) 17 | styles.set(UID, style) 18 | return () => { 19 | if (style && document.head.contains(style)) { 20 | document.head.removeChild(style) 21 | styles.delete(UID) 22 | } 23 | } 24 | }, [UID, CSS]) 25 | } 26 | -------------------------------------------------------------------------------- /packages/tldraw/src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import type { TDSnapshot, Theme } from '~types' 2 | import { useTldrawApp } from './useTldrawApp' 3 | 4 | const themeSelector = (data: TDSnapshot): Theme => (data.settings.isDarkMode ? 'dark' : 'light') 5 | 6 | export function useTheme() { 7 | const app = useTldrawApp() 8 | const theme = app.useStore(themeSelector) 9 | 10 | return { 11 | theme, 12 | toggle: app.toggleDarkMode, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/tldraw/src/hooks/useTldrawApp.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type { TldrawApp } from '~state' 3 | 4 | export const TldrawContext = React.createContext({} as TldrawApp) 5 | 6 | export function useTldrawApp() { 7 | const context = React.useContext(TldrawContext) 8 | return context 9 | } 10 | -------------------------------------------------------------------------------- /packages/tldraw/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Tldraw' 2 | export * from './types' 3 | export * from './state/shapes' 4 | export { TldrawApp } from './state' 5 | export { useFileSystem } from './hooks' 6 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/StateManager/copy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deep copy function for TypeScript. 3 | * @param T Generic type of target/copied value. 4 | * @param target Target value to be copied. 5 | * @see Source project, ts-deeply https://github.com/ykdr2017/ts-deepcopy 6 | * @see Code pen https://codepen.io/erikvullings/pen/ejyBYg 7 | */ 8 | export function deepCopy(target: T): T { 9 | if (target === null) { 10 | return target 11 | } 12 | if (target instanceof Date) { 13 | return new Date(target.getTime()) as any 14 | } 15 | 16 | // First part is for array and second part is for Realm.Collection 17 | // if (target instanceof Array || typeof (target as any).type === 'string') { 18 | if (typeof target === 'object') { 19 | if (typeof target[Symbol.iterator as keyof T] === 'function') { 20 | const cp = [] as any[] 21 | if ((target as any as any[]).length > 0) { 22 | for (const arrayMember of target as any as any[]) { 23 | cp.push(deepCopy(arrayMember)) 24 | } 25 | } 26 | return cp as any as T 27 | } else { 28 | const targetKeys = Object.keys(target) 29 | const cp = {} as T 30 | if (targetKeys.length > 0) { 31 | for (const key of targetKeys) { 32 | cp[key as keyof T] = deepCopy(target[key as keyof T]) 33 | } 34 | } 35 | return cp 36 | } 37 | } 38 | 39 | // Means that object is atomic 40 | return target 41 | } 42 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/StateManager/index.ts: -------------------------------------------------------------------------------- 1 | export * from './StateManager' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/alignShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './alignShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/changePage/changePage.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | 3 | describe('Change page command', () => { 4 | const app = new TldrawTestApp() 5 | 6 | it('does, undoes and redoes command', () => { 7 | app.loadDocument(mockDocument) 8 | 9 | const initialId = app.page.id 10 | 11 | app.createPage() 12 | 13 | const nextId = app.page.id 14 | 15 | app.changePage(initialId) 16 | 17 | expect(app.page.id).toBe(initialId) 18 | 19 | app.changePage(nextId) 20 | 21 | expect(app.page.id).toBe(nextId) 22 | 23 | app.undo() 24 | 25 | expect(app.page.id).toBe(initialId) 26 | 27 | app.redo() 28 | 29 | expect(app.page.id).toBe(nextId) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/changePage/changePage.ts: -------------------------------------------------------------------------------- 1 | import type { TldrawCommand } from '~types' 2 | import type { TldrawApp } from '../../internal' 3 | 4 | export function changePage(app: TldrawApp, pageId: string): TldrawCommand { 5 | return { 6 | id: 'change_page', 7 | before: { 8 | appState: { 9 | currentPageId: app.currentPageId, 10 | }, 11 | }, 12 | after: { 13 | appState: { 14 | currentPageId: pageId, 15 | }, 16 | }, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/changePage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './changePage' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/createPage/createPage.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | 3 | describe('Create page command', () => { 4 | const app = new TldrawTestApp() 5 | 6 | it('does, undoes and redoes command', () => { 7 | app.loadDocument(mockDocument) 8 | 9 | const initialId = app.page.id 10 | const initialPageState = app.pageState 11 | 12 | app.createPage() 13 | 14 | const nextId = app.page.id 15 | const nextPageState = app.pageState 16 | 17 | expect(Object.keys(app.document.pages).length).toBe(2) 18 | expect(app.page.id).toBe(nextId) 19 | expect(app.pageState).toEqual(nextPageState) 20 | 21 | app.undo() 22 | 23 | expect(Object.keys(app.document.pages).length).toBe(1) 24 | expect(app.page.id).toBe(initialId) 25 | expect(app.pageState).toEqual(initialPageState) 26 | 27 | app.redo() 28 | 29 | expect(Object.keys(app.document.pages).length).toBe(2) 30 | expect(app.page.id).toBe(nextId) 31 | expect(app.pageState).toEqual(nextPageState) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/createPage/createPage.ts: -------------------------------------------------------------------------------- 1 | import type { TldrawCommand, TDPage } from '~types' 2 | import { Utils, TLPageState } from '@tldraw/core' 3 | import type { TldrawApp } from '~state' 4 | 5 | export function createPage( 6 | app: TldrawApp, 7 | center: number[], 8 | pageId = Utils.uniqueId() 9 | ): TldrawCommand { 10 | const { currentPageId } = app 11 | 12 | const topPage = Object.values(app.state.document.pages).sort( 13 | (a, b) => (b.childIndex || 0) - (a.childIndex || 0) 14 | )[0] 15 | 16 | const nextChildIndex = topPage?.childIndex ? topPage?.childIndex + 1 : 1 17 | 18 | // TODO: Iterate the name better 19 | const nextName = `New Page` 20 | 21 | const page: TDPage = { 22 | id: pageId, 23 | name: nextName, 24 | childIndex: nextChildIndex, 25 | shapes: {}, 26 | bindings: {}, 27 | } 28 | 29 | const pageState: TLPageState = { 30 | id: pageId, 31 | selectedIds: [], 32 | camera: { point: center, zoom: 1 }, 33 | editingId: undefined, 34 | bindingId: undefined, 35 | hoveredId: undefined, 36 | pointedId: undefined, 37 | } 38 | 39 | return { 40 | id: 'create_page', 41 | before: { 42 | appState: { 43 | currentPageId, 44 | }, 45 | document: { 46 | pages: { 47 | [pageId]: undefined, 48 | }, 49 | pageStates: { 50 | [pageId]: undefined, 51 | }, 52 | }, 53 | }, 54 | after: { 55 | appState: { 56 | currentPageId: page.id, 57 | }, 58 | document: { 59 | pages: { 60 | [pageId]: page, 61 | }, 62 | pageStates: { 63 | [pageId]: pageState, 64 | }, 65 | }, 66 | }, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/createPage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createPage' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/createShapes/createShapes.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | 3 | describe('Create command', () => { 4 | const app = new TldrawTestApp() 5 | 6 | beforeEach(() => { 7 | app.loadDocument(mockDocument) 8 | }) 9 | 10 | describe('when no shape is provided', () => { 11 | it('does nothing', () => { 12 | const initialState = app.state 13 | app.create() 14 | 15 | const currentState = app.state 16 | 17 | expect(currentState).toEqual(initialState) 18 | }) 19 | }) 20 | 21 | it('does, undoes and redoes command', () => { 22 | const shape = { ...app.getShape('rect1'), id: 'rect4' } 23 | app.create([shape]) 24 | 25 | expect(app.getShape('rect4')).toBeTruthy() 26 | 27 | app.undo() 28 | 29 | expect(app.getShape('rect4')).toBe(undefined) 30 | 31 | app.redo() 32 | 33 | expect(app.getShape('rect4')).toBeTruthy() 34 | }) 35 | 36 | it.todo('Creates bindings') 37 | }) 38 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/createShapes/createShapes.ts: -------------------------------------------------------------------------------- 1 | import type { Patch, TDShape, TldrawCommand, TDBinding } from '~types' 2 | import type { TldrawApp } from '../../internal' 3 | 4 | export function createShapes( 5 | app: TldrawApp, 6 | shapes: TDShape[], 7 | bindings: TDBinding[] = [] 8 | ): TldrawCommand { 9 | const { currentPageId } = app 10 | 11 | const beforeShapes: Record | undefined> = {} 12 | const afterShapes: Record | undefined> = {} 13 | 14 | shapes.forEach((shape) => { 15 | beforeShapes[shape.id] = undefined 16 | afterShapes[shape.id] = shape 17 | }) 18 | 19 | const beforeBindings: Record | undefined> = {} 20 | const afterBindings: Record | undefined> = {} 21 | 22 | bindings.forEach((binding) => { 23 | beforeBindings[binding.id] = undefined 24 | afterBindings[binding.id] = binding 25 | }) 26 | 27 | return { 28 | id: 'create', 29 | before: { 30 | document: { 31 | pages: { 32 | [currentPageId]: { 33 | shapes: beforeShapes, 34 | bindings: beforeBindings, 35 | }, 36 | }, 37 | pageStates: { 38 | [currentPageId]: { 39 | selectedIds: [...app.selectedIds], 40 | }, 41 | }, 42 | }, 43 | }, 44 | after: { 45 | document: { 46 | pages: { 47 | [currentPageId]: { 48 | shapes: afterShapes, 49 | bindings: afterBindings, 50 | }, 51 | }, 52 | pageStates: { 53 | [currentPageId]: { 54 | selectedIds: shapes.map((shape) => shape.id), 55 | }, 56 | }, 57 | }, 58 | }, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/createShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/deletePage/deletePage.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | 3 | describe('Delete page', () => { 4 | const app = new TldrawTestApp() 5 | 6 | beforeEach(() => { 7 | app.loadDocument(mockDocument) 8 | }) 9 | 10 | describe('when there are no pages in the current document', () => { 11 | it('does nothing', () => { 12 | app.resetDocument() 13 | const initialState = app.state 14 | app.deletePage('page1') 15 | const currentState = app.state 16 | 17 | expect(currentState).toEqual(initialState) 18 | }) 19 | }) 20 | 21 | it('does, undoes and redoes command', () => { 22 | const initialId = app.currentPageId 23 | 24 | app.createPage() 25 | 26 | const nextId = app.currentPageId 27 | 28 | app.deletePage() 29 | 30 | expect(app.currentPageId).toBe(initialId) 31 | 32 | app.undo() 33 | 34 | expect(app.currentPageId).toBe(nextId) 35 | 36 | app.redo() 37 | 38 | expect(app.currentPageId).toBe(initialId) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/deletePage/deletePage.ts: -------------------------------------------------------------------------------- 1 | import type { TldrawCommand } from '~types' 2 | import type { TldrawApp } from '../../internal' 3 | 4 | export function deletePage(app: TldrawApp, pageId: string): TldrawCommand { 5 | const { 6 | currentPageId, 7 | document: { pages, pageStates }, 8 | } = app 9 | 10 | const pagesArr = Object.values(pages).sort((a, b) => (a.childIndex || 0) - (b.childIndex || 0)) 11 | 12 | const currentIndex = pagesArr.findIndex((page) => page.id === pageId) 13 | 14 | let nextCurrentPageId: string 15 | 16 | if (pageId === currentPageId) { 17 | if (currentIndex === pagesArr.length - 1) { 18 | nextCurrentPageId = pagesArr[pagesArr.length - 2].id 19 | } else { 20 | nextCurrentPageId = pagesArr[currentIndex + 1].id 21 | } 22 | } else { 23 | nextCurrentPageId = currentPageId 24 | } 25 | 26 | return { 27 | id: 'delete_page', 28 | before: { 29 | appState: { 30 | currentPageId: pageId, 31 | }, 32 | document: { 33 | pages: { 34 | [pageId]: { ...pages[pageId] }, 35 | }, 36 | pageStates: { 37 | [pageId]: { ...pageStates[pageId] }, 38 | }, 39 | }, 40 | }, 41 | after: { 42 | appState: { 43 | currentPageId: nextCurrentPageId, 44 | }, 45 | document: { 46 | pages: { 47 | [pageId]: undefined, 48 | }, 49 | pageStates: { 50 | [pageId]: undefined, 51 | }, 52 | }, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/deletePage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './deletePage' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/deleteShapes/deleteShapes.ts: -------------------------------------------------------------------------------- 1 | import type { TDAsset, TDAssets, TldrawCommand } from '~types' 2 | import type { TldrawApp } from '../../internal' 3 | import { removeShapesFromPage } from '../shared/removeShapesFromPage' 4 | 5 | const removeAssetsFromDocument = (assets: TDAssets, idsToRemove: string[]) => { 6 | const afterAssets: Record = { ...assets } 7 | idsToRemove.forEach((id) => (afterAssets[id] = undefined)) 8 | return afterAssets 9 | } 10 | 11 | export function deleteShapes( 12 | app: TldrawApp, 13 | ids: string[], 14 | pageId = app.currentPageId 15 | ): TldrawCommand { 16 | const { 17 | pageState, 18 | selectedIds, 19 | document: { assets: beforeAssets }, 20 | } = app 21 | const { before, after, assetsToRemove } = removeShapesFromPage(app.state, ids, pageId) 22 | const afterAssets = removeAssetsFromDocument(beforeAssets, assetsToRemove) 23 | 24 | return { 25 | id: 'delete', 26 | before: { 27 | document: { 28 | assets: beforeAssets, 29 | pages: { 30 | [pageId]: before, 31 | }, 32 | pageStates: { 33 | [pageId]: { selectedIds: [...app.selectedIds] }, 34 | }, 35 | }, 36 | }, 37 | after: { 38 | document: { 39 | assets: afterAssets, 40 | pages: { 41 | [pageId]: after, 42 | }, 43 | pageStates: { 44 | [pageId]: { 45 | selectedIds: selectedIds.filter((id) => !ids.includes(id)), 46 | hoveredId: 47 | pageState.hoveredId && ids.includes(pageState.hoveredId) 48 | ? undefined 49 | : pageState.hoveredId, 50 | }, 51 | }, 52 | }, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/deleteShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './deleteShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/distributeShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './distributeShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/duplicatePage/duplicatePage.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | 3 | describe('Duplicate page command', () => { 4 | const app = new TldrawTestApp() 5 | 6 | it('does, undoes and redoes command', () => { 7 | app.loadDocument(mockDocument) 8 | 9 | const initialId = app.page.id 10 | 11 | app.duplicatePage(app.currentPageId) 12 | 13 | const nextId = app.page.id 14 | 15 | app.undo() 16 | 17 | expect(app.page.id).toBe(initialId) 18 | 19 | app.redo() 20 | 21 | expect(app.page.id).toBe(nextId) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/duplicatePage/duplicatePage.ts: -------------------------------------------------------------------------------- 1 | import type { TldrawCommand } from '~types' 2 | import { Utils } from '@tldraw/core' 3 | import type { TldrawApp } from '../../internal' 4 | 5 | export function duplicatePage(app: TldrawApp, pageId: string): TldrawCommand { 6 | const newId = Utils.uniqueId() 7 | const { 8 | currentPageId, 9 | page, 10 | pageState: { camera }, 11 | } = app 12 | 13 | const nextPage = { 14 | ...page, 15 | id: newId, 16 | name: page.name + ' Copy', 17 | shapes: Object.fromEntries( 18 | Object.entries(page.shapes).map(([id, shape]) => { 19 | return [ 20 | id, 21 | { 22 | ...shape, 23 | parentId: shape.parentId === pageId ? newId : shape.parentId, 24 | }, 25 | ] 26 | }) 27 | ), 28 | } 29 | 30 | return { 31 | id: 'duplicate_page', 32 | before: { 33 | appState: { 34 | currentPageId, 35 | }, 36 | document: { 37 | pages: { 38 | [newId]: undefined, 39 | }, 40 | pageStates: { 41 | [newId]: undefined, 42 | }, 43 | }, 44 | }, 45 | after: { 46 | appState: { 47 | currentPageId: newId, 48 | }, 49 | document: { 50 | pages: { 51 | [newId]: nextPage, 52 | }, 53 | pageStates: { 54 | [newId]: { 55 | ...page, 56 | id: newId, 57 | selectedIds: [], 58 | camera: { ...camera }, 59 | editingId: undefined, 60 | bindingId: undefined, 61 | hoveredId: undefined, 62 | pointedId: undefined, 63 | }, 64 | }, 65 | }, 66 | }, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/duplicatePage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './duplicatePage' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/duplicateShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './duplicateShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/flipShapes/flipShapes.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | import type { RectangleShape } from '~types' 3 | 4 | describe('Flip command', () => { 5 | const app = new TldrawTestApp() 6 | 7 | beforeEach(() => { 8 | app.loadDocument(mockDocument) 9 | }) 10 | 11 | describe('when no shape is selected', () => { 12 | it('does nothing', () => { 13 | const initialState = app.state 14 | app.flipHorizontal() 15 | const currentState = app.state 16 | 17 | expect(currentState).toEqual(initialState) 18 | }) 19 | }) 20 | 21 | it('does, undoes and redoes command', () => { 22 | app.select('rect1', 'rect2') 23 | app.flipHorizontal() 24 | 25 | expect(app.getShape('rect1').point).toStrictEqual([100, 0]) 26 | 27 | app.undo() 28 | 29 | expect(app.getShape('rect1').point).toStrictEqual([0, 0]) 30 | 31 | app.redo() 32 | 33 | expect(app.getShape('rect1').point).toStrictEqual([100, 0]) 34 | }) 35 | 36 | it('flips horizontally', () => { 37 | app.select('rect1', 'rect2') 38 | app.flipHorizontal() 39 | 40 | expect(app.getShape('rect1').point).toStrictEqual([100, 0]) 41 | }) 42 | 43 | it('flips vertically', () => { 44 | app.select('rect1', 'rect2') 45 | app.flipVertical() 46 | 47 | expect(app.getShape('rect1').point).toStrictEqual([0, 100]) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/flipShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './flipShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/groupShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './groupShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/index.ts: -------------------------------------------------------------------------------- 1 | export * from './alignShapes' 2 | export * from './changePage' 3 | export * from './createPage' 4 | export * from './createShapes' 5 | export * from './deletePage' 6 | export * from './deleteShapes' 7 | export * from './distributeShapes' 8 | export * from './duplicatePage' 9 | export * from './duplicateShapes' 10 | export * from './flipShapes' 11 | export * from './groupShapes' 12 | export * from './moveShapesToPage' 13 | export * from './reorderShapes' 14 | export * from './renamePage' 15 | export * from './resetBounds' 16 | export * from './rotateShapes' 17 | export * from './stretchShapes' 18 | export * from './styleShapes' 19 | export * from './toggleShapesDecoration' 20 | export * from './toggleShapesProp' 21 | export * from './translateShapes' 22 | export * from './ungroupShapes' 23 | export * from './updateShapes' 24 | export * from './setShapesProps' 25 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/moveShapesToPage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './moveShapesToPage' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/renamePage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './renamePage' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/renamePage/renamePage.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | 3 | describe('Rename page command', () => { 4 | const app = new TldrawTestApp() 5 | 6 | it('does, undoes and redoes command', () => { 7 | app.loadDocument(mockDocument) 8 | 9 | const initialId = app.page.id 10 | const initialName = app.page.name 11 | 12 | app.renamePage(initialId, 'My Special Page') 13 | 14 | expect(app.page.name).toBe('My Special Page') 15 | 16 | app.undo() 17 | 18 | expect(app.page.name).toBe(initialName) 19 | 20 | app.redo() 21 | 22 | expect(app.page.name).toBe('My Special Page') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/renamePage/renamePage.ts: -------------------------------------------------------------------------------- 1 | import type { TldrawCommand } from '~types' 2 | import type { TldrawApp } from '../../internal' 3 | 4 | export function renamePage(app: TldrawApp, pageId: string, name: string): TldrawCommand { 5 | const { page } = app 6 | 7 | return { 8 | id: 'rename_page', 9 | before: { 10 | document: { 11 | pages: { 12 | [pageId]: { name: page.name }, 13 | }, 14 | }, 15 | }, 16 | after: { 17 | document: { 18 | pages: { 19 | [pageId]: { name: name }, 20 | }, 21 | }, 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/reorderShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reorderShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/resetBounds/index.ts: -------------------------------------------------------------------------------- 1 | export * from './resetBounds' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/resetBounds/resetBounds.spec.ts: -------------------------------------------------------------------------------- 1 | import { TLBoundsCorner, Utils } from '@tldraw/core' 2 | import { TLDR } from '~state/TLDR' 3 | import { mockDocument, TldrawTestApp } from '~test' 4 | import { SessionType, TDShapeType } from '~types' 5 | 6 | describe('Reset bounds command', () => { 7 | const app = new TldrawTestApp() 8 | 9 | beforeEach(() => { 10 | app.loadDocument(mockDocument) 11 | }) 12 | 13 | it('does, undoes and redoes command', () => { 14 | app.createShapes({ 15 | id: 'text1', 16 | type: TDShapeType.Text, 17 | point: [0, 0], 18 | text: 'Hello World', 19 | }) 20 | 21 | // Scale is undefined by default 22 | expect(app.getShape('text1').style.scale).toBe(1) 23 | 24 | // Transform the shape in order to change its point and scale 25 | 26 | app 27 | .select('text1') 28 | .movePointer([0, 0]) 29 | .startSession(SessionType.Transform, TLBoundsCorner.TopLeft) 30 | .movePointer({ x: -100, y: -100, shiftKey: false, altKey: false }) 31 | .completeSession() 32 | 33 | const scale = app.getShape('text1').style.scale 34 | const bounds = TLDR.getBounds(app.getShape('text1')) 35 | const center = Utils.getBoundsCenter(bounds) 36 | 37 | expect(scale).not.toBe(1) 38 | expect(Number.isNaN(scale)).toBe(false) 39 | 40 | // Reset the bounds 41 | 42 | app.resetBounds(['text1']) 43 | 44 | // The scale should be back to 1 45 | expect(app.getShape('text1').style.scale).toBe(1) 46 | // The centers should be the same 47 | expect(Utils.getBoundsCenter(TLDR.getBounds(app.getShape('text1')))).toStrictEqual(center) 48 | 49 | app.undo() 50 | 51 | // The scale should be what it was before 52 | expect(app.getShape('text1').style.scale).not.toBe(1) 53 | // The centers should be the same 54 | expect(Utils.getBoundsCenter(TLDR.getBounds(app.getShape('text1')))).toStrictEqual(center) 55 | 56 | app.redo() 57 | 58 | // The scale should be back to 1 59 | expect(app.getShape('text1').style.scale).toBe(1) 60 | // The centers should be the same 61 | expect(Utils.getBoundsCenter(TLDR.getBounds(app.getShape('text1')))).toStrictEqual(center) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/resetBounds/resetBounds.ts: -------------------------------------------------------------------------------- 1 | import type { TldrawCommand } from '~types' 2 | import { TLDR } from '~state/TLDR' 3 | import type { TldrawApp } from '../../internal' 4 | 5 | export function resetBounds(app: TldrawApp, ids: string[], pageId: string): TldrawCommand { 6 | const { currentPageId } = app 7 | 8 | const { before, after } = TLDR.mutateShapes( 9 | app.state, 10 | ids, 11 | (shape) => app.getShapeUtil(shape).onDoubleClickBoundsHandle?.(shape), 12 | pageId 13 | ) 14 | 15 | return { 16 | id: 'reset_bounds', 17 | before: { 18 | document: { 19 | pages: { 20 | [currentPageId]: { shapes: before }, 21 | }, 22 | pageStates: { 23 | [currentPageId]: { 24 | selectedIds: ids, 25 | }, 26 | }, 27 | }, 28 | }, 29 | after: { 30 | document: { 31 | pages: { 32 | [currentPageId]: { shapes: after }, 33 | }, 34 | pageStates: { 35 | [currentPageId]: { 36 | selectedIds: ids, 37 | }, 38 | }, 39 | }, 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/rotateShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './rotateShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/rotateShapes/rotateShapes.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | 3 | describe('Rotate command', () => { 4 | const app = new TldrawTestApp() 5 | 6 | beforeEach(() => { 7 | app.loadDocument(mockDocument) 8 | }) 9 | 10 | describe('when no shape is selected', () => { 11 | it('does nothing', () => { 12 | const initialState = app.state 13 | app.rotate() 14 | const currentState = app.state 15 | 16 | expect(currentState).toEqual(initialState) 17 | }) 18 | }) 19 | 20 | it('does, undoes and redoes command', () => { 21 | app.select('rect1') 22 | 23 | expect(app.getShape('rect1').rotation).toBe(undefined) 24 | 25 | app.rotate() 26 | 27 | expect(app.getShape('rect1').rotation).toBe(Math.PI * (6 / 4)) 28 | 29 | app.undo() 30 | 31 | expect(app.getShape('rect1').rotation).toBe(undefined) 32 | 33 | app.redo() 34 | 35 | expect(app.getShape('rect1').rotation).toBe(Math.PI * (6 / 4)) 36 | }) 37 | 38 | it.todo('Rotates several shapes at once.') 39 | 40 | it.todo('Rotates shapes with handles.') 41 | }) 42 | 43 | describe('when running the command', () => { 44 | it('restores selection on undo', () => { 45 | const app = new TldrawTestApp() 46 | .loadDocument(mockDocument) 47 | .select('rect1') 48 | .rotate() 49 | .selectNone() 50 | .undo() 51 | 52 | expect(app.selectedIds).toEqual(['rect1']) 53 | 54 | app.selectNone().redo() 55 | 56 | expect(app.selectedIds).toEqual(['rect1']) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/rotateShapes/rotateShapes.ts: -------------------------------------------------------------------------------- 1 | import { Utils } from '@tldraw/core' 2 | import type { TldrawCommand, TDShape } from '~types' 3 | import { TLDR } from '~state/TLDR' 4 | import type { TldrawApp } from '../../internal' 5 | 6 | const PI2 = Math.PI * 2 7 | 8 | export function rotateShapes( 9 | app: TldrawApp, 10 | ids: string[], 11 | delta = -PI2 / 4 12 | ): TldrawCommand | void { 13 | const { currentPageId } = app 14 | 15 | // The shapes for the before patch 16 | const before: Record> = {} 17 | 18 | // The shapes for the after patch 19 | const after: Record> = {} 20 | 21 | // Find the shapes that we want to rotate. 22 | // We don't rotate groups: we rotate their children instead. 23 | const shapesToRotate = ids 24 | .flatMap((id) => { 25 | const shape = app.getShape(id) 26 | return shape.children ? shape.children.map((childId) => app.getShape(childId)) : shape 27 | }) 28 | .filter((shape) => !shape.isLocked) 29 | 30 | // Find the common center to all shapes 31 | // This is the point that we'll rotate around 32 | const origin = Utils.getBoundsCenter( 33 | Utils.getCommonBounds(shapesToRotate.map((shape) => TLDR.getBounds(shape))) 34 | ) 35 | 36 | // Find the rotate mutations for each shape 37 | shapesToRotate.forEach((shape) => { 38 | const change = TLDR.getRotatedShapeMutation(shape, TLDR.getCenter(shape), origin, delta) 39 | if (!change) return 40 | before[shape.id] = TLDR.getBeforeShape(shape, change) 41 | after[shape.id] = change 42 | }) 43 | 44 | return { 45 | id: 'rotate', 46 | before: { 47 | document: { 48 | pages: { 49 | [currentPageId]: { shapes: before }, 50 | }, 51 | pageStates: { 52 | [currentPageId]: { 53 | selectedIds: ids, 54 | }, 55 | }, 56 | }, 57 | }, 58 | after: { 59 | document: { 60 | pages: { 61 | [currentPageId]: { shapes: after }, 62 | }, 63 | pageStates: { 64 | [currentPageId]: { 65 | selectedIds: ids, 66 | }, 67 | }, 68 | }, 69 | }, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/setShapesProps/index.ts: -------------------------------------------------------------------------------- 1 | export * from './setShapesProps' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/setShapesProps/setShapesProps.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Set shapes props command', () => { 2 | it.todo('sets the props of the provided shapes') 3 | }) 4 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/setShapesProps/setShapesProps.ts: -------------------------------------------------------------------------------- 1 | import type { TDShape, TldrawCommand } from '~types' 2 | import type { TldrawApp } from '~state' 3 | 4 | export function setShapesProps( 5 | app: TldrawApp, 6 | ids: string[], 7 | partial: Partial 8 | ): TldrawCommand { 9 | const { currentPageId, selectedIds } = app 10 | 11 | const initialShapes = ids 12 | .map((id) => app.getShape(id)) 13 | .filter((shape) => (partial['isLocked'] ? true : !shape.isLocked)) 14 | 15 | const before: Record> = {} 16 | const after: Record> = {} 17 | 18 | const keys = Object.keys(partial) as (keyof T)[] 19 | 20 | initialShapes.forEach((shape) => { 21 | before[shape.id] = Object.fromEntries(keys.map((key) => [key, shape[key]])) 22 | after[shape.id] = partial 23 | }) 24 | 25 | return { 26 | id: 'set_props', 27 | before: { 28 | document: { 29 | pages: { 30 | [currentPageId]: { 31 | shapes: before, 32 | }, 33 | }, 34 | pageStates: { 35 | [currentPageId]: { 36 | selectedIds, 37 | }, 38 | }, 39 | }, 40 | }, 41 | after: { 42 | document: { 43 | pages: { 44 | [currentPageId]: { 45 | shapes: after, 46 | }, 47 | }, 48 | pageStates: { 49 | [currentPageId]: { 50 | selectedIds, 51 | }, 52 | }, 53 | }, 54 | }, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/stretchShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './stretchShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/styleShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './styleShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/toggleShapesDecoration/index.ts: -------------------------------------------------------------------------------- 1 | export * from './toggleShapesDecoration' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/toggleShapesDecoration/toggleShapesDecoration.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | import { ArrowShape, Decoration, TDShapeType } from '~types' 3 | 4 | describe('Toggle decoration command', () => { 5 | describe('when no shape is selected', () => { 6 | it('does nothing', () => { 7 | const app = new TldrawTestApp() 8 | const initialState = app.state 9 | app.toggleDecoration('start') 10 | const currentState = app.state 11 | 12 | expect(currentState).toEqual(initialState) 13 | }) 14 | }) 15 | 16 | describe('when handle id is invalid', () => { 17 | it('does nothing', () => { 18 | const app = new TldrawTestApp() 19 | const initialState = app.state 20 | app.toggleDecoration('invalid') 21 | const currentState = app.state 22 | 23 | expect(currentState).toEqual(initialState) 24 | }) 25 | }) 26 | 27 | it('does, undoes and redoes command', () => { 28 | const app = new TldrawTestApp() 29 | .createShapes({ 30 | id: 'arrow1', 31 | type: TDShapeType.Arrow, 32 | }) 33 | .select('arrow1') 34 | 35 | expect(app.getShape('arrow1').decorations?.end).toBe(Decoration.Arrow) 36 | 37 | app.toggleDecoration('end') 38 | 39 | expect(app.getShape('arrow1').decorations?.end).toBe(undefined) 40 | 41 | app.undo() 42 | 43 | expect(app.getShape('arrow1').decorations?.end).toBe(Decoration.Arrow) 44 | 45 | app.redo() 46 | 47 | expect(app.getShape('arrow1').decorations?.end).toBe(undefined) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/toggleShapesDecoration/toggleShapesDecoration.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from '~types' 2 | import type { Patch, ArrowShape, TldrawCommand } from '~types' 3 | import type { TldrawApp } from '../../internal' 4 | 5 | export function toggleShapesDecoration( 6 | app: TldrawApp, 7 | ids: string[], 8 | decorationId: 'start' | 'end' 9 | ): TldrawCommand { 10 | const { currentPageId, selectedIds } = app 11 | 12 | const beforeShapes: Record> = Object.fromEntries( 13 | ids.map((id) => [ 14 | id, 15 | { 16 | decorations: { 17 | [decorationId]: app.getShape(id).decorations?.[decorationId], 18 | }, 19 | }, 20 | ]) 21 | ) 22 | 23 | const afterShapes: Record> = Object.fromEntries( 24 | ids 25 | .filter((id) => !app.getShape(id).isLocked) 26 | .map((id) => [ 27 | id, 28 | { 29 | decorations: { 30 | [decorationId]: app.getShape(id).decorations?.[decorationId] 31 | ? undefined 32 | : Decoration.Arrow, 33 | }, 34 | }, 35 | ]) 36 | ) 37 | 38 | return { 39 | id: 'toggle_decorations', 40 | before: { 41 | document: { 42 | pages: { 43 | [currentPageId]: { shapes: beforeShapes }, 44 | }, 45 | pageStates: { 46 | [currentPageId]: { 47 | selectedIds, 48 | }, 49 | }, 50 | }, 51 | }, 52 | after: { 53 | document: { 54 | pages: { 55 | [currentPageId]: { shapes: afterShapes }, 56 | }, 57 | pageStates: { 58 | [currentPageId]: { 59 | selectedIds: ids, 60 | }, 61 | }, 62 | }, 63 | }, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/toggleShapesProp/index.ts: -------------------------------------------------------------------------------- 1 | export * from './toggleShapesProp' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/toggleShapesProp/toggleShapesProp.ts: -------------------------------------------------------------------------------- 1 | import type { TDShape, TldrawCommand } from '~types' 2 | import type { TldrawApp } from '~state' 3 | 4 | export function toggleShapeProp(app: TldrawApp, ids: string[], prop: keyof TDShape): TldrawCommand { 5 | const { currentPageId } = app 6 | 7 | const initialShapes = ids 8 | .map((id) => app.getShape(id)) 9 | .filter((shape) => (prop === 'isLocked' ? true : !shape.isLocked)) 10 | 11 | const isAllToggled = initialShapes.every((shape) => shape[prop]) 12 | 13 | const before: Record> = {} 14 | const after: Record> = {} 15 | 16 | initialShapes.forEach((shape) => { 17 | before[shape.id] = { [prop]: shape[prop] } 18 | after[shape.id] = { [prop]: !isAllToggled } 19 | }) 20 | 21 | return { 22 | id: 'toggle', 23 | before: { 24 | document: { 25 | pages: { 26 | [currentPageId]: { 27 | shapes: before, 28 | }, 29 | }, 30 | pageStates: { 31 | [currentPageId]: { 32 | selectedIds: ids, 33 | }, 34 | }, 35 | }, 36 | }, 37 | after: { 38 | document: { 39 | pages: { 40 | [currentPageId]: { 41 | shapes: after, 42 | }, 43 | }, 44 | pageStates: { 45 | [currentPageId]: { 46 | selectedIds: ids, 47 | }, 48 | }, 49 | }, 50 | }, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/translateShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translateShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/ungroupShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ungroupShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/updateShapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './updateShapes' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/updateShapes/updateShapes.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | 3 | describe('Update command', () => { 4 | const app = new TldrawTestApp() 5 | 6 | beforeEach(() => { 7 | app.loadDocument(mockDocument) 8 | }) 9 | 10 | describe('when no shape is selected', () => { 11 | it('does nothing', () => { 12 | const initialState = app.state 13 | app.updateShapes() 14 | const currentState = app.state 15 | 16 | expect(currentState).toEqual(initialState) 17 | }) 18 | }) 19 | 20 | it('does, undoes and redoes command', () => { 21 | app.updateShapes({ id: 'rect1', point: [100, 100] }) 22 | 23 | expect(app.getShape('rect1').point).toStrictEqual([100, 100]) 24 | 25 | app.undo() 26 | 27 | expect(app.getShape('rect1').point).toStrictEqual([0, 0]) 28 | 29 | app.redo() 30 | 31 | expect(app.getShape('rect1').point).toStrictEqual([100, 100]) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/commands/updateShapes/updateShapes.ts: -------------------------------------------------------------------------------- 1 | import type { TldrawCommand, TDShape } from '~types' 2 | import { TLDR } from '~state/TLDR' 3 | import type { TldrawApp } from '../../internal' 4 | 5 | export function updateShapes( 6 | app: TldrawApp, 7 | updates: ({ id: string } & Partial)[], 8 | pageId: string 9 | ): TldrawCommand { 10 | const ids = updates.map((update) => update.id) 11 | 12 | const change = TLDR.mutateShapes( 13 | app.state, 14 | ids.filter((id) => !app.getShape(id, pageId).isLocked), 15 | (_shape, i) => updates[i], 16 | pageId 17 | ) 18 | 19 | return { 20 | id: 'update', 21 | before: { 22 | document: { 23 | pages: { 24 | [pageId]: { 25 | shapes: change.before, 26 | }, 27 | }, 28 | }, 29 | }, 30 | after: { 31 | document: { 32 | pages: { 33 | [pageId]: { 34 | shapes: change.after, 35 | }, 36 | }, 37 | }, 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/browser-fs-access/directory-open.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0. 17 | 18 | import supported from './supported.js' 19 | 20 | const implementation = !supported 21 | ? import('./legacy/directory-open.js') 22 | : import('./fs-access/directory-open.js') 23 | 24 | /** 25 | * For opening directories, dynamically either loads the File System Access API 26 | * module or the legacy method. 27 | */ 28 | export async function directoryOpen(...args) { 29 | return (await implementation).default(...args) 30 | } 31 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/browser-fs-access/file-open.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0. 17 | 18 | import supported from './supported.js' 19 | 20 | const implementation = !supported 21 | ? import('./legacy/file-open.js') 22 | : import('./fs-access/file-open.js') 23 | 24 | /** 25 | * For opening files, dynamically either loads the File System Access API module 26 | * or the legacy method. 27 | */ 28 | export async function fileOpen(...args) { 29 | return (await implementation).default(...args) 30 | } 31 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/browser-fs-access/file-save.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0. 17 | 18 | import supported from './supported.js' 19 | 20 | const implementation = !supported 21 | ? import('./legacy/file-save.js') 22 | : import('./fs-access/file-save.js') 23 | 24 | /** 25 | * For saving files, dynamically either loads the File System Access API module 26 | * or the legacy method. 27 | */ 28 | export async function fileSave(...args) { 29 | return (await implementation).default(...args) 30 | } 31 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/browser-fs-access/fs-access/directory-open.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0. 17 | 18 | const getFiles = async (dirHandle, recursive, path = dirHandle.name, skipDirectory) => { 19 | const dirs = [] 20 | const files = [] 21 | for (const entry of dirHandle.values()) { 22 | const nestedPath = `${path}/${entry.name}` 23 | if (entry.kind === 'file') { 24 | files.push( 25 | await entry.getFile().then((file) => { 26 | file.directoryHandle = dirHandle 27 | return Object.defineProperty(file, 'webkitRelativePath', { 28 | configurable: true, 29 | enumerable: true, 30 | get: () => nestedPath, 31 | }) 32 | }) 33 | ) 34 | } else if ( 35 | entry.kind === 'directory' && 36 | recursive && 37 | (!skipDirectory || !skipDirectory(entry)) 38 | ) { 39 | dirs.push(await getFiles(entry, recursive, nestedPath, skipDirectory)) 40 | } 41 | } 42 | return [...(await Promise.all(dirs)).flat(), ...(await Promise.all(files))] 43 | } 44 | 45 | /** 46 | * Opens a directory from disk using the File System Access API. 47 | * @type { typeof import("../../index").directoryOpen } 48 | */ 49 | export default async (options = {}) => { 50 | options.recursive = options.recursive || false 51 | const handle = await window.showDirectoryPicker({ 52 | id: options.id, 53 | startIn: options.startIn, 54 | }) 55 | return getFiles(handle, options.recursive, undefined, options.skipDirectory) 56 | } 57 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/browser-fs-access/fs-access/file-open.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0. 17 | 18 | const getFileWithHandle = async (handle) => { 19 | const file = await handle.getFile(); 20 | file.handle = handle; 21 | return file; 22 | }; 23 | 24 | /** 25 | * Opens a file from disk using the File System Access API. 26 | * @type { typeof import("../../index").fileOpen } 27 | */ 28 | export default async (options = [{}]) => { 29 | if (!Array.isArray(options)) { 30 | options = [options]; 31 | } 32 | const types = []; 33 | options.forEach((option, i) => { 34 | types[i] = { 35 | description: option.description || '', 36 | accept: {}, 37 | }; 38 | if (option.mimeTypes) { 39 | option.mimeTypes.map((mimeType) => { 40 | types[i].accept[mimeType] = option.extensions || []; 41 | }); 42 | } else { 43 | types[i].accept['*/*'] = option.extensions || []; 44 | } 45 | }); 46 | const handleOrHandles = await window.showOpenFilePicker({ 47 | id: options[0].id, 48 | startIn: options[0].startIn, 49 | types, 50 | multiple: options[0].multiple || false, 51 | excludeAcceptAllOption: options[0].excludeAcceptAllOption || false, 52 | }); 53 | const files = await Promise.all(handleOrHandles.map(getFileWithHandle)); 54 | if (options[0].multiple) { 55 | return files; 56 | } 57 | return files[0]; 58 | }; 59 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/browser-fs-access/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0. 17 | 18 | /** 19 | * @module browser-fs-access 20 | */ 21 | export { fileOpen } from './file-open.js' 22 | export { directoryOpen } from './directory-open.js' 23 | export { fileSave } from './file-save.js' 24 | export { default as supported } from './supported.js' 25 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/browser-fs-access/legacy/file-open.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0. 17 | 18 | /** 19 | * Opens a file from disk using the legacy `` method. 20 | * @type { typeof import("../../index").fileOpen } 21 | */ 22 | export default async (options = [{}]) => { 23 | if (!Array.isArray(options)) { 24 | options = [options]; 25 | } 26 | return new Promise((resolve, reject) => { 27 | const input = document.createElement('input'); 28 | input.type = 'file'; 29 | const accept = [ 30 | ...options.map((option) => option.mimeTypes || []).join(), 31 | options.map((option) => option.extensions || []).join(), 32 | ].join(); 33 | input.multiple = options[0].multiple || false; 34 | // Empty string allows everything. 35 | input.accept = accept || ''; 36 | const _reject = () => cleanupListenersAndMaybeReject(reject); 37 | const _resolve = (value) => { 38 | if (typeof cleanupListenersAndMaybeReject === 'function') { 39 | cleanupListenersAndMaybeReject(); 40 | } 41 | resolve(value); 42 | }; 43 | // ToDo: Remove this workaround once 44 | // https://github.com/whatwg/html/issues/6376 is specified and supported. 45 | const cleanupListenersAndMaybeReject = 46 | options[0].legacySetup && 47 | options[0].legacySetup(_resolve, _reject, input); 48 | input.addEventListener('change', () => { 49 | _resolve(input.multiple ? Array.from(input.files) : input.files[0]); 50 | }); 51 | 52 | input.click(); 53 | }); 54 | }; 55 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/browser-fs-access/supported.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0. 17 | 18 | /** 19 | * Returns whether the File System Access API is supported and usable in the 20 | * current context (for example cross-origin iframes). 21 | * @returns {boolean} Returns `true` if the File System Access API is supported and usable, else returns `false`. 22 | */ 23 | const supported = (() => { 24 | // When running in an SSR environment return `false`. 25 | if (typeof self === 'undefined') { 26 | return false 27 | } 28 | // ToDo: Remove this check once Permissions Policy integration 29 | // has happened, tracked in 30 | // https://github.com/WICG/file-system-access/issues/245. 31 | if ('top' in self && self !== top) { 32 | try { 33 | // This will succeed on same-origin iframes, 34 | // but fail on cross-origin iframes. 35 | top.location + '' 36 | } catch { 37 | return false 38 | } 39 | } else if ('showOpenFilePicker' in self) { 40 | return 'showOpenFilePicker' 41 | } 42 | return false 43 | })() 44 | 45 | export default supported 46 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/filesystem.spec.ts: -------------------------------------------------------------------------------- 1 | describe('when saving data to the file system', () => { 2 | it.todo('saves a new file in the filesystem') 3 | it.todo('saves a new file in the filesystem') 4 | }) 5 | 6 | describe('when opening files from file system', () => { 7 | it.todo('opens a file and loads it into the document') 8 | it.todo('opens an older file, migrates it, and loads it into the document') 9 | it.todo('opens a corrupt file, tries to fix it, and fails without crashing') 10 | }) 11 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/index.ts: -------------------------------------------------------------------------------- 1 | export * from './migrate' 2 | export * from './filesystem' 3 | export * from './browser-fs-access' 4 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/data/migrate.spec.ts: -------------------------------------------------------------------------------- 1 | import type { TDDocument } from '~types' 2 | import { TldrawApp } from '~state' 3 | import oldDoc from '~test/documents/old-doc' 4 | import oldDoc2 from '~test/documents/old-doc-2' 5 | 6 | describe('When migrating bindings', () => { 7 | it('migrates a document without a version', () => { 8 | new TldrawApp().loadDocument(oldDoc as unknown as TDDocument) 9 | }) 10 | 11 | it('migrates a document with an older version', () => { 12 | const app = new TldrawApp().loadDocument(oldDoc2 as unknown as TDDocument) 13 | expect(app.getShape('d7ab0a49-3cb3-43ae-3d83-f5cf2f4a510a').style.color).toBe('black') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/index.ts: -------------------------------------------------------------------------------- 1 | import './internal' 2 | 3 | export * from './TldrawApp' 4 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/internal.ts: -------------------------------------------------------------------------------- 1 | export * from './TldrawApp' 2 | export * from './sessions' 3 | export * from './commands' 4 | export * from './tools' 5 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/ArrowSession/__snapshots__/ArrowSession.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Arrow session arrow binding binds on the inside of a shape while alt is held 1`] = ` 4 | Array [ 5 | 0.76, 6 | 0.09, 7 | ] 8 | `; 9 | 10 | exports[`Arrow session arrow binding snaps to the inside center when the point is close to the center 1`] = ` 11 | Array [ 12 | 0.81, 13 | 0.19, 14 | ] 15 | `; 16 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/ArrowSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ArrowSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/BaseSession.ts: -------------------------------------------------------------------------------- 1 | import type { TLPerformanceMode } from '@tldraw/core' 2 | import type { SessionType, TldrawCommand, TldrawPatch } from '~types' 3 | import type { TldrawApp } from '../internal' 4 | 5 | export abstract class BaseSession { 6 | abstract type: SessionType 7 | abstract performanceMode: TLPerformanceMode | undefined 8 | constructor(public app: TldrawApp) {} 9 | abstract start: () => TldrawPatch | undefined 10 | abstract update: () => TldrawPatch | undefined 11 | abstract complete: () => TldrawPatch | TldrawCommand | undefined 12 | abstract cancel: () => TldrawPatch | undefined 13 | } 14 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/BrushSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BrushSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/DrawSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DrawSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/EraseSession/EraseSession.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | import { TDStatus } from '~types' 3 | 4 | describe('Draw session', () => { 5 | it('begins, updates, completes session', () => { 6 | const app = new TldrawTestApp().loadDocument(mockDocument) 7 | 8 | app.selectTool('erase').pointCanvas([300, 300]) 9 | 10 | expect(app.status).toBe('pointing') 11 | 12 | app.movePointer([0, 0]) 13 | 14 | expect(app.status).toBe('erasing') 15 | 16 | app.stopPointing() 17 | 18 | expect(app.appState.status).toBe(TDStatus.Idle) 19 | 20 | expect(app.shapes.length).toBe(0) 21 | }) 22 | 23 | it('does, undoes and redoes', () => { 24 | const app = new TldrawTestApp() 25 | .loadDocument(mockDocument) 26 | .selectTool('erase') 27 | .pointCanvas([300, 300]) 28 | .movePointer([0, 0]) 29 | .stopPointing() 30 | 31 | expect(app.shapes.length).toBe(0) 32 | 33 | app.undo() 34 | 35 | expect(app.shapes.length).toBe(3) 36 | 37 | app.redo() 38 | 39 | expect(app.shapes.length).toBe(0) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/EraseSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EraseSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/GridSession/GridSession.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | import { TDStatus } from '~types' 3 | 4 | describe('Grid session', () => { 5 | it('begins, updateSession', () => { 6 | const app = new TldrawTestApp() 7 | .loadDocument(mockDocument) 8 | .select('rect1') 9 | .pointShape('rect1', [5, 5]) 10 | .movePointer([10, 10]) 11 | 12 | expect(app.getShape('rect1').point).toStrictEqual([5, 5]) 13 | 14 | app.completeSession() 15 | 16 | expect(app.appState.status).toBe(TDStatus.Idle) 17 | 18 | expect(app.getShape('rect1').point).toStrictEqual([5, 5]) 19 | 20 | app.undo() 21 | 22 | expect(app.getShape('rect1').point).toStrictEqual([0, 0]) 23 | 24 | app.redo() 25 | 26 | expect(app.getShape('rect1').point).toStrictEqual([5, 5]) 27 | }) 28 | 29 | it('cancels session', () => { 30 | const app = new TldrawTestApp() 31 | .loadDocument(mockDocument) 32 | .select('rect1', 'rect2') 33 | .pointBounds([5, 5]) 34 | .movePointer([10, 10]) 35 | .cancelSession() 36 | 37 | expect(app.getShape('rect1').point).toStrictEqual([0, 0]) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/GridSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GridSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/HandleSession/HandleSession.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | import { SessionType, TDShapeType, TDStatus } from '~types' 3 | 4 | describe('Handle session', () => { 5 | it('begins, updateSession', () => { 6 | const app = new TldrawTestApp() 7 | .loadDocument(mockDocument) 8 | .createShapes({ 9 | id: 'arrow1', 10 | type: TDShapeType.Arrow, 11 | }) 12 | .select('arrow1') 13 | .movePointer([-10, -10]) 14 | .startSession(SessionType.Arrow, 'arrow1', 'end') 15 | .movePointer([10, 10]) 16 | .completeSession() 17 | 18 | expect(app.status).toBe(TDStatus.Idle) 19 | 20 | app.undo().redo() 21 | }) 22 | 23 | it('cancels session', () => { 24 | const app = new TldrawTestApp() 25 | .loadDocument(mockDocument) 26 | .createShapes({ 27 | type: TDShapeType.Arrow, 28 | id: 'arrow1', 29 | }) 30 | .select('arrow1') 31 | .movePointer([-10, -10]) 32 | .startSession(SessionType.Arrow, 'arrow1', 'end') 33 | .movePointer([10, 10]) 34 | .cancelSession() 35 | 36 | expect(app.getShape('rect1').point).toStrictEqual([0, 0]) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/HandleSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HandleSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/RotateSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RotateSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/TransformSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TransformSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/TransformSingleSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TransformSingleSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/TranslateLabelSession/TranslateLabelSession.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockDocument, TldrawTestApp } from '~test' 2 | import { SessionType, TDShapeType, TDStatus } from '~types' 3 | 4 | describe('Translate label session', () => { 5 | it.todo('begins, updateSession') 6 | it.todo('cancels session') 7 | }) 8 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/TranslateLabelSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TranslateLabelSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/sessions/TranslateSession/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TranslateSession' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/ArrowUtil/ArrowUtil.spec.tsx: -------------------------------------------------------------------------------- 1 | import Vec from '@tldraw/vec' 2 | import { TldrawTestApp } from '~test' 3 | import { ArrowShape, SessionType, TDShapeType } from '~types' 4 | import { Arrow } from '..' 5 | 6 | describe('Arrow shape', () => { 7 | it('Creates a shape', () => { 8 | expect(Arrow.create({ id: 'arrow' })).toMatchSnapshot('arrow') 9 | }) 10 | }) 11 | 12 | describe('When the arrow has a label...', () => { 13 | it("Positions a straight arrow's label in the center of the bounding box", () => { 14 | const app = new TldrawTestApp() 15 | .resetDocument() 16 | .createShapes( 17 | { type: TDShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] }, 18 | { type: TDShapeType.Arrow, id: 'arrow1', point: [200, 200] } 19 | ) 20 | .select('arrow1') 21 | .movePointer([200, 200]) 22 | .startSession(SessionType.Arrow, 'arrow1', 'start') 23 | .movePointer([55, 55]) 24 | expect(app.bindings[0]).toMatchObject({ 25 | fromId: 'arrow1', 26 | toId: 'target1', 27 | point: [0.5, 0.5], 28 | }) 29 | function getOffset() { 30 | const shape = app.getShape('arrow1') 31 | const bounds = Arrow.getBounds(shape) 32 | const offset = Vec.sub( 33 | shape.handles.bend.point, 34 | Vec.toFixed([bounds.width / 2, bounds.height / 2]) 35 | ) 36 | return offset 37 | } 38 | expect(getOffset()).toMatchObject([0, 0]) 39 | app.select('target1') 40 | app.nudge([0, 1]) 41 | expect(getOffset()).toMatchObject([0, 0]) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/ArrowUtil/__snapshots__/ArrowUtil.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Arrow shape Creates a shape: arrow 1`] = ` 4 | Object { 5 | "bend": 0, 6 | "childIndex": 1, 7 | "decorations": Object { 8 | "end": "arrow", 9 | }, 10 | "handles": Object { 11 | "bend": Object { 12 | "id": "bend", 13 | "index": 2, 14 | "point": Array [ 15 | 0.5, 16 | 0.5, 17 | ], 18 | }, 19 | "end": Object { 20 | "canBind": true, 21 | "id": "end", 22 | "index": 1, 23 | "point": Array [ 24 | 1, 25 | 1, 26 | ], 27 | }, 28 | "start": Object { 29 | "canBind": true, 30 | "id": "start", 31 | "index": 0, 32 | "point": Array [ 33 | 0, 34 | 0, 35 | ], 36 | }, 37 | }, 38 | "id": "arrow", 39 | "label": "", 40 | "labelPoint": Array [ 41 | 0.5, 42 | 0.5, 43 | ], 44 | "name": "Arrow", 45 | "parentId": "page", 46 | "point": Array [ 47 | 0, 48 | 0, 49 | ], 50 | "rotation": 0, 51 | "style": Object { 52 | "color": "black", 53 | "dash": "draw", 54 | "isFilled": false, 55 | "scale": 1, 56 | "size": "small", 57 | }, 58 | "type": "arrow", 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/ArrowUtil/components/ArrowHead.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export interface ArrowheadProps { 4 | left: number[] 5 | middle: number[] 6 | right: number[] 7 | stroke: string 8 | strokeWidth: number 9 | } 10 | 11 | export function Arrowhead({ left, middle, right, stroke, strokeWidth }: ArrowheadProps) { 12 | return ( 13 | 14 | 15 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/ArrowUtil/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ArrowUtil' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/DrawUtil/DrawUtil.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Draw } from '..' 2 | 3 | describe('Draw shape', () => { 4 | it('Creates a shape', () => { 5 | expect(Draw.create({ id: 'draw' })).toMatchSnapshot('draw') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/DrawUtil/__snapshots__/DrawUtil.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Draw shape Creates a shape: draw 1`] = ` 4 | Object { 5 | "childIndex": 1, 6 | "id": "draw", 7 | "isComplete": false, 8 | "name": "Draw", 9 | "parentId": "page", 10 | "point": Array [ 11 | 0, 12 | 0, 13 | ], 14 | "points": Array [], 15 | "rotation": 0, 16 | "style": Object { 17 | "color": "black", 18 | "dash": "draw", 19 | "isFilled": false, 20 | "scale": 1, 21 | "size": "small", 22 | }, 23 | "type": "draw", 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/DrawUtil/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DrawUtil' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/EllipseUtil/EllipseUtil.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Ellipse } from '..' 2 | 3 | describe('Ellipse shape', () => { 4 | it('Creates a shape', () => { 5 | expect(Ellipse.create({ id: 'ellipse' })).toMatchSnapshot('ellipse') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/EllipseUtil/__snapshots__/EllipseUtil.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Ellipse shape Creates a shape: ellipse 1`] = ` 4 | Object { 5 | "childIndex": 1, 6 | "id": "ellipse", 7 | "label": "", 8 | "labelPoint": Array [ 9 | 0.5, 10 | 0.5, 11 | ], 12 | "name": "Ellipse", 13 | "parentId": "page", 14 | "point": Array [ 15 | 0, 16 | 0, 17 | ], 18 | "radius": Array [ 19 | 1, 20 | 1, 21 | ], 22 | "rotation": 0, 23 | "style": Object { 24 | "color": "black", 25 | "dash": "draw", 26 | "isFilled": false, 27 | "scale": 1, 28 | "size": "small", 29 | }, 30 | "type": "ellipse", 31 | } 32 | `; 33 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/EllipseUtil/components/DashedEllipse.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Utils } from '@tldraw/core' 3 | import type { ShapeStyles } from '~types' 4 | import { getShapeStyle } from '~state/shapes/shared' 5 | 6 | interface EllipseSvgProps { 7 | radius: number[] 8 | style: ShapeStyles 9 | isSelected: boolean 10 | isDarkMode: boolean 11 | } 12 | 13 | export const DashedEllipse = React.memo(function DashedEllipse({ 14 | radius, 15 | style, 16 | isSelected, 17 | isDarkMode, 18 | }: EllipseSvgProps) { 19 | const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) 20 | const sw = 1 + strokeWidth * 1.618 21 | const rx = Math.max(0, radius[0] - sw / 2) 22 | const ry = Math.max(0, radius[1] - sw / 2) 23 | const perimeter = Utils.perimeterOfEllipse(rx, ry) 24 | const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( 25 | perimeter < 64 ? perimeter * 2 : perimeter, 26 | strokeWidth * 1.618, 27 | style.dash, 28 | 4 29 | ) 30 | 31 | return ( 32 | <> 33 | 40 | 54 | 55 | ) 56 | }) 57 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/EllipseUtil/components/DrawEllipse.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { getShapeStyle } from '~state/shapes/shared' 3 | import type { ShapeStyles } from '~types' 4 | import { getEllipseIndicatorPath, getEllipsePath } from '../ellipseHelpers' 5 | 6 | interface EllipseSvgProps { 7 | id: string 8 | radius: number[] 9 | style: ShapeStyles 10 | isSelected: boolean 11 | isDarkMode: boolean 12 | } 13 | 14 | export const DrawEllipse = React.memo(function DrawEllipse({ 15 | id, 16 | radius, 17 | style, 18 | isSelected, 19 | isDarkMode, 20 | }: EllipseSvgProps) { 21 | const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) 22 | const innerPath = getEllipsePath(id, radius, style) 23 | 24 | return ( 25 | <> 26 | 33 | {style.isFilled && ( 34 | 40 | )} 41 | 50 | 51 | ) 52 | }) 53 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/EllipseUtil/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EllipseUtil' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/GroupUtil/GroupUtil.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Group } from '..' 2 | 3 | describe('Group shape', () => { 4 | it('Creates a shape', () => { 5 | expect(Group.create({ id: 'group' })).toMatchSnapshot('group') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/GroupUtil/__snapshots__/GroupUtil.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Group shape Creates a shape: group 1`] = ` 4 | Object { 5 | "childIndex": 1, 6 | "children": Array [], 7 | "id": "group", 8 | "name": "Group", 9 | "parentId": "page", 10 | "point": Array [ 11 | 0, 12 | 0, 13 | ], 14 | "rotation": 0, 15 | "size": Array [ 16 | 100, 17 | 100, 18 | ], 19 | "style": Object { 20 | "color": "black", 21 | "dash": "draw", 22 | "isFilled": false, 23 | "scale": 1, 24 | "size": "small", 25 | }, 26 | "type": "group", 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/GroupUtil/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GroupUtil' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/ImageUtil/ImageUtil.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from '..' 2 | 3 | describe('Image shape', () => { 4 | it('Creates a shape', () => { 5 | expect(Image.create({ id: 'image' })).toMatchSnapshot('image') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/ImageUtil/__snapshots__/ImageUtil.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Image shape Creates a shape: image 1`] = ` 4 | Object { 5 | "assetId": "assetId", 6 | "childIndex": 1, 7 | "id": "image", 8 | "name": "Image", 9 | "parentId": "page", 10 | "point": Array [ 11 | 0, 12 | 0, 13 | ], 14 | "rotation": 0, 15 | "size": Array [ 16 | 1, 17 | 1, 18 | ], 19 | "style": Object { 20 | "color": "black", 21 | "dash": "draw", 22 | "isFilled": true, 23 | "scale": 1, 24 | "size": "small", 25 | }, 26 | "type": "image", 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/ImageUtil/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ImageUtil' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/RectangleUtil/RectangleUtil.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Rectangle } from '..' 2 | 3 | describe('Rectangle shape', () => { 4 | it('Creates a shape', () => { 5 | expect(Rectangle.create({ id: 'rectangle' })).toMatchSnapshot('rectangle') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/RectangleUtil/__snapshots__/RectangleUtil.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Rectangle shape Creates a shape: rectangle 1`] = ` 4 | Object { 5 | "childIndex": 1, 6 | "id": "rectangle", 7 | "label": "", 8 | "labelPoint": Array [ 9 | 0.5, 10 | 0.5, 11 | ], 12 | "name": "Rectangle", 13 | "parentId": "page", 14 | "point": Array [ 15 | 0, 16 | 0, 17 | ], 18 | "rotation": 0, 19 | "size": Array [ 20 | 1, 21 | 1, 22 | ], 23 | "style": Object { 24 | "color": "black", 25 | "dash": "draw", 26 | "isFilled": false, 27 | "scale": 1, 28 | "size": "small", 29 | }, 30 | "type": "rectangle", 31 | } 32 | `; 33 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/RectangleUtil/components/BindingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { BINDING_DISTANCE } from '~constants' 3 | 4 | interface BindingIndicatorProps { 5 | strokeWidth: number 6 | size: number[] 7 | } 8 | export function BindingIndicator({ strokeWidth, size }: BindingIndicatorProps) { 9 | return ( 10 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/RectangleUtil/components/DashedRectangle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Utils } from '@tldraw/core' 3 | import { BINDING_DISTANCE } from '~constants' 4 | import type { ShapeStyles } from '~types' 5 | import { getShapeStyle } from '~state/shapes/shared' 6 | 7 | interface RectangleSvgProps { 8 | id: string 9 | style: ShapeStyles 10 | isSelected: boolean 11 | size: number[] 12 | isDarkMode: boolean 13 | } 14 | 15 | export const DashedRectangle = React.memo(function DashedRectangle({ 16 | id, 17 | style, 18 | size, 19 | isSelected, 20 | isDarkMode, 21 | }: RectangleSvgProps) { 22 | const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) 23 | 24 | const sw = 1 + strokeWidth * 1.618 25 | 26 | const w = Math.max(0, size[0] - sw / 2) 27 | const h = Math.max(0, size[1] - sw / 2) 28 | 29 | const strokes: [number[], number[], number][] = [ 30 | [[sw / 2, sw / 2], [w, sw / 2], w - sw / 2], 31 | [[w, sw / 2], [w, h], h - sw / 2], 32 | [[w, h], [sw / 2, h], w - sw / 2], 33 | [[sw / 2, h], [sw / 2, sw / 2], h - sw / 2], 34 | ] 35 | 36 | const paths = strokes.map(([start, end, length], i) => { 37 | const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( 38 | length, 39 | strokeWidth * 1.618, 40 | style.dash 41 | ) 42 | 43 | return ( 44 | 53 | ) 54 | }) 55 | 56 | return ( 57 | <> 58 | 66 | {style.isFilled && ( 67 | 68 | )} 69 | 70 | {paths} 71 | 72 | 73 | ) 74 | }) 75 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/RectangleUtil/components/DrawRectangle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { getShapeStyle } from '~state/shapes/shared' 3 | import type { ShapeStyles } from '~types' 4 | import { getRectangleIndicatorPathTDSnapshot, getRectanglePath } from '../rectangleHelpers' 5 | 6 | interface RectangleSvgProps { 7 | id: string 8 | style: ShapeStyles 9 | isSelected: boolean 10 | isDarkMode: boolean 11 | size: number[] 12 | } 13 | 14 | export const DrawRectangle = React.memo(function DrawRectangle({ 15 | id, 16 | style, 17 | size, 18 | isSelected, 19 | isDarkMode, 20 | }: RectangleSvgProps) { 21 | const { isFilled } = style 22 | const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) 23 | const pathTDSnapshot = getRectanglePath(id, style, size) 24 | const innerPath = getRectangleIndicatorPathTDSnapshot(id, style, size) 25 | 26 | return ( 27 | <> 28 | 32 | {isFilled && } 33 | 40 | 41 | ) 42 | }) 43 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/RectangleUtil/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RectangleUtil' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/StickyUtil/StickyUtil.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Sticky } from '..' 2 | 3 | describe('Post-It shape', () => { 4 | it('Creates a shape', () => { 5 | expect(Sticky.create).toBeDefined() 6 | // expect(Sticky.create({ id: 'sticky' })).toMatchSnapshot('sticky') 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/StickyUtil/index.ts: -------------------------------------------------------------------------------- 1 | export * from './StickyUtil' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/TextUtil/TextUtil.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from '..' 2 | 3 | describe('Text shape', () => { 4 | it('Creates a shape', () => { 5 | expect(Text.create({ id: 'text' })).toMatchSnapshot('text') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/TextUtil/__snapshots__/TextUtil.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Text shape Creates a shape: text 1`] = ` 4 | Object { 5 | "childIndex": 1, 6 | "id": "text", 7 | "name": "Text", 8 | "parentId": "page", 9 | "point": Array [ 10 | 0, 11 | 0, 12 | ], 13 | "rotation": 0, 14 | "style": Object { 15 | "color": "black", 16 | "dash": "draw", 17 | "font": "script", 18 | "isFilled": false, 19 | "scale": 1, 20 | "size": "small", 21 | "textAlign": "middle", 22 | }, 23 | "text": " ", 24 | "type": "text", 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/TextUtil/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TextUtil' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Triangle } from '..' 2 | 3 | describe('Triangle shape', () => { 4 | it('Creates a shape', () => { 5 | expect(Triangle.create({ id: 'triangle' })).toMatchSnapshot('triangle') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/TriangleUtil/__snapshots__/TriangleUtil.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Triangle shape Creates a shape: triangle 1`] = ` 4 | Object { 5 | "childIndex": 1, 6 | "id": "triangle", 7 | "label": "", 8 | "labelPoint": Array [ 9 | 0.5, 10 | 0.5, 11 | ], 12 | "name": "Triangle", 13 | "parentId": "page", 14 | "point": Array [ 15 | 0, 16 | 0, 17 | ], 18 | "rotation": 0, 19 | "size": Array [ 20 | 1, 21 | 1, 22 | ], 23 | "style": Object { 24 | "color": "black", 25 | "dash": "draw", 26 | "isFilled": false, 27 | "scale": 1, 28 | "size": "small", 29 | }, 30 | "type": "triangle", 31 | } 32 | `; 33 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/TriangleUtil/components/DashedTriangle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Utils } from '@tldraw/core' 3 | import type { ShapeStyles } from '~types' 4 | import { getShapeStyle } from '~state/shapes/shared' 5 | import { getTrianglePoints } from '../triangleHelpers' 6 | import Vec from '@tldraw/vec' 7 | 8 | interface TriangleSvgProps { 9 | id: string 10 | size: number[] 11 | style: ShapeStyles 12 | isSelected: boolean 13 | isDarkMode: boolean 14 | } 15 | 16 | export const DashedTriangle = React.memo(function DashedTriangle({ 17 | id, 18 | size, 19 | style, 20 | isSelected, 21 | isDarkMode, 22 | }: TriangleSvgProps) { 23 | const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) 24 | const sw = 1 + strokeWidth * 1.618 25 | const points = getTrianglePoints(size) 26 | const sides = Utils.pointsToLineSegments(points, true) 27 | const paths = sides.map(([start, end], i) => { 28 | const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( 29 | Vec.dist(start, end), 30 | strokeWidth * 1.618, 31 | style.dash 32 | ) 33 | 34 | return ( 35 | 47 | ) 48 | }) 49 | 50 | const bgPath = points.join() 51 | 52 | return ( 53 | <> 54 | 58 | {style.isFilled && } 59 | {paths} 60 | 61 | ) 62 | }) 63 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/TriangleUtil/components/DrawTriangle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { getShapeStyle } from '~state/shapes/shared' 3 | import type { ShapeStyles } from '~types' 4 | import { getTriangleIndicatorPathTDSnapshot, getTrianglePath } from '../triangleHelpers' 5 | 6 | interface TriangleSvgProps { 7 | id: string 8 | size: number[] 9 | style: ShapeStyles 10 | isSelected: boolean 11 | isDarkMode: boolean 12 | } 13 | 14 | export const DrawTriangle = React.memo(function DrawTriangle({ 15 | id, 16 | size, 17 | style, 18 | isSelected, 19 | isDarkMode, 20 | }: TriangleSvgProps) { 21 | const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) 22 | const pathTDSnapshot = getTrianglePath(id, size, style) 23 | const indicatorPath = getTriangleIndicatorPathTDSnapshot(id, size, style) 24 | return ( 25 | <> 26 | 30 | {style.isFilled && } 31 | 38 | 39 | ) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/TriangleUtil/components/TriangleBindingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { BINDING_DISTANCE } from '~constants' 3 | import { getTrianglePoints } from '../triangleHelpers' 4 | 5 | interface TriangleBindingIndicatorProps { 6 | size: number[] 7 | } 8 | 9 | export function TriangleBindingIndicator({ size }: TriangleBindingIndicatorProps) { 10 | const trianglePoints = getTrianglePoints(size).join() 11 | return ( 12 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/TriangleUtil/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TriangleUtil' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/VideoUtil/VideoUtil.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Video } from '..' 2 | 3 | describe('Video shape', () => { 4 | it('Creates a shape', () => { 5 | expect(Video.create({ id: 'video' })).toMatchSnapshot('video') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/VideoUtil/__snapshots__/VideoUtil.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Video shape Creates a shape: video 1`] = ` 4 | Object { 5 | "assetId": "assetId", 6 | "childIndex": 1, 7 | "currentTime": 0, 8 | "id": "video", 9 | "isPlaying": true, 10 | "name": "Video", 11 | "parentId": "page", 12 | "point": Array [ 13 | 0, 14 | 0, 15 | ], 16 | "rotation": 0, 17 | "size": Array [ 18 | 1, 19 | 1, 20 | ], 21 | "style": Object { 22 | "color": "black", 23 | "dash": "draw", 24 | "isFilled": false, 25 | "scale": 1, 26 | "size": "small", 27 | }, 28 | "type": "video", 29 | } 30 | `; 31 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/VideoUtil/index.ts: -------------------------------------------------------------------------------- 1 | export * from './VideoUtil' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/about-shape-utils.md: -------------------------------------------------------------------------------- 1 | # Shape Utils 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/index.ts: -------------------------------------------------------------------------------- 1 | import type { TDShapeUtil } from './TDShapeUtil' 2 | import { RectangleUtil } from './RectangleUtil' 3 | import { TriangleUtil } from './TriangleUtil' 4 | import { EllipseUtil } from './EllipseUtil' 5 | import { ArrowUtil } from './ArrowUtil' 6 | import { GroupUtil } from './GroupUtil' 7 | import { StickyUtil } from './StickyUtil' 8 | import { TextUtil } from './TextUtil' 9 | import { DrawUtil } from './DrawUtil' 10 | import { ImageUtil } from './ImageUtil' 11 | import { TDShape, TDShapeType } from '~types' 12 | import { VideoUtil } from './VideoUtil' 13 | 14 | export const Rectangle = new RectangleUtil() 15 | export const Triangle = new TriangleUtil() 16 | export const Ellipse = new EllipseUtil() 17 | export const Draw = new DrawUtil() 18 | export const Arrow = new ArrowUtil() 19 | export const Text = new TextUtil() 20 | export const Group = new GroupUtil() 21 | export const Sticky = new StickyUtil() 22 | export const Image = new ImageUtil() 23 | export const Video = new VideoUtil() 24 | 25 | export const shapeUtils = { 26 | [TDShapeType.Rectangle]: Rectangle, 27 | [TDShapeType.Triangle]: Triangle, 28 | [TDShapeType.Ellipse]: Ellipse, 29 | [TDShapeType.Draw]: Draw, 30 | [TDShapeType.Arrow]: Arrow, 31 | [TDShapeType.Text]: Text, 32 | [TDShapeType.Group]: Group, 33 | [TDShapeType.Sticky]: Sticky, 34 | [TDShapeType.Image]: Image, 35 | [TDShapeType.Video]: Video, 36 | } 37 | 38 | export const getShapeUtil = (shape: T | T['type']) => { 39 | if (typeof shape === 'string') return shapeUtils[shape] as unknown as TDShapeUtil 40 | return shapeUtils[shape.type] as unknown as TDShapeUtil 41 | } 42 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/shared/LabelMask.tsx: -------------------------------------------------------------------------------- 1 | import type { TLBounds } from '@tldraw/core' 2 | import * as React from 'react' 3 | 4 | interface WithLabelMaskProps { 5 | id: string 6 | bounds: TLBounds 7 | labelSize: number[] 8 | offset?: number[] 9 | scale?: number 10 | } 11 | 12 | export function LabelMask({ id, bounds, labelSize, offset, scale = 1 }: WithLabelMaskProps) { 13 | return ( 14 | 15 | 16 | 23 | 33 | 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/shared/getBoundsRectangle.ts: -------------------------------------------------------------------------------- 1 | import { TLBounds, TLShape, Utils } from '@tldraw/core' 2 | 3 | /** 4 | * Find the bounds of a rectangular shape. 5 | * @param shape 6 | * @param boundsCache 7 | */ 8 | export function getBoundsRectangle( 9 | shape: T, 10 | boundsCache: WeakMap 11 | ) { 12 | const bounds = Utils.getFromCache(boundsCache, shape, () => { 13 | const [width, height] = shape.size 14 | return { 15 | minX: 0, 16 | maxX: width, 17 | minY: 0, 18 | maxY: height, 19 | width, 20 | height, 21 | } 22 | }) 23 | 24 | return Utils.translateBounds(bounds, shape.point) 25 | } 26 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/shared/getTextAlign.ts: -------------------------------------------------------------------------------- 1 | import { AlignStyle } from '~types' 2 | 3 | const ALIGN_VALUES = { 4 | [AlignStyle.Start]: 'left', 5 | [AlignStyle.Middle]: 'center', 6 | [AlignStyle.End]: 'right', 7 | [AlignStyle.Justify]: 'justify', 8 | } as const 9 | 10 | export function getTextAlign(alignStyle: AlignStyle = AlignStyle.Start) { 11 | return ALIGN_VALUES[alignStyle] 12 | } 13 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/shared/getTextSize.ts: -------------------------------------------------------------------------------- 1 | import { LETTER_SPACING } from '~constants' 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | let melm: any 5 | 6 | function getMeasurementDiv() { 7 | // A div used for measurement 8 | document.getElementById('__textLabelMeasure')?.remove() 9 | 10 | const pre = document.createElement('pre') 11 | pre.id = '__textLabelMeasure' 12 | 13 | Object.assign(pre.style, { 14 | whiteSpace: 'pre', 15 | width: 'auto', 16 | border: '1px solid transparent', 17 | padding: '4px', 18 | margin: '0px', 19 | letterSpacing: LETTER_SPACING, 20 | opacity: '0', 21 | position: 'absolute', 22 | top: '-500px', 23 | left: '0px', 24 | zIndex: '9999', 25 | pointerEvents: 'none', 26 | userSelect: 'none', 27 | alignmentBaseline: 'mathematical', 28 | dominantBaseline: 'mathematical', 29 | }) 30 | 31 | pre.tabIndex = -1 32 | 33 | document.body.appendChild(pre) 34 | return pre 35 | } 36 | 37 | if (typeof window !== 'undefined') { 38 | melm = getMeasurementDiv() 39 | } 40 | 41 | let prevText = '' 42 | let prevFont = '' 43 | let prevSize = [0, 0] 44 | 45 | export function getTextLabelSize(text: string, font: string) { 46 | if (!text) { 47 | return [16, 32] 48 | } 49 | 50 | if (!melm) { 51 | // We're in SSR 52 | return [10, 10] 53 | } 54 | 55 | if (text === prevText && font === prevFont) { 56 | return prevSize 57 | } 58 | 59 | prevText = text 60 | prevFont = font 61 | 62 | melm.textContent = `${text}` 63 | melm.style.font = font 64 | 65 | // In tests, offsetWidth and offsetHeight will be 0 66 | const width = melm.offsetWidth || 1 67 | const height = melm.offsetHeight || 1 68 | 69 | prevSize = [width, height] 70 | return prevSize 71 | } 72 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/shared/getTextSvgElement.ts: -------------------------------------------------------------------------------- 1 | import type { TLBounds } from '@tldraw/core' 2 | import { AlignStyle, ShapeStyles } from '~types' 3 | import { getFontFace, getFontSize } from './shape-styles' 4 | import { getTextAlign } from './getTextAlign' 5 | import { LINE_HEIGHT } from '~constants' 6 | 7 | export function getTextSvgElement(text: string, style: ShapeStyles, bounds: TLBounds) { 8 | const fontSize = getFontSize(style.size, style.font) 9 | const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') 10 | const textLines = text.split('\n').map((line, i) => { 11 | const textElm = document.createElementNS('http://www.w3.org/2000/svg', 'text') 12 | textElm.textContent = line 13 | textElm.setAttribute('y', LINE_HEIGHT * fontSize * (0.5 + i) + '') 14 | g.appendChild(textElm) 15 | return textElm 16 | }) 17 | g.setAttribute('font-size', fontSize + '') 18 | g.setAttribute('font-family', getFontFace(style.font).slice(1, -1)) 19 | g.setAttribute('text-align', getTextAlign(style.textAlign)) 20 | switch (style.textAlign) { 21 | case AlignStyle.Middle: { 22 | g.setAttribute('text-align', 'center') 23 | g.setAttribute('text-anchor', 'middle') 24 | textLines.forEach((textElm) => textElm.setAttribute('x', bounds.width / 2 + '')) 25 | break 26 | } 27 | case AlignStyle.End: { 28 | g.setAttribute('text-align', 'right') 29 | g.setAttribute('text-anchor', 'end') 30 | textLines.forEach((textElm) => textElm.setAttribute('x', bounds.width + '')) 31 | break 32 | } 33 | case AlignStyle.Start: { 34 | g.setAttribute('text-anchor', 'start') 35 | g.setAttribute('alignment-baseline', 'central') 36 | } 37 | } 38 | return g 39 | } 40 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getBoundsRectangle' 2 | export * from './transformRectangle' 3 | export * from './transformSingleRectangle' 4 | export * from './TextAreaUtils' 5 | export * from './shape-styles' 6 | export * from './getTextAlign' 7 | export * from './TextLabel' 8 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/shared/transformRectangle.ts: -------------------------------------------------------------------------------- 1 | import type { TLBounds, TLShape, TLTransformInfo } from '@tldraw/core' 2 | import Vec from '@tldraw/vec' 3 | 4 | /** 5 | * Transform a rectangular shape. 6 | * @param shape 7 | * @param bounds 8 | * @param param2 9 | */ 10 | export function transformRectangle( 11 | shape: T, 12 | bounds: TLBounds, 13 | { initialShape, transformOrigin, scaleX, scaleY }: TLTransformInfo 14 | ) { 15 | if (shape.rotation || initialShape.isAspectRatioLocked) { 16 | const size = Vec.toFixed( 17 | Vec.mul(initialShape.size, Math.min(Math.abs(scaleX), Math.abs(scaleY))) 18 | ) 19 | const point = Vec.toFixed([ 20 | bounds.minX + 21 | (bounds.width - shape.size[0]) * (scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]), 22 | bounds.minY + 23 | (bounds.height - shape.size[1]) * 24 | (scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]), 25 | ]) 26 | const rotation = 27 | (scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0) 28 | ? initialShape.rotation 29 | ? -initialShape.rotation 30 | : 0 31 | : initialShape.rotation 32 | return { 33 | size, 34 | point, 35 | rotation, 36 | } 37 | } else { 38 | return { 39 | point: Vec.toFixed([bounds.minX, bounds.minY]), 40 | size: Vec.toFixed([bounds.width, bounds.height]), 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/shared/transformSingleRectangle.ts: -------------------------------------------------------------------------------- 1 | import type { TLBounds, TLShape } from '@tldraw/core' 2 | import Vec from '@tldraw/vec' 3 | 4 | /** 5 | * Transform a single rectangular shape. 6 | * @param shape 7 | * @param bounds 8 | */ 9 | export function transformSingleRectangle( 10 | shape: T, 11 | bounds: TLBounds 12 | ) { 13 | return { 14 | size: Vec.toFixed([bounds.width, bounds.height]), 15 | point: Vec.toFixed([bounds.minX, bounds.minY]), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/shapes/shared/useTextKeyboardEvents.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { TLDR } from '~state/TLDR' 3 | import { TextAreaUtils } from '.' 4 | 5 | export function useTextKeyboardEvents(onChange: (text: string) => void) { 6 | const handleKeyDown = React.useCallback( 7 | (e: React.KeyboardEvent) => { 8 | // If this keydown was just the meta key or a shortcut 9 | // that includes holding the meta key like (Command+V) 10 | // then leave the event untouched. We also have to explicitly 11 | // Implement undo/redo for some reason in order to get this working 12 | // in the vscode extension. Without the below code the following doesn't work 13 | // 14 | // - You can't cut/copy/paste when when text-editing/focused 15 | // - You can't undo/redo when when text-editing/focused 16 | // - You can't use Command+A to select all the text, when when text-editing/focused 17 | if (e.metaKey) e.stopPropagation() 18 | 19 | switch (e.key) { 20 | case 'Meta': { 21 | e.stopPropagation() 22 | break 23 | } 24 | case 'z': { 25 | if (e.metaKey) { 26 | if (e.shiftKey) { 27 | document.execCommand('redo', false) 28 | } else { 29 | document.execCommand('undo', false) 30 | } 31 | e.preventDefault() 32 | } 33 | break 34 | } 35 | case 'Escape': { 36 | e.currentTarget.blur() 37 | break 38 | } 39 | case 'Enter': { 40 | if (e.ctrlKey || e.metaKey) { 41 | e.currentTarget.blur() 42 | } 43 | break 44 | } 45 | case 'Tab': { 46 | e.preventDefault() 47 | if (e.shiftKey) { 48 | TextAreaUtils.unindent(e.currentTarget) 49 | } else { 50 | TextAreaUtils.indent(e.currentTarget) 51 | } 52 | 53 | onChange(TLDR.normalizeText(e.currentTarget.value)) 54 | break 55 | } 56 | } 57 | }, 58 | [onChange] 59 | ) 60 | 61 | return handleKeyDown 62 | } 63 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/ArrowTool/ArrowTool.spec.ts: -------------------------------------------------------------------------------- 1 | import { TldrawApp } from '~state' 2 | import { ArrowTool } from '.' 3 | 4 | describe('ArrowTool', () => { 5 | it('creates tool', () => { 6 | const app = new TldrawApp() 7 | new ArrowTool(app) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/ArrowTool/ArrowTool.ts: -------------------------------------------------------------------------------- 1 | import { Utils, TLPointerEventHandler } from '@tldraw/core' 2 | import Vec from '@tldraw/vec' 3 | import { Arrow } from '~state/shapes' 4 | import { SessionType, TDShapeType } from '~types' 5 | import { BaseTool, Status } from '../BaseTool' 6 | 7 | export class ArrowTool extends BaseTool { 8 | type = TDShapeType.Arrow as const 9 | 10 | /* ----------------- Event Handlers ----------------- */ 11 | 12 | onPointerDown: TLPointerEventHandler = () => { 13 | if (this.status !== Status.Idle) return 14 | 15 | const { 16 | currentPoint, 17 | currentGrid, 18 | settings: { showGrid }, 19 | appState: { currentPageId, currentStyle }, 20 | } = this.app 21 | 22 | const childIndex = this.getNextChildIndex() 23 | 24 | const id = Utils.uniqueId() 25 | 26 | const newShape = Arrow.create({ 27 | id, 28 | parentId: currentPageId, 29 | childIndex, 30 | point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint, 31 | style: { ...currentStyle }, 32 | }) 33 | 34 | this.app.patchCreate([newShape]) 35 | 36 | this.app.startSession(SessionType.Arrow, newShape.id, 'end', true) 37 | 38 | this.setStatus(Status.Creating) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/ArrowTool/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ArrowTool' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/DrawTool/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DrawTool' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/EllipseTool/EllipseTool.spec.ts: -------------------------------------------------------------------------------- 1 | import { TldrawApp } from '~state' 2 | import { EllipseTool } from '.' 3 | 4 | describe('EllipseTool', () => { 5 | it('creates tool', () => { 6 | const app = new TldrawApp() 7 | new EllipseTool(app) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/EllipseTool/EllipseTool.ts: -------------------------------------------------------------------------------- 1 | import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core' 2 | import Vec from '@tldraw/vec' 3 | import { Ellipse } from '~state/shapes' 4 | import { SessionType, TDShapeType } from '~types' 5 | import { BaseTool, Status } from '../BaseTool' 6 | 7 | export class EllipseTool extends BaseTool { 8 | type = TDShapeType.Ellipse as const 9 | 10 | /* ----------------- Event Handlers ----------------- */ 11 | 12 | onPointerDown: TLPointerEventHandler = () => { 13 | if (this.status !== Status.Idle) return 14 | 15 | const { 16 | currentPoint, 17 | currentGrid, 18 | settings: { showGrid }, 19 | appState: { currentPageId, currentStyle }, 20 | } = this.app 21 | 22 | const childIndex = this.getNextChildIndex() 23 | 24 | const id = Utils.uniqueId() 25 | 26 | const newShape = Ellipse.create({ 27 | id, 28 | parentId: currentPageId, 29 | childIndex, 30 | point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint, 31 | style: { ...currentStyle }, 32 | }) 33 | 34 | this.app.patchCreate([newShape]) 35 | 36 | this.app.startSession( 37 | SessionType.TransformSingle, 38 | newShape.id, 39 | TLBoundsCorner.BottomRight, 40 | true 41 | ) 42 | 43 | this.setStatus(Status.Creating) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/EllipseTool/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EllipseTool' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/EraseTool/EraseTool.spec.ts: -------------------------------------------------------------------------------- 1 | import { TldrawApp } from '~state' 2 | import { EraseTool } from './EraseTool' 3 | 4 | describe('EraseTool', () => { 5 | it('creates tool', () => { 6 | const app = new TldrawApp() 7 | new EraseTool(app) 8 | }) 9 | 10 | it.todo('restores previous tool after erasing') 11 | }) 12 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/EraseTool/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EraseTool' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/LineTool/LineTool.spec.ts: -------------------------------------------------------------------------------- 1 | import { TldrawApp } from '~state' 2 | import { LineTool } from '.' 3 | 4 | describe('LineTool', () => { 5 | it('creates tool', () => { 6 | const app = new TldrawApp() 7 | new LineTool(app) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/LineTool/LineTool.ts: -------------------------------------------------------------------------------- 1 | import { Utils, TLPointerEventHandler } from '@tldraw/core' 2 | import Vec from '@tldraw/vec' 3 | import { Arrow } from '~state/shapes' 4 | import { SessionType, TDShapeType } from '~types' 5 | import { BaseTool, Status } from '../BaseTool' 6 | 7 | export class LineTool extends BaseTool { 8 | type = TDShapeType.Line as const 9 | 10 | /* ----------------- Event Handlers ----------------- */ 11 | 12 | onPointerDown: TLPointerEventHandler = () => { 13 | if (this.status !== Status.Idle) return 14 | 15 | const { 16 | currentPoint, 17 | currentGrid, 18 | settings: { showGrid }, 19 | appState: { currentPageId, currentStyle }, 20 | } = this.app 21 | 22 | const childIndex = this.getNextChildIndex() 23 | 24 | const id = Utils.uniqueId() 25 | 26 | const newShape = Arrow.create({ 27 | id, 28 | parentId: currentPageId, 29 | childIndex, 30 | point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint, 31 | decorations: { 32 | start: undefined, 33 | end: undefined, 34 | }, 35 | style: { ...currentStyle }, 36 | }) 37 | 38 | this.app.patchCreate([newShape]) 39 | 40 | this.app.startSession(SessionType.Arrow, newShape.id, 'end', true) 41 | 42 | this.setStatus(Status.Creating) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/LineTool/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LineTool' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/RectangleTool/RectangleTool.spec.ts: -------------------------------------------------------------------------------- 1 | import { TldrawApp } from '~state' 2 | import { RectangleTool } from '.' 3 | 4 | describe('RectangleTool', () => { 5 | it('creates tool', () => { 6 | const app = new TldrawApp() 7 | new RectangleTool(app) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/RectangleTool/RectangleTool.ts: -------------------------------------------------------------------------------- 1 | import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core' 2 | import Vec from '@tldraw/vec' 3 | import { Rectangle } from '~state/shapes' 4 | import { SessionType, TDShapeType } from '~types' 5 | import { BaseTool, Status } from '../BaseTool' 6 | 7 | export class RectangleTool extends BaseTool { 8 | type = TDShapeType.Rectangle as const 9 | 10 | /* ----------------- Event Handlers ----------------- */ 11 | 12 | onPointerDown: TLPointerEventHandler = () => { 13 | if (this.status !== Status.Idle) return 14 | 15 | const { 16 | currentPoint, 17 | currentGrid, 18 | settings: { showGrid }, 19 | appState: { currentPageId, currentStyle }, 20 | } = this.app 21 | 22 | const childIndex = this.getNextChildIndex() 23 | 24 | const id = Utils.uniqueId() 25 | 26 | const newShape = Rectangle.create({ 27 | id, 28 | parentId: currentPageId, 29 | childIndex, 30 | point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint, 31 | style: { ...currentStyle }, 32 | }) 33 | 34 | this.app.patchCreate([newShape]) 35 | 36 | this.app.startSession( 37 | SessionType.TransformSingle, 38 | newShape.id, 39 | TLBoundsCorner.BottomRight, 40 | true 41 | ) 42 | 43 | this.setStatus(Status.Creating) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/RectangleTool/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RectangleTool' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/SelectTool/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SelectTool' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/StickyTool/StickyTool.spec.ts: -------------------------------------------------------------------------------- 1 | import { TldrawApp } from '~state' 2 | import { StickyTool } from '.' 3 | 4 | describe('StickyTool', () => { 5 | it('creates tool', () => { 6 | const app = new TldrawApp() 7 | new StickyTool(app) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/StickyTool/StickyTool.ts: -------------------------------------------------------------------------------- 1 | import Vec from '@tldraw/vec' 2 | import type { TLPointerEventHandler } from '@tldraw/core' 3 | import { Utils } from '@tldraw/core' 4 | import { Sticky } from '~state/shapes' 5 | import { SessionType, TDShapeType } from '~types' 6 | import { BaseTool, Status } from '../BaseTool' 7 | 8 | export class StickyTool extends BaseTool { 9 | type = TDShapeType.Sticky as const 10 | 11 | shapeId?: string 12 | 13 | /* ----------------- Event Handlers ----------------- */ 14 | 15 | onPointerDown: TLPointerEventHandler = () => { 16 | if (this.status === Status.Creating) { 17 | this.setStatus(Status.Idle) 18 | 19 | if (!this.app.appState.isToolLocked) { 20 | this.app.selectTool('select') 21 | } 22 | 23 | return 24 | } 25 | 26 | if (this.status === Status.Idle) { 27 | const { 28 | currentPoint, 29 | currentGrid, 30 | settings: { showGrid }, 31 | appState: { currentPageId, currentStyle }, 32 | } = this.app 33 | 34 | const childIndex = this.getNextChildIndex() 35 | 36 | const id = Utils.uniqueId() 37 | 38 | this.shapeId = id 39 | 40 | const newShape = Sticky.create({ 41 | id, 42 | parentId: currentPageId, 43 | childIndex, 44 | point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint, 45 | style: { ...currentStyle }, 46 | }) 47 | 48 | const bounds = Sticky.getBounds(newShape) 49 | 50 | newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2]) 51 | 52 | this.app.createShapes(newShape) 53 | 54 | this.app.startSession(SessionType.Translate) 55 | 56 | this.setStatus(Status.Creating) 57 | } 58 | } 59 | 60 | onPointerUp: TLPointerEventHandler = () => { 61 | if (this.status === Status.Creating) { 62 | this.setStatus(Status.Idle) 63 | this.app.completeSession() 64 | this.app.selectTool('select') 65 | this.app.setEditingId(this.shapeId) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/StickyTool/index.ts: -------------------------------------------------------------------------------- 1 | export * from './StickyTool' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/TextTool/TextTool.spec.ts: -------------------------------------------------------------------------------- 1 | import { TldrawApp } from '~state' 2 | import { TextTool } from '.' 3 | 4 | describe('TextTool', () => { 5 | it('creates tool', () => { 6 | const app = new TldrawApp() 7 | new TextTool(app) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/TextTool/TextTool.ts: -------------------------------------------------------------------------------- 1 | import type { TLPointerEventHandler, TLKeyboardEventHandler } from '@tldraw/core' 2 | import Vec from '@tldraw/vec' 3 | import { TDShapeType } from '~types' 4 | import { BaseTool, Status } from '../BaseTool' 5 | 6 | export class TextTool extends BaseTool { 7 | type = TDShapeType.Text as const 8 | 9 | /* --------------------- Methods -------------------- */ 10 | 11 | stopEditingShape = () => { 12 | this.setStatus(Status.Idle) 13 | 14 | if (!this.app.appState.isToolLocked) { 15 | this.app.selectTool('select') 16 | } 17 | } 18 | 19 | /* ----------------- Event Handlers ----------------- */ 20 | 21 | onKeyUp: TLKeyboardEventHandler = () => { 22 | // noop 23 | } 24 | 25 | onKeyDown: TLKeyboardEventHandler = () => { 26 | // noop 27 | } 28 | 29 | onPointerDown: TLPointerEventHandler = () => { 30 | if (this.status === Status.Creating) { 31 | this.stopEditingShape() 32 | return 33 | } 34 | 35 | if (this.status === Status.Idle) { 36 | const { 37 | currentPoint, 38 | currentGrid, 39 | settings: { showGrid }, 40 | } = this.app 41 | 42 | this.app.createTextShapeAtPoint(showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint) 43 | this.setStatus(Status.Creating) 44 | return 45 | } 46 | } 47 | 48 | onPointerUp: TLPointerEventHandler = () => { 49 | // noop important! We don't want the inherited event 50 | // from BaseUtil to run. 51 | } 52 | 53 | onPointShape: TLPointerEventHandler = (info) => { 54 | const shape = this.app.getShape(info.target) 55 | if (shape.type === TDShapeType.Text) { 56 | this.setStatus(Status.Idle) 57 | this.app.setEditingId(shape.id) 58 | } 59 | } 60 | 61 | onShapeBlur = () => { 62 | this.stopEditingShape() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/TextTool/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TextTool' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/TriangleTool/TriangleTool.spec.ts: -------------------------------------------------------------------------------- 1 | import { TldrawApp } from '~state' 2 | import { TriangleTool } from '.' 3 | 4 | describe('TriangleTool', () => { 5 | it('creates tool', () => { 6 | const app = new TldrawApp() 7 | new TriangleTool(app) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/TriangleTool/TriangleTool.ts: -------------------------------------------------------------------------------- 1 | import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core' 2 | import Vec from '@tldraw/vec' 3 | import { Triangle } from '~state/shapes' 4 | import { SessionType, TDShapeType } from '~types' 5 | import { BaseTool, Status } from '../BaseTool' 6 | 7 | export class TriangleTool extends BaseTool { 8 | type = TDShapeType.Triangle as const 9 | 10 | /* ----------------- Event Handlers ----------------- */ 11 | 12 | onPointerDown: TLPointerEventHandler = () => { 13 | if (this.status !== Status.Idle) return 14 | 15 | const { 16 | currentPoint, 17 | currentGrid, 18 | settings: { showGrid }, 19 | appState: { currentPageId, currentStyle }, 20 | } = this.app 21 | 22 | const childIndex = this.getNextChildIndex() 23 | 24 | const id = Utils.uniqueId() 25 | 26 | const newShape = Triangle.create({ 27 | id, 28 | parentId: currentPageId, 29 | childIndex, 30 | point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint, 31 | style: { ...currentStyle }, 32 | }) 33 | 34 | this.app.patchCreate([newShape]) 35 | 36 | this.app.startSession( 37 | SessionType.TransformSingle, 38 | newShape.id, 39 | TLBoundsCorner.BottomRight, 40 | true 41 | ) 42 | 43 | this.setStatus(Status.Creating) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/TriangleTool/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TriangleTool' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/about-tools.md: -------------------------------------------------------------------------------- 1 | # Tools 2 | 3 | Tools are classes that handle events. A tldrawApp instance has a set of tools (`tools`) and one current tool (`currentTool`). The state delegates events (such as `onPointerMove`) to its current tool for handling. 4 | 5 | In this way, tools function as a finite state machine: events are always handled by a tool and will only ever be handled by one tool. 6 | 7 | ## BaseTool 8 | 9 | Each tool extends `BaseTool`, which comes with several default methods used by the majority of other tools. If a tool overrides one of the BaseTool methods, consider re-implementing the functionality found in BaseTool. For example, see how `StickyTool` overrides `onPointerUp` so that, in addition to completing the current session, the it also sets the state's `editingId` to the new sticky shape. 10 | 11 | ## Enter and Exit Methods 12 | 13 | When the state changes from one tool to another, it will: 14 | 15 | 1. run the previous tool's `onExit` method 16 | 2. switch to the new tool 17 | 3. run the new current tool's `onEnter` method 18 | 19 | Each tool has a status (`status`) that may be set with `setStatus`. 20 | -------------------------------------------------------------------------------- /packages/tldraw/src/state/tools/index.ts: -------------------------------------------------------------------------------- 1 | import { TDShapeType, TDToolType } from '~types' 2 | import { ArrowTool } from './ArrowTool' 3 | import { LineTool } from './LineTool' 4 | import { DrawTool } from './DrawTool' 5 | import { EllipseTool } from './EllipseTool' 6 | import { RectangleTool } from './RectangleTool' 7 | import { TriangleTool } from './TriangleTool' 8 | import { SelectTool } from './SelectTool' 9 | import { StickyTool } from './StickyTool' 10 | import { TextTool } from './TextTool' 11 | import { EraseTool } from './EraseTool' 12 | 13 | export interface ToolsMap { 14 | select: typeof SelectTool 15 | erase: typeof EraseTool 16 | [TDShapeType.Text]: typeof TextTool 17 | [TDShapeType.Draw]: typeof DrawTool 18 | [TDShapeType.Ellipse]: typeof EllipseTool 19 | [TDShapeType.Rectangle]: typeof RectangleTool 20 | [TDShapeType.Triangle]: typeof TriangleTool 21 | [TDShapeType.Line]: typeof LineTool 22 | [TDShapeType.Arrow]: typeof ArrowTool 23 | [TDShapeType.Sticky]: typeof StickyTool 24 | } 25 | 26 | export type ToolOfType = ToolsMap[K] 27 | 28 | export type ArgsOfType = ConstructorParameters> 29 | 30 | export const tools: { [K in TDToolType]: ToolsMap[K] } = { 31 | select: SelectTool, 32 | erase: EraseTool, 33 | [TDShapeType.Text]: TextTool, 34 | [TDShapeType.Draw]: DrawTool, 35 | [TDShapeType.Ellipse]: EllipseTool, 36 | [TDShapeType.Rectangle]: RectangleTool, 37 | [TDShapeType.Triangle]: TriangleTool, 38 | [TDShapeType.Line]: LineTool, 39 | [TDShapeType.Arrow]: ArrowTool, 40 | [TDShapeType.Sticky]: StickyTool, 41 | } 42 | -------------------------------------------------------------------------------- /packages/tldraw/src/styles/index.ts: -------------------------------------------------------------------------------- 1 | export * from './stitches.config' 2 | -------------------------------------------------------------------------------- /packages/tldraw/src/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mockDocument' 2 | export * from './renderWithContext' 3 | export * from './TldrawTestApp' 4 | -------------------------------------------------------------------------------- /packages/tldraw/src/test/mockDocument.tsx: -------------------------------------------------------------------------------- 1 | import { TDDocument, ColorStyle, DashStyle, SizeStyle, TDShapeType } from '~types' 2 | 3 | export const mockDocument: TDDocument = { 4 | version: 0, 5 | id: 'doc', 6 | name: 'New Document', 7 | pages: { 8 | page1: { 9 | id: 'page1', 10 | shapes: { 11 | rect1: { 12 | id: 'rect1', 13 | parentId: 'page1', 14 | name: 'Rectangle', 15 | childIndex: 1, 16 | type: TDShapeType.Rectangle, 17 | point: [0, 0], 18 | size: [100, 100], 19 | style: { 20 | dash: DashStyle.Draw, 21 | size: SizeStyle.Medium, 22 | color: ColorStyle.Blue, 23 | }, 24 | label: '', 25 | }, 26 | rect2: { 27 | id: 'rect2', 28 | parentId: 'page1', 29 | name: 'Rectangle', 30 | childIndex: 2, 31 | type: TDShapeType.Rectangle, 32 | point: [100, 100], 33 | size: [100, 100], 34 | style: { 35 | dash: DashStyle.Draw, 36 | size: SizeStyle.Medium, 37 | color: ColorStyle.Blue, 38 | }, 39 | label: '', 40 | labelPoint: [0.5, 0.5], 41 | }, 42 | rect3: { 43 | id: 'rect3', 44 | parentId: 'page1', 45 | name: 'Rectangle', 46 | childIndex: 3, 47 | type: TDShapeType.Rectangle, 48 | point: [20, 20], 49 | size: [100, 100], 50 | style: { 51 | dash: DashStyle.Draw, 52 | size: SizeStyle.Medium, 53 | color: ColorStyle.Blue, 54 | }, 55 | label: '', 56 | labelPoint: [0.5, 0.5], 57 | }, 58 | }, 59 | bindings: {}, 60 | }, 61 | }, 62 | pageStates: { 63 | page1: { 64 | id: 'page1', 65 | selectedIds: [], 66 | camera: { 67 | point: [0, 0], 68 | zoom: 1, 69 | }, 70 | }, 71 | }, 72 | assets: {}, 73 | } 74 | -------------------------------------------------------------------------------- /packages/tldraw/src/test/renderWithContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { TldrawApp } from '~state' 3 | import { useKeyboardShortcuts, TldrawContext } from '~hooks' 4 | import { mockDocument } from './mockDocument' 5 | import { render } from '@testing-library/react' 6 | 7 | export const Wrapper: React.FC = ({ children }) => { 8 | const [app] = React.useState(() => new TldrawApp()) 9 | const [context] = React.useState(() => { 10 | return app 11 | }) 12 | 13 | const rWrapper = React.useRef(null) 14 | 15 | useKeyboardShortcuts(rWrapper) 16 | 17 | React.useEffect(() => { 18 | if (!document) return 19 | app.loadDocument(mockDocument) 20 | }, [document, app]) 21 | 22 | return ( 23 | 24 |
{children}
25 |
26 | ) 27 | } 28 | 29 | export const renderWithContext = (children: JSX.Element) => { 30 | return render({children}) 31 | } 32 | -------------------------------------------------------------------------------- /packages/tldraw/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "**/*.test.tsx", 6 | "**/*.test.ts", 7 | "**/*.spec.tsx", 8 | "**/*.spec.ts", 9 | "src/test", 10 | "dist", 11 | "docs" 12 | ], 13 | "compilerOptions": { 14 | "skipLibCheck": true, 15 | "composite": false, 16 | "incremental": false, 17 | "declaration": true, 18 | "declarationMap": true, 19 | "sourceMap": true 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /packages/tldraw/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "exclude": ["node_modules", "dist", "docs"], 4 | "include": ["src"], 5 | "compilerOptions": { 6 | "outDir": "./dist/types", 7 | "rootDir": "src", 8 | "baseUrl": ".", 9 | "paths": { 10 | "~*": ["./src/*"] 11 | } 12 | }, 13 | "typedocOptions": { 14 | "entryPoints": ["src/index.ts"], 15 | "out": "docs" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /screenshots/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimeshnayaju/tldrawe/c96676b75bebdf30aa2f9db01fa7f4af4e594099/screenshots/screenshot.png -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "declaration": true, 5 | "declarationMap": false, 6 | "sourceMap": false, 7 | "emitDeclarationOnly": true, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "experimentalDecorators": true, 11 | "forceConsistentCasingInFileNames": false, 12 | "importHelpers": true, 13 | "importsNotUsedAsValues": "error", 14 | "resolveJsonModule": true, 15 | "incremental": true, 16 | "jsx": "preserve", 17 | "lib": ["dom", "esnext"], 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "noFallthroughCasesInSwitch": true, 21 | "noImplicitAny": true, 22 | "noImplicitReturns": true, 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "skipLibCheck": true, 26 | "strict": true, 27 | "strictFunctionTypes": true, 28 | "strictNullChecks": true, 29 | "stripInternal": true, 30 | "target": "es6", 31 | "typeRoots": ["node_modules/@types", "node_modules/jest"], 32 | "types": ["node", "jest", "@testing-library/jest-dom", "@testing-library/react"] 33 | } 34 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"] 4 | } --------------------------------------------------------------------------------