snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

6
apps/vscode/extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
dist
editor
node_modules
.vscode-test-web/
*.vsix
.DS_Store

View File

@@ -0,0 +1,5 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": ["amodio.tsl-problem-matcher"]
}

View File

@@ -0,0 +1,23 @@
// A launch configuration that compiles the extension and then opens it inside a new window
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Web Extension ",
"type": "extensionHost",
"request": "launch",
"cwd": "${workspaceFolder}",
"args": [
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}/examples"
],
"outFiles": ["${workspaceFolder}/dist/web/**/*.js", "!**/node_modules/**"],
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
"sourceMaps": true
}
]
}

View File

@@ -0,0 +1,11 @@
// Place your settings in this file to overwrite default and user settings.
{
"files.exclude": {
"out": false // set this to true to hide the "out" folder with the compiled JS files
},
"search.exclude": {
"out": true // set this to false to include "out" folder in search results
},
// Turn off tsc task auto detection since we have the necessary tasks as npm scripts
"typescript.tsc.autoDetect": "off"
}

View File

View File

@@ -0,0 +1 @@
See [tldraw.dev](https://tldraw.dev/releases) for release notes.

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 tldraw Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,56 @@
## Introduction
[tldraw](https://tldraw.com) is a very good whiteboard for the web. This extension will let you use tldraw inside of VS Code, using offline `.tldr` files. Create drawings, diagrams, or visual documentation, and then commit the files alongside your code!
![A screenshot of tldraw in VS Code](https://assets.tldraw.xyz/uploads/6Jnp8CCAr7gn2To06NF8r-vscode-screenshot.png)
## Usage
- To create a new tldraw file, use the provided command: "tldraw: New tldraw File".
![A recording of opening tldraw in VS Code](https://assets.tldraw.xyz/uploads/vx7dEFbx2oURPB3vw8x0E-vscode-recording.gif)
- To view an existing tldraw file, open a file with the `.tldr` extension in VS Code.
> **Tip:** The `.tldr` files you create with this extension can also be opened in the tldraw [web app](https://tldraw.com). You can also use this extension to open `.tldr` files saved from the web app.
## Releases
This extension is automatically released to the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=tldraw-org.tldraw-vscode) when changes are merged:
- **Pre-releases**: Merges to `main` branch trigger pre-release builds
- **Production releases**: Merges to `production` branch trigger stable releases
### Direct Downloads
You can also download the extension directly from our repository:
- **Latest Production Release**: [tldraw-vscode.vsix](https://github.com/tldraw/tldraw/blob/production/apps/vscode/extension/release/tldraw-vscode.vsix) (from `production` branch)
To install from a `.vsix` file:
1. Download the file from the link above
2. Open VS Code
3. Go to Extensions (Ctrl+Shift+X)
4. Click the "..." menu and select "Install from VSIX..."
5. Select the downloaded `tldraw-vscode.vsix` file
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## License
This project is provided under the MIT license found [here](https://github.com/tldraw/tldraw/blob/main/apps/vscode/extension/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,168 @@
{
"fileHandle": null,
"document": {
"id": "doc",
"name": "New Document",
"version": 15.3,
"pages": {
"page": {
"id": "page",
"name": "Page 1",
"childIndex": 1,
"shapes": {
"3805a6e3-b50a-4bb2-0782-e687526cafc8": {
"id": "3805a6e3-b50a-4bb2-0782-e687526cafc8",
"type": "rectangle",
"name": "Rectangle",
"parentId": "page",
"childIndex": 1,
"point": [
245.96,
277.88
],
"size": [
292.43,
125.76
],
"rotation": 0,
"style": {
"color": "white",
"size": "small",
"isFilled": false,
"dash": "draw"
},
"label": "Rectangles with labels"
},
"9a20ffa8-044b-4ca3-0448-632e2b2831d1": {
"id": "9a20ffa8-044b-4ca3-0448-632e2b2831d1",
"type": "ellipse",
"name": "Ellipse",
"parentId": "page",
"childIndex": 3,
"point": [
995.24,
235.11
],
"radius": [
112.92500000000001,
108.96000000000004
],
"rotation": 0,
"style": {
"color": "white",
"size": "small",
"isFilled": true,
"dash": "draw",
"scale": 1
},
"label": "Ovals with labels",
"labelPoint": [
0.5,
0.5
]
},
"c2125c4e-e9ef-4eeb-2011-9e8aaeadd6ea": {
"id": "c2125c4e-e9ef-4eeb-2011-9e8aaeadd6ea",
"type": "arrow",
"name": "Arrow",
"parentId": "page",
"childIndex": 4,
"point": [
538.39,
341.56
],
"rotation": 0,
"bend": -0.000013369615345367926,
"handles": {
"start": {
"id": "start",
"index": 0,
"point": [
0,
0
],
"canBind": true,
"bindingId": "31007b26-4667-4932-00aa-7f389642b187"
},
"end": {
"id": "end",
"index": 1,
"point": [
448.85,
2.44
],
"canBind": true,
"bindingId": "7b099540-f8b1-4bf3-0d7d-2b74df370067"
},
"bend": {
"id": "bend",
"index": 2,
"point": [
224.43,
1.22
]
}
},
"decorations": {
"end": "arrow"
},
"style": {
"color": "white",
"size": "small",
"isFilled": false,
"dash": "draw",
"scale": 1
},
"label": "Arrows with labels",
"labelPoint": [
0.5,
0.5
]
}
},
"bindings": {
"7b099540-f8b1-4bf3-0d7d-2b74df370067": {
"id": "7b099540-f8b1-4bf3-0d7d-2b74df370067",
"type": "arrow",
"fromId": "c2125c4e-e9ef-4eeb-2011-9e8aaeadd6ea",
"toId": "9a20ffa8-044b-4ca3-0448-632e2b2831d1",
"handleId": "end",
"point": [
0.08,
0.5
],
"distance": 8
},
"31007b26-4667-4932-00aa-7f389642b187": {
"id": "31007b26-4667-4932-00aa-7f389642b187",
"type": "arrow",
"fromId": "c2125c4e-e9ef-4eeb-2011-9e8aaeadd6ea",
"toId": "3805a6e3-b50a-4bb2-0782-e687526cafc8",
"handleId": "start",
"point": [
0.5,
0.5
],
"distance": 16
}
}
}
},
"pageStates": {
"page": {
"id": "page",
"selectedIds": [],
"camera": {
"point": [
0,
0
],
"zoom": 1
},
"editingId": null
}
},
"assets": {}
},
"assets": {}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
{"tldrawFileFormatVersion":1,"schema":{"schemaVersion":1,"storeVersion":1,"recordVersions":{"asset":{"version":0,"subTypeKey":"type","subTypeVersions":{"image":2,"video":2,"bookmark":0}},"camera":{"version":0},"document":{"version":0},"instance":{"version":5},"instance_page_state":{"version":0},"page":{"version":0},"shape":{"version":1,"subTypeKey":"type","subTypeVersions":{"draw":1,"text":0,"line":0,"arrow":1,"image":1,"video":1,"geo":2,"note":1,"group":0,"bookmark":1,"embed":1,"frame":0}},"user":{"version":0},"user_document":{"version":2},"user_presence":{"version":0}}},"records":[{"x":0,"y":0,"z":1,"id":"camera:ZEdonVzBw8UQNxHVSfYeN","typeName":"camera"},{"gridSize":10,"id":"document:document","typeName":"document"},{"propsForNextShape":{"opacity":"1","color":"black","labelColor":"black","dash":"draw","fill":"none","size":"m","icon":"file","font":"draw","align":"middle","geo":"rectangle","arrowheadStart":"none","arrowheadEnd":"arrow","spline":"line"},"brush":null,"scribble":null,"cursor":{"type":"default","color":"black","rotation":0},"isFocusMode":false,"exportBackground":true,"isDebugMode":true,"isToolLocked":false,"screenBounds":{"x":0,"y":0,"w":1016,"h":1308},"id":"instance:9NyQ7QvIoscZrrjfaLlw3","userId":"user:THIgzt9P6hTGullzaOPpd","currentPageId":"page:DldMYGg34nlqr9pTjmOaV","typeName":"instance"},{"editingId":null,"selectedIds":[],"hoveredId":null,"erasingIds":[],"hintingIds":[],"focusLayerId":null,"id":"instance_page_state:l05WL5JCaWeamtf3xX-rR","pageId":"page:DldMYGg34nlqr9pTjmOaV","instanceId":"instance:9NyQ7QvIoscZrrjfaLlw3","cameraId":"camera:ZEdonVzBw8UQNxHVSfYeN","typeName":"instance_page_state"},{"id":"page:DldMYGg34nlqr9pTjmOaV","name":"Page 1","index":"a1","typeName":"page"},{"name":"New User","locale":"en","id":"user:THIgzt9P6hTGullzaOPpd","typeName":"user"},{"isReadOnly":false,"isPenMode":false,"isGridMode":false,"isDarkMode":false,"isMobileMode":false,"isSnapMode":false,"lastUpdatedPageId":"page:DldMYGg34nlqr9pTjmOaV","lastUsedTabId":"instance:Bgu8sMzoEjBFOltNtawzR","id":"user_document:m48KwWKytfJTnajCKC4Ci","userId":"user:THIgzt9P6hTGullzaOPpd","typeName":"user_document"},{"lastUsedInstanceId":"instance:Bgu8sMzoEjBFOltNtawzR","lastActivityTimestamp":1679326918108,"cursor":{"x":1057.125,"y":439.03125,"z":0},"color":"#39B178","id":"user_presence:mWjDj8J3KWsxO9FST9mJY","userId":"user:THIgzt9P6hTGullzaOPpd","typeName":"user_presence"},{"propsForNextShape":{"opacity":"1","color":"black","labelColor":"black","dash":"draw","fill":"none","size":"m","icon":"file","font":"draw","align":"middle","geo":"rectangle","arrowheadStart":"none","arrowheadEnd":"arrow","spline":"line"},"brush":null,"scribble":null,"cursor":{"type":"default","color":"black","rotation":0},"isFocusMode":false,"exportBackground":true,"isDebugMode":true,"isToolLocked":false,"screenBounds":{"x":0,"y":0,"w":1016,"h":1308},"id":"instance:qjCFl-gVEHZV6qnavaI5A","userId":"user:THIgzt9P6hTGullzaOPpd","currentPageId":"page:DldMYGg34nlqr9pTjmOaV","typeName":"instance"},{"x":0,"y":0,"z":1,"id":"camera:kDPjsRu3p8aoVfzWbNISY","typeName":"camera"},{"editingId":null,"selectedIds":[],"hoveredId":null,"erasingIds":[],"hintingIds":[],"focusLayerId":null,"id":"instance_page_state:80sg16r6TLoBdklDRg4px","pageId":"page:DldMYGg34nlqr9pTjmOaV","instanceId":"instance:qjCFl-gVEHZV6qnavaI5A","cameraId":"camera:kDPjsRu3p8aoVfzWbNISY","typeName":"instance_page_state"},{"propsForNextShape":{"opacity":"1","color":"black","labelColor":"black","dash":"draw","fill":"none","size":"m","icon":"file","font":"draw","align":"middle","geo":"rectangle","arrowheadStart":"none","arrowheadEnd":"arrow","spline":"line"},"brush":null,"scribble":null,"cursor":{"type":"default","color":"black","rotation":0},"isFocusMode":false,"exportBackground":true,"isDebugMode":true,"isToolLocked":false,"screenBounds":{"x":0,"y":0,"w":1223,"h":1308},"id":"instance:Bgu8sMzoEjBFOltNtawzR","userId":"user:THIgzt9P6hTGullzaOPpd","currentPageId":"page:DldMYGg34nlqr9pTjmOaV","typeName":"instance"},{"x":0,"y":0,"z":1,"id":"camera:6grbCQvcD63BvbkYvT_zD","typeName":"camera"},{"editingId":null,"selectedIds":["shape:qzz1TZBX_0FfNiKrwE8pI"],"hoveredId":null,"erasingIds":[],"hintingIds":[],"focusLayerId":null,"id":"instance_page_state:VIw9Cdw1hWs5ABt9Bpl8U","pageId":"page:DldMYGg34nlqr9pTjmOaV","instanceId":"instance:Bgu8sMzoEjBFOltNtawzR","cameraId":"camera:6grbCQvcD63BvbkYvT_zD","typeName":"instance_page_state"},{"x":454.046875,"y":384.203125,"rotation":0,"isLocked":false,"id":"shape:qzz1TZBX_0FfNiKrwE8pI","type":"geo","props":{"w":97.83984375,"h":103.23828125,"geo":"rectangle","color":"black","labelColor":"black","fill":"none","dash":"draw","size":"m","opacity":"1","font":"draw","text":"","align":"middle","growY":0,"url":""},"parentId":"page:DldMYGg34nlqr9pTjmOaV","index":"a1","typeName":"unknown-type"}]}

View File

@@ -0,0 +1 @@
This is not a valid tldr file.

View File

@@ -0,0 +1 @@
{"tldrawFileFormatVersion":1000,"schema":{"schemaVersion":1,"storeVersion":1,"recordVersions":{"asset":{"version":0,"subTypeKey":"type","subTypeVersions":{"image":2,"video":2,"bookmark":0}},"camera":{"version":0},"document":{"version":0},"instance":{"version":5},"instance_page_state":{"version":0},"page":{"version":0},"user":{"version":0},"user_document":{"version":2},"user_presence":{"version":0},"shape":{"version":1,"subTypeKey":"type","subTypeVersions":{"draw":1,"text":0,"line":0,"arrow":1,"image":1,"video":1,"geo":2,"note":1,"group":0,"bookmark":1,"embed":1,"frame":0}}}},"records":[{"gridSize":10,"id":"document:document","typeName":"document"},{"isReadOnly":false,"isPenMode":false,"isGridMode":false,"isDarkMode":false,"isMobileMode":false,"isSnapMode":false,"lastUpdatedPageId":"page:wTYNt8AJ-CAe-ozU4bYw0","lastUsedTabId":"instance:H7Ghe-kI0V36JYl-FtXV6","id":"user_document:384AkIr8uUukpLs33G6TO","userId":"user:P_x7HuwQNs0er568NA5nt","typeName":"user_document"},{"id":"page:wTYNt8AJ-CAe-ozU4bYw0","name":"Page 1","index":"a1","typeName":"page"},{"propsForNextShape":{"opacity":"1","color":"black","labelColor":"black","dash":"draw","fill":"none","size":"m","icon":"file","font":"draw","align":"middle","geo":"rectangle","arrowheadStart":"none","arrowheadEnd":"arrow","spline":"line"},"brush":null,"scribble":null,"cursor":{"type":"default","color":"black","rotation":0},"isFocusMode":false,"exportBackground":true,"isDebugMode":false,"isToolLocked":false,"screenBounds":{"x":0,"y":0,"w":1920,"h":1093},"id":"instance:H7Ghe-kI0V36JYl-FtXV6","userId":"user:P_x7HuwQNs0er568NA5nt","currentPageId":"page:wTYNt8AJ-CAe-ozU4bYw0","typeName":"instance"},{"name":"New User","locale":"en","id":"user:P_x7HuwQNs0er568NA5nt","typeName":"user"},{"lastUsedInstanceId":"instance:H7Ghe-kI0V36JYl-FtXV6","lastActivityTimestamp":1679326696474,"cursor":{"x":1218.24609375,"y":608.05859375,"z":0},"color":"#55B467","id":"user_presence:mao_e5ejuI4IdRbJPALvk","userId":"user:P_x7HuwQNs0er568NA5nt","typeName":"user_presence"},{"x":0,"y":0,"z":1,"id":"camera:EJQijEXfj0inf8U-V-cUp","typeName":"camera"},{"editingId":null,"selectedIds":["shape:is_pvm9FlmAIZ2IYg-9jp"],"hoveredId":null,"erasingIds":[],"hintingIds":[],"focusLayerId":null,"id":"instance_page_state:xcQsboc8bb8EOAh_0wA2u","pageId":"page:wTYNt8AJ-CAe-ozU4bYw0","instanceId":"instance:H7Ghe-kI0V36JYl-FtXV6","cameraId":"camera:EJQijEXfj0inf8U-V-cUp","typeName":"instance_page_state"},{"x":696.625,"y":390.11328125,"rotation":0,"isLocked":false,"id":"shape:is_pvm9FlmAIZ2IYg-9jp","type":"geo","props":{"w":174.56640625,"h":209.875,"geo":"rectangle","color":"black","labelColor":"black","fill":"none","dash":"draw","size":"m","opacity":"1","font":"draw","text":"","align":"middle","growY":0,"url":""},"parentId":"page:wTYNt8AJ-CAe-ozU4bYw0","index":"a1","typeName":"shape"}]}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -0,0 +1,180 @@
{
"name": "tldraw-vscode",
"description": "The tldraw extension for VS Code.",
"version": "2.0.36",
"private": true,
"author": {
"name": "tldraw Inc.",
"email": "hello@tldraw.com"
},
"license": "MIT",
"homepage": "https://tldraw.dev",
"repository": {
"type": "git",
"url": "https://github.com/tldraw/tldraw"
},
"bugs": {
"url": "https://github.com/tldraw/tldraw/issues"
},
"keywords": [
"tldraw",
"sdk",
"drawing",
"app",
"development",
"whiteboard",
"canvas",
"infinite"
],
"displayName": "tldraw",
"publisher": "tldraw-org",
"icon": "icon.png",
"galleryBanner": {
"color": "#1d1d1d",
"theme": "dark"
},
"categories": [
"Visualization"
],
"engines": {
"vscode": "^1.96.0"
},
"activationEvents": [],
"browser": "./dist/web/extension.js",
"main": "./dist/web/extension.js",
"extensionKind": [
"workspace"
],
"contributes": {
"customEditors": [
{
"viewType": "tldraw.tldr",
"displayName": "tldraw",
"selector": [
{
"filenamePattern": "*.tldr"
},
{
"filenamePattern": "*.tldr.json"
}
]
},
{
"viewType": "tldraw.tldraw-offline",
"displayName": "tldraw",
"selector": [
{
"filenamePattern": "*.tldraw"
}
]
}
],
"keybindings": [
{
"key": "ctrl+shift+d",
"mac": "cmd+shift+d",
"title": "Toggle Dark Mode",
"command": "tldraw.tldr.toggleDarkMode",
"category": "tldraw",
"when": "resourceExtname == .tldr"
},
{
"key": "ctrl+numpad_add",
"mac": "cmd+numpad_add",
"title": "Zoom In",
"command": "tldraw.tldr.zoomIn",
"category": "tldraw",
"when": "resourceExtname == .tldr"
},
{
"key": "ctrl+=",
"mac": "cmd+=",
"title": "Zoom In",
"command": "tldraw.tldr.zoomIn",
"category": "tldraw",
"when": "resourceExtname == .tldr"
},
{
"key": "ctrl+numpad_subtract",
"mac": "cmd+numpad_subtract",
"title": "Zoom Out",
"command": "tldraw.tldr.zoomOut",
"category": "tldraw",
"when": "resourceExtname == .tldr"
},
{
"key": "ctrl+-",
"mac": "cmd+-",
"title": "Zoom Out",
"command": "tldraw.tldr.zoomOut",
"category": "tldraw",
"when": "resourceExtname == .tldr"
},
{
"key": "ctrl+numpad0",
"mac": "cmd+numpad0",
"title": "Reset Zoom",
"command": "tldraw.tldr.resetZoom",
"category": "tldraw",
"when": "resourceExtname == .tldr"
}
],
"commands": [
{
"command": "tldraw.tldr.new",
"title": "New Project",
"category": "tldraw"
}
]
},
"vsce": {
"dependencies": false
},
"scripts": {
"dev": "tsx scripts/dev.ts",
"build": "cd ../editor && yarn build && cd ../extension && tsx scripts/build.ts",
"get-info": "vsce show tldraw-org.tldraw-vscode --json > extension.json",
"package": "yarn build && tsx scripts/package.ts",
"publish": "tsx scripts/publish.ts",
"lint": "yarn run -T tsx ../../../internal/scripts/lint.ts",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist && rm -rf editor && rm -rf temp & yarn",
"test": "yarn run -T vitest --passWithNoTests",
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests"
},
"devDependencies": {
"@tldraw/editor": "workspace:*",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.14",
"@types/node-fetch": "^2.6.12",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/vscode": "^1.96.0",
"assert": "^2.1.0",
"esbuild": "^0.28.0",
"fs-extra": "^11.3.0",
"lazyrepo": "0.0.0-alpha.27",
"lodash": "^4.17.21",
"mocha": "^9.2.2",
"ovsx": "^0.10.7",
"process": "^0.11.10",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tldraw": "workspace:*",
"ts-loader": "^9.5.2",
"tslib": "^2.8.1",
"tsx": "^4.19.2",
"typescript": "^7.0.2",
"vsce": "^2.15.0",
"webpack": "^5.97.1"
},
"gitHead": "4b1137849ad07da36fc8f0f19cb64e7535a79296",
"dependencies": {
"cheerio": "^1.0.0",
"node-fetch": "^2.7.0"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.2.1",
"react-dom": "^18.2.0 || ^19.2.1"
}
}

View File

@@ -0,0 +1,42 @@
import esbuild from 'esbuild'
import { logEnv } from './cli'
import { copyEditor, removeDistDirectory } from './helpers'
const log = logEnv('extension')
async function build() {
await copyEditor({ log })
await removeDistDirectory({ log })
try {
const entryPoints = ['./src/extension.ts']
log({ cmd: 'esbuild', args: { entryPoints } })
await esbuild.build({
entryPoints,
outdir: 'dist/web',
minify: false,
bundle: true,
format: 'cjs',
target: 'es6',
platform: 'node',
define: {
'process.env.NODE_ENV': '"production"',
},
tsconfig: './tsconfig.json',
external: ['vscode'],
loader: {
'.woff2': 'dataurl',
'.woff': 'dataurl',
'.svg': 'file',
'.png': 'file',
'.json': 'file',
},
})
log({ cmd: 'esbuild:success', args: { entryPoints } })
} catch (error) {
log({ cmd: 'esbuild:error', args: { error } })
throw error
}
}
build()

View File

@@ -0,0 +1,58 @@
import path from 'path'
const displayRelative = (from: string, to: string) => {
const outpath = path.relative(from, to)
if (!outpath.match(/^\./)) {
return `./${outpath}`
}
return outpath
}
type LogDef =
| { cmd: 'remove'; env: string; args: { target: string } }
| { cmd: 'copy'; env: string; args: { source: string; dest: string } }
| { cmd: 'esbuild'; env: string; args: { entryPoints: string[] } }
| { cmd: 'esbuild:success'; env: string; args: any }
| { cmd: 'esbuild:error'; env: string; args: { error: string } }
| { cmd: 'esbuild:serve'; env: string; args: { host: string; port: number | string } }
export function log(def: LogDef) {
const printStderr = (icon: string, cmd: string, ...args: unknown[]) => {
console.error(`${icon} [${def.env ?? 'unknown'}/${cmd}]`, ...args)
}
if (def.cmd === 'remove') {
const { target } = def.args
printStderr('🗑 ', 'remove', displayRelative(process.cwd(), target))
} else if (def.cmd === 'copy') {
const { source, dest } = def.args
printStderr(
'🏠',
'copy',
`${displayRelative(process.cwd(), source)} -> ${displayRelative(process.cwd(), dest)}`
)
} else if (def.cmd === 'esbuild') {
printStderr(
'🤖',
'esbuild',
`${def.args.entryPoints.map((pathname) => displayRelative(process.cwd(), pathname))}`
)
} else if (def.cmd === 'esbuild:success') {
printStderr('✅', `esbuild`, `build successful (${new Date().toISOString()})`)
} else if (def.cmd === 'esbuild:error') {
printStderr(`❌`, `esbuild`, `error`)
console.error(def.args.error)
} else if (def.cmd === 'esbuild:serve') {
const { host = 'localhost', port } = def.args
printStderr(`🌎`, `esbuild`, `serving <http://${host}:${port}>`)
} else {
// @ts-ignore
printStderr(`❓`, def.cmd, JSON.stringify(def.args))
}
}
export function logEnv(env: string) {
return (opts: any) => {
log({ ...opts, env })
}
}

View File

@@ -0,0 +1,60 @@
import { join } from 'path'
import esbuild from 'esbuild'
import { logEnv } from './cli'
import { copyEditor, removeDistDirectory } from './helpers'
import { getDirname } from './path'
const rootDir = getDirname(import.meta.url, '../')
const log = logEnv('extension')
async function dev() {
await copyEditor({ log })
await removeDistDirectory({ log })
const entryPoints = [join(rootDir, 'src', 'extension.ts')]
log({ cmd: 'esbuild', args: { entryPoints } })
try {
const builder = await esbuild.context({
entryPoints,
outdir: join(rootDir, 'dist', 'web'),
minify: false,
bundle: true,
format: 'cjs',
target: 'es6',
sourcemap: 'inline',
platform: 'node',
define: {
'process.env.NODE_ENV': '"development"',
},
tsconfig: './tsconfig.json',
external: ['vscode'],
plugins: [
{
name: 'log-builds',
setup(build) {
build.onEnd((result) => {
if (result.errors.length) {
log({ cmd: 'esbuild:error', args: { err: result.errors } })
} else {
copyEditor({ log })
log({ cmd: 'esbuild:success', args: {} })
}
})
},
},
],
loader: {
'.woff2': 'dataurl',
'.woff': 'dataurl',
'.svg': 'file',
'.png': 'file',
'.json': 'file',
},
})
await builder.watch()
} catch (error) {
log({ cmd: 'esbuild:error', args: { error } })
throw error
}
}
dev()

View File

@@ -0,0 +1,25 @@
import fs from 'fs'
import { join } from 'path'
import fse from 'fs-extra'
import { exists, getDirname } from './path'
const vscodeDir = getDirname(import.meta.url, '../../')
export async function copyEditor({ log }: { log(opts: any): void }) {
const editorRoot = join(vscodeDir, 'editor')
const extensionRoot = join(vscodeDir, 'extension')
const source = join(editorRoot, 'dist')
const dest = join(extensionRoot, 'editor')
log({ cmd: 'copy', args: { source, dest } })
await fse.copy(source, dest)
}
export async function removeDistDirectory({ log }: { log(opts: any): void }) {
const target = join(vscodeDir, 'extension', 'dist')
if (await exists(target)) {
log({ cmd: 'remove', args: { target } })
await fs.promises.rm(target, { recursive: true })
}
}

View File

@@ -0,0 +1,38 @@
import { exec } from 'child_process'
import * as fs from 'fs'
import * as path from 'path'
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'))
const { log: jslog } = console
async function main() {
if (fs.existsSync('./editor')) {
fs.rmSync('./editor', { recursive: true })
}
if (fs.existsSync('./temp')) {
fs.rmSync('./temp', { recursive: true })
}
fs.mkdirSync('./temp')
const preRelease = process.argv.includes('--pre-release')
try {
exec(
`cp -r ../editor/dist editor; vsce package${preRelease ? ' --pre-release' : ''}; mv ${pkg.name}-${pkg.version}.vsix ${'./temp'}`,
(error, stdout, stderr) => {
if (error) {
throw new Error(error.message)
}
if (stderr && !/^(warning|\(node:)/.test(stderr)) {
throw new Error(stderr)
}
}
)
} catch (e) {
jslog(`× ${pkg.name}: Build failed due to an error.`)
jslog(e)
}
}
main()

View File

@@ -0,0 +1,16 @@
import fs from 'fs'
import path from 'path'
export function getDirname(metaUrl: string, targetPath: string) {
const dirname = path.dirname(metaUrl.replace('file://', ''))
return path.normalize(path.join(dirname, targetPath))
}
export async function exists(targetFolder: string) {
try {
await fs.promises.access(targetFolder)
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,82 @@
import { exec } from 'child_process'
import { readdirSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
const execAsync = promisify(exec)
const MAX_REGISTRY_PUBLISH_ATTEMPTS = 3
const REGISTRY_PUBLISH_RETRY_DELAY_MS = 10_000
class AmbiguousPublishError extends Error {}
async function publishWithRetry(command: string) {
for (let attempt = 1; attempt <= MAX_REGISTRY_PUBLISH_ATTEMPTS; attempt++) {
try {
await execAsync(command)
return
} catch (err) {
const error = err as Error & { stdout?: string; stderr?: string }
if (
[error.message, error.stdout, error.stderr].some((text) => text?.includes('already exists'))
) {
if (attempt > 1) {
throw new AmbiguousPublishError('An earlier publish attempt may have succeeded.', {
cause: err,
})
}
throw err
}
if (attempt === MAX_REGISTRY_PUBLISH_ATTEMPTS) {
throw err
}
console.error(
`Publish attempt ${attempt}/${MAX_REGISTRY_PUBLISH_ATTEMPTS} failed; retrying in ${REGISTRY_PUBLISH_RETRY_DELAY_MS / 1000}s...`,
err
)
await new Promise((resolve) => setTimeout(resolve, REGISTRY_PUBLISH_RETRY_DELAY_MS))
}
}
}
function getVsixPath(): string {
const tempDir = join(__dirname, '../temp')
const files = readdirSync(tempDir)
const vsixFile = files.find((file) => file.endsWith('.vsix'))
if (!vsixFile) {
throw new Error('No .vsix file found in temp directory')
}
return join(tempDir, vsixFile)
}
async function publishToVSCodeMarketplace(preRelease: boolean) {
// eslint-disable-next-line no-console
console.log(`Publishing to VS Code Marketplace${preRelease ? ' (pre-release)' : ''}`)
await publishWithRetry(`vsce publish${preRelease ? ' --pre-release' : ''}`)
// eslint-disable-next-line no-console
console.log('Successfully published to VS Code Marketplace')
}
async function publishToOpenVSX(preRelease: boolean) {
const vsixPath = getVsixPath()
// eslint-disable-next-line no-console
console.log('Publishing to Open VSX...')
// An upload can succeed even if its response is lost; retrying must accept that version.
await publishWithRetry(
`npx ovsx publish --skip-duplicate${preRelease ? ' --pre-release' : ''} ${vsixPath}`
)
// eslint-disable-next-line no-console
console.log('Successfully published to Open VSX')
}
async function main() {
const preRelease = process.argv.includes('--pre-release')
await publishToVSCodeMarketplace(preRelease)
await publishToOpenVSX(preRelease)
}
main().catch((err) => {
console.error(err)
// The outer publisher must not interpret the duplicate in the cause as a version conflict.
process.exit(err instanceof AmbiguousPublishError ? 75 : 1)
})

View File

@@ -0,0 +1,181 @@
import { TldrawFile } from 'tldraw'
import * as vscode from 'vscode'
import { defaultFileContents, fileExists, loadFile } from './file'
import { nicelog } from './utils'
export type DocumentChangeEventArgs =
| { reason: 'undo' | 'redo' }
| {
reason: 'revert'
fileContents: TldrawFile
}
export class TLDrawDocument implements vscode.CustomDocument {
isBlankDocument = false
lastBackupDestination: vscode.Uri | undefined
private readonly _onDidChangeDocument = new vscode.EventEmitter<DocumentChangeEventArgs>()
/** Fired to notify webviews that the document has changed. */
public readonly onDidChangeContent = this._onDidChangeDocument.event
private readonly _onDidChange = new vscode.EventEmitter<{
readonly label: string
undo(): void
redo(): void
}>()
public readonly onDidChange = this._onDidChange.event
private readonly _onDidDispose = new vscode.EventEmitter<void>()
/** Fired when the document is disposed of. */
public readonly onDidDispose = this._onDidDispose.event
private disposables: vscode.Disposable[] = [
this._onDidChange,
this._onDidChangeDocument,
this._onDidDispose,
]
private constructor(
public uri: vscode.Uri,
public documentData: TldrawFile,
backupId: string | undefined
) {
this.isBlankDocument = backupId === 'undefined'
}
static async create(uri: vscode.Uri, backupId: string | undefined) {
let fileData: TldrawFile
if (typeof backupId === 'string' && (await fileExists(vscode.Uri.parse(backupId)))) {
fileData = await TLDrawDocument.readFile(vscode.Uri.parse(backupId))
} else {
fileData = await TLDrawDocument.readFile(uri)
}
return new TLDrawDocument(uri, fileData, backupId)
}
makeEdit(nextFile: TldrawFile) {
nicelog('makeEdit')
const prevData = this.documentData
this.documentData = nextFile
this._onDidChange.fire({
label: 'edit',
undo: async () => {
nicelog('undo')
this.documentData = prevData
this._onDidChangeDocument.fire({
reason: 'undo',
})
},
redo: async () => {
nicelog('redo')
this.documentData = nextFile
this._onDidChangeDocument.fire({
reason: 'redo',
})
},
})
}
dispose(): void {
this.disposables.forEach((d) => d.dispose())
this._onDidDispose.fire()
}
private static async readFile(uri: vscode.Uri): Promise<TldrawFile> {
nicelog('readFile')
if (uri.scheme === 'untitled') {
return defaultFileContents
}
const fileContents = await vscode.workspace.fs.readFile(uri)
return loadFile(Buffer.from(fileContents).toString('utf8'))
}
async loadBlankDocument() {
this.documentData = defaultFileContents
await this.writeToResource(this.uri)
if (this.lastBackupDestination && (await fileExists(this.lastBackupDestination))) {
await vscode.workspace.fs.delete(this.lastBackupDestination)
}
}
/** Called by VS Code when the user saves the document. */
async save(cancellation: vscode.CancellationToken): Promise<void> {
nicelog('save')
await this.saveAs(this.uri, cancellation)
}
/** Called by VS Code when the user saves the document to a new location. */
async saveAs(targetResource: vscode.Uri, cancellation: vscode.CancellationToken): Promise<void> {
nicelog('saveAs')
if (cancellation.isCancellationRequested) {
return
}
await this.writeToResource(targetResource)
}
private async writeToResource(targetResource: vscode.Uri) {
const fileContents = Buffer.from(JSON.stringify(this.documentData, null, '\t'), 'utf8')
await vscode.workspace.fs.writeFile(targetResource, fileContents)
}
/** Called by VS Code when the user calls `revert` on a document. */
async revert(_cancellation: vscode.CancellationToken): Promise<void> {
nicelog('revert')
const diskContent = await TLDrawDocument.readFile(this.uri)
this.documentData = diskContent
this._onDidChangeDocument.fire({
reason: 'revert',
fileContents: diskContent,
})
}
/**
* Called by VS Code to backup the edited document.
*
* These backups are used to implement hot exit.
*/
async backup(
destination: vscode.Uri,
cancellation: vscode.CancellationToken
): Promise<vscode.CustomDocumentBackup> {
nicelog('backup')
this.lastBackupDestination = destination
await this.saveAs(destination, cancellation)
return {
id: destination.toString(),
delete: async () => {
try {
await vscode.workspace.fs.delete(destination)
} catch {
// noop
}
},
}
}
async v1Backup(backupSaved: string, backupFailed: string) {
const regex = /\.tldr$/gi
if (!regex.test(this.uri.path)) {
vscode.window.showInformationMessage(backupFailed)
return
}
let destination = this.uri.with({ path: this.uri.path.replace(/\.tldr$/gi, ' - old.tldr') })
let exists = await fileExists(destination)
let fileNumber = 1
while (exists) {
destination = this.uri.with({
path: this.uri.path.replace(/\.tldr$/gi, ` - old (${fileNumber}).tldr`),
})
exists = await fileExists(destination)
fileNumber++
}
await vscode.workspace.fs.copy(this.uri, destination, { overwrite: false })
const fileName = destination.path.split('/').pop()
vscode.window.showInformationMessage(`${backupSaved}: ${fileName}`)
}
}

View File

@@ -0,0 +1,153 @@
import * as vscode from 'vscode'
// @ts-ignore
import type { VscodeMessage } from '../../messages'
import { DocumentChangeEventArgs, TLDrawDocument } from './TldrawDocument'
import { TldrawWebviewManager } from './TldrawWebviewManager'
import { nicelog } from './utils'
export class TldrawEditorProvider implements vscode.CustomEditorProvider<TLDrawDocument> {
private static newTDFileId = 1
private disposables: vscode.Disposable[] = []
private static readonly viewType = 'tldraw.tldr'
private webviewPanels: vscode.WebviewPanel[] = []
private readonly _onDidChangeCustomDocument = new vscode.EventEmitter<
vscode.CustomDocumentEditEvent<TLDrawDocument>
>()
public readonly onDidChangeCustomDocument = this._onDidChangeCustomDocument.event
constructor(private readonly context: vscode.ExtensionContext) {}
public static register(context: vscode.ExtensionContext): vscode.Disposable {
// Several commands exist only to prevent the default keyboard shortcuts
const noopCmds = ['zoomIn', 'zoomOut', 'resetZoom', 'toggleDarkMode']
noopCmds.forEach((name) =>
context.subscriptions.push(vscode.commands.registerCommand(`tldraw.tldr.${name}`, () => null))
)
// Register the 'Create New File' command, which creates a temporary
// .tldr file and opens it in the editor.
context.subscriptions.push(
vscode.commands.registerCommand(`tldraw.tldr.new`, () => {
const id = this.newTDFileId++
const name = id > 1 ? `New Document ${id}.tldr` : `New Document.tldr`
const workspaceFolders = vscode.workspace.workspaceFolders
const path = workspaceFolders ? workspaceFolders[0].uri : vscode.Uri.parse('')
vscode.commands.executeCommand(
'vscode.openWith',
vscode.Uri.joinPath(path, name).with({ scheme: 'untitled' }),
this.viewType
)
})
)
// Register our editor provider, indicating to VS Code that we can
// handle files with the .tldr extension.
return vscode.window.registerCustomEditorProvider(
this.viewType,
new TldrawEditorProvider(context),
{
webviewOptions: {
retainContextWhenHidden: true,
},
supportsMultipleEditorsPerDocument: true,
}
)
}
async openCustomDocument(
uri: vscode.Uri,
openContext: { backupId?: string },
_token: vscode.CancellationToken
): Promise<TLDrawDocument> {
nicelog('openCustomDocument')
const document: TLDrawDocument = await TLDrawDocument.create(uri, openContext.backupId)
this.disposables.push(
document.onDidChange((e) => {
nicelog('onDidChange')
// Tell VS Code that the document has been edited by the use.
this._onDidChangeCustomDocument.fire({
document,
...e,
})
})
)
this.disposables.push(
document.onDidChangeContent((e: DocumentChangeEventArgs) => {
nicelog('onDidChange')
this.webviewPanels.forEach((w: vscode.WebviewPanel) => {
if (w.active) {
if (e.reason === 'undo' || e.reason === 'redo') {
w.webview.postMessage({
type: `vscode:${e.reason}`,
} as VscodeMessage)
} else if (e.reason === 'revert') {
w.webview.postMessage({
type: `vscode:revert`,
data: {
fileContents: JSON.stringify(e.fileContents),
},
} as VscodeMessage)
}
}
})
})
)
document.onDidDispose(() => {
nicelog('onDidDispose document in provider')
this.disposables.forEach((d) => d.dispose())
})
return document
}
async resolveCustomEditor(
document: TLDrawDocument,
webviewPanel: vscode.WebviewPanel,
_token: vscode.CancellationToken
): Promise<void> {
nicelog('resolveCustomEditor')
this.webviewPanels.push(webviewPanel)
webviewPanel.onDidDispose(() => {
this.webviewPanels = this.webviewPanels.filter((w) => w !== webviewPanel)
})
new TldrawWebviewManager(this.context, document, webviewPanel)
}
public saveCustomDocument(
document: TLDrawDocument,
cancellation: vscode.CancellationToken
): Thenable<void> {
return document.save(cancellation)
}
public saveCustomDocumentAs(
document: TLDrawDocument,
destination: vscode.Uri,
cancellation: vscode.CancellationToken
): Thenable<void> {
return document.saveAs(destination, cancellation)
}
public revertCustomDocument(
document: TLDrawDocument,
cancellation: vscode.CancellationToken
): Thenable<void> {
return document.revert(cancellation)
}
public backupCustomDocument(
document: TLDrawDocument,
context: vscode.CustomDocumentBackupContext,
cancellation: vscode.CancellationToken
): Thenable<vscode.CustomDocumentBackup> {
return document.backup(context.destination, cancellation)
}
}

View File

@@ -0,0 +1,82 @@
import * as vscode from 'vscode'
/**
* tldraw offline saves documents as `.tldraw` archives — a zipped SQLite database rather than the
* JSON a `.tldr` file holds — so nothing in this extension can read one. We claim the extension
* anyway, and open a read-only panel explaining that, because the alternative is VS Code's generic
* "file is not displayed because it is binary" placeholder, which says nothing about tldraw.
*
* This deliberately doesn't reuse `TldrawEditorProvider`: that path reads the file as UTF-8 and
* hands it to `loadFile`, which would turn the archive's bytes into a corrupt-file error on a
* mounted canvas.
*/
export class TldrawOfflineFileProvider implements vscode.CustomReadonlyEditorProvider {
private static readonly viewType = 'tldraw.tldraw-offline'
public static register(): vscode.Disposable {
return vscode.window.registerCustomEditorProvider(
TldrawOfflineFileProvider.viewType,
new TldrawOfflineFileProvider()
)
}
openCustomDocument(uri: vscode.Uri): vscode.CustomDocument {
return { uri, dispose() {} }
}
resolveCustomEditor(_document: vscode.CustomDocument, webviewPanel: vscode.WebviewPanel): void {
webviewPanel.webview.html = html
}
}
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>tldraw</title>
<style>
body {
background-color: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.message {
max-width: 24rem;
text-align: center;
line-height: 1.5;
}
.message h1 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.message p {
margin: 0;
opacity: 0.8;
}
</style>
</head>
<body>
<div class="message">
<h1>Can&rsquo;t open .tldraw files yet</h1>
<p>
We&rsquo;re working on support for files from
<a href="https://offline.tldraw.com/">tldraw offline</a>.
</p>
<p>
For now, you can
<a href="https://tldraw.notion.site/User-manual-tldraw-offline-39a3e4c324c080e7b2eacc5afd078e85#3aa3e4c324c080669967e2cc3ae2c789">export as a .tldr file</a>
to use it here.
</p>
</div>
</body>
</html>
`

View File

@@ -0,0 +1,82 @@
import { uniqueId } from 'tldraw'
import * as vscode from 'vscode'
import { TLDrawDocument } from './TldrawDocument'
import { GlobalStateKeys, WebViewMessageHandler } from './WebViewMessageHandler'
export class TldrawWebviewManager {
private disposables: vscode.Disposable[] = []
private webViewMessageHandler: WebViewMessageHandler
constructor(
context: vscode.ExtensionContext,
document: TLDrawDocument,
webviewPanel: vscode.WebviewPanel
) {
let userId = context.globalState.get(GlobalStateKeys.UserId)
if (!userId) {
userId = 'user:' + uniqueId()
context.globalState.update(GlobalStateKeys.UserId, userId)
}
const assetSrc = webviewPanel.webview
.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, 'editor', '/'))
.toString()
this.webViewMessageHandler = new WebViewMessageHandler(
document,
webviewPanel,
context,
userId,
assetSrc
)
// Listen for messages sent from the extensions webview.
webviewPanel.webview.onDidReceiveMessage(
this.webViewMessageHandler.handle,
undefined,
this.disposables
)
// Configure the webview. For now all we do is enable scripts and also
// provide the initial webview's html content.
Object.assign(webviewPanel.webview, {
options: { enableScripts: true },
html: this.getHtmlForWebview(assetSrc),
})
const showV1FileOpenWarning = context.globalState.get(GlobalStateKeys.ShowV1FileOpenWarning)
if (showV1FileOpenWarning === undefined) {
context.globalState.update(GlobalStateKeys.ShowV1FileOpenWarning, true)
}
// Clean up disposables when the editor is closed.
webviewPanel.onDidDispose(this.handleDidDispose)
}
// eslint-disable-next-line tldraw/prefer-class-methods
private handleDidDispose = () => {
this.disposables.forEach(({ dispose }) => dispose())
}
getHtmlForWebview(assetSrc: string) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="${assetSrc}/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>tldraw</title>
</head>
<body>
<div id="root"></div>
<noscript>You need to enable JavaScript to run this editor.</noscript>
<script>
// Plenty of other extensions do this see <https://sourcegraph.com/search?q=context%3Aglobal+%22_defaultStyles%22&patternType=standard&sm=1&groupBy=repo>
document.getElementById("_defaultStyles").remove();
</script>
<script src="${assetSrc}/index.js"></script>
</body>
</html>
`
}
}

View File

@@ -0,0 +1,221 @@
import isEqual from 'lodash/isEqual'
import { UnknownRecord } from 'tldraw'
import * as vscode from 'vscode'
// @ts-ignore
import type { VscodeMessage } from '../../messages'
import { loadFile } from './file'
import { getMimeTypeFromPath } from './media'
import { TLDrawDocument } from './TldrawDocument'
import { unfurl } from './unfurl'
import { nicelog } from './utils'
export const GlobalStateKeys = {
ShowV1FileOpenWarning: 'showV1fileOpenWarning',
UserId: 'userId',
}
export class WebViewMessageHandler {
multiplayerOmitKeys = /^(user_presence:|camera:|user:|user_document:|instance:)/
newDocumentOmitKeys = /^(user_presence:|camera:|user:|user_document:|instance:|document:|page:)/
constructor(
private document: TLDrawDocument,
private webviewPanel: vscode.WebviewPanel,
private context: vscode.ExtensionContext,
private userId: unknown,
private assetSrc: string
) {}
isLoaded = false
// eslint-disable-next-line tldraw/prefer-class-methods
handle = async (e: VscodeMessage) => {
if (!this.document) return
switch (e.type) {
case 'vscode:ready-to-receive-file': {
// Send the initial document content to bootstrap the Tldraw/Tldraw component.
this.webviewPanel.webview.postMessage({
type: 'vscode:opened-file',
data: {
fileContents: JSON.stringify(this.document.documentData),
uri: this.document.uri.toString(),
userId: this.userId,
assetSrc: this.assetSrc,
isDarkMode:
this.document.isBlankDocument &&
(vscode.window.activeColorTheme.kind === 2 ||
vscode.window.activeColorTheme.kind === 3),
},
} as VscodeMessage)
break
}
case 'vscode:open-window': {
vscode.env.openExternal(vscode.Uri.parse(e.data.url))
break
}
case 'vscode:undo': {
vscode.commands.executeCommand('undo')
break
}
case 'vscode:redo': {
vscode.commands.executeCommand('redo')
break
}
case 'vscode:refresh-page': {
vscode.commands.executeCommand('workbench.action.reloadWindow')
break
}
case 'vscode:hard-reset': {
await this.document.loadBlankDocument()
vscode.commands.executeCommand('workbench.action.reloadWindow')
break
}
case 'vscode:get-file/request': {
try {
const url = e.data.url
const fileUri = vscode.Uri.parse(url)
const fileData = await vscode.workspace.fs.readFile(fileUri)
const mimeType = getMimeTypeFromPath(fileUri.path)
this.webviewPanel.webview.postMessage({
type: 'vscode:get-file/response',
uuid: e.uuid + '_response',
data: {
fileName: fileUri.path.split('/').pop(),
file: Array.from(fileData),
mimeType,
},
})
} catch (error: any) {
this.webviewPanel.webview.postMessage({
type: 'vscode:get-file/error',
data: {
error: error.toString(),
},
})
}
}
case 'vscode:bookmark/request': {
const url = e.data.url
await unfurl(url)
.then(async (json: any) => {
this.webviewPanel.webview.postMessage({
type: 'vscode:bookmark/response',
// Add a suffix to the uuid to represent the response.
uuid: e.uuid + '_response',
data: {
url,
title: json.title,
description: json.description,
image: json.image,
favicon: json.favicon,
},
})
})
.catch((error: any) => {
this.webviewPanel.webview.postMessage({
type: 'vscode:bookmark/error',
data: {
error: error.toString(),
},
})
})
break
}
case 'vscode:editor-loaded': {
this.isLoaded = true
break
}
case 'vscode:v1-file-opened': {
const showV1FileOpenWarning = this.context.globalState.get(
GlobalStateKeys.ShowV1FileOpenWarning
)
if (!showV1FileOpenWarning) return
const { backup, open, description, dontAskAgain } = e.data
vscode.window
.showInformationMessage(description, open, dontAskAgain, backup)
.then((result) => {
if (result === backup) {
this.document.v1Backup(e.data.backupSaved, e.data.backupFailed)
} else if (result === dontAskAgain) {
this.context.globalState.update(GlobalStateKeys.ShowV1FileOpenWarning, false)
}
})
break
}
case 'vscode:editor-updated': {
if (!this.isLoaded) return
const raw = e.data.fileContents
if (!raw) return
// The event will contain the new TDFile as JSON.
const nextFile = loadFile(raw)
const existingDoc = this.document.documentData
let isSame = false
if (existingDoc?.records?.length > 0) {
const oldDoc = this.omit(existingDoc.records, this.multiplayerOmitKeys)
const newDoc = this.omit(nextFile.records, this.multiplayerOmitKeys)
isSame = isEqual(oldDoc, newDoc)
} else {
const newDoc = this.omit(nextFile.records, this.newDocumentOmitKeys)
isSame = isEqual(newDoc, [])
}
if (!isSame) {
this.document.makeEdit(nextFile)
}
break
}
case 'vscode:hide-v1-file-open-warning': {
this.context.globalState.update(GlobalStateKeys.ShowV1FileOpenWarning, false)
break
}
case 'vscode:cancel-v1-migrate': {
vscode.commands.executeCommand('workbench.action.closeActiveEditor')
break
}
}
}
omit(records: UnknownRecord[], keys: RegExp) {
return records.filter((record) => {
return !record.id.match(keys)
})
}
findDiff(oldDoc: Record<string, any>, newDoc: Record<string, any>) {
const newRecords = Object.values(newDoc)
const oldRecords = Object.values(oldDoc)
for (const oldRecord of oldRecords) {
const newRecord = newRecords.find((r: any) => r.id === oldRecord.id)
if (!newRecord) {
nicelog('record missing in new doc', oldRecord)
continue
} else {
if (!isEqual(oldRecord, newRecord)) {
nicelog('record different', oldRecord, newRecord)
continue
}
}
}
for (const newRecord of newRecords) {
const oldRecord = oldRecords.find((r: any) => r.id === newRecord.id)
if (!oldRecord) {
nicelog('record missing in oldDoc doc', newRecord)
continue
} else {
if (!isEqual(newRecord, oldRecord)) {
nicelog('record different', newRecord, oldRecord)
continue
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
import { watch } from 'fs'
import path from 'path'
import * as vscode from 'vscode'
import { TldrawEditorProvider } from './TldrawEditorProvider'
import { TldrawOfflineFileProvider } from './TldrawOfflineFileProvider'
import { nicelog } from './utils'
export function activate(context: vscode.ExtensionContext) {
try {
if (process.env.NODE_ENV !== 'production') {
const extensionWatcher = watch(
__dirname + '/extension.js',
{ persistent: false },
(eventType, filename) => {
nicelog('reloading[%s]', eventType, filename)
extensionWatcher.close()
vscode.commands.executeCommand('workbench.action.reloadWindow')
}
)
const dirname = path.dirname(__dirname)
const editorpath = dirname.slice(0, dirname.lastIndexOf(path.sep))
const editorWatcher = watch(
editorpath + '/editor/index.js',
{ persistent: false },
(eventType, filename) => {
nicelog('reloading[%s]', eventType, filename)
editorWatcher.close()
vscode.commands.executeCommand('workbench.action.reloadWindow')
}
)
}
context.subscriptions.push(TldrawEditorProvider.register(context))
context.subscriptions.push(TldrawOfflineFileProvider.register())
} catch (e) {
console.error(e)
}
}

View File

@@ -0,0 +1,36 @@
import { TldrawFile, createTLSchema } from 'tldraw'
import * as vscode from 'vscode'
import { nicelog } from './utils'
export const defaultFileContents: TldrawFile = {
tldrawFileFormatVersion: 1,
schema: createTLSchema().serialize(),
records: [],
}
export const fileContentWithErrors: TldrawFile = {
tldrawFileFormatVersion: 1,
schema: createTLSchema().serialize(),
records: [{ typeName: 'shape', id: null } as any],
}
export function loadFile(fileContents: string): TldrawFile {
if (!fileContents) return defaultFileContents
try {
return JSON.parse(fileContents) as TldrawFile
} catch {
return fileContentWithErrors
}
}
export async function fileExists(destination: vscode.Uri) {
try {
await vscode.workspace.fs.stat(destination)
return true
} catch (e: any) {
if (e.code !== 'FileNotFound') {
nicelog(e)
}
return false
}
}

View File

@@ -0,0 +1,50 @@
import { DEFAULT_SUPPORTED_MEDIA_TYPES } from 'tldraw'
const EXTENSION_TO_DEFAULT_MIME_TYPE = {
// Vector image extensions
svg: 'image/svg+xml',
// Static image extensions
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
// Animated image extensions
gif: 'image/gif',
apng: 'image/apng',
avif: 'image/avif',
// Video extensions
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
qt: 'video/quicktime',
} as const
type DefaultSupportedMimeType = (typeof DEFAULT_SUPPORTED_MEDIA_TYPES)[number]
type DefaultSupportedExtensions = keyof typeof EXTENSION_TO_DEFAULT_MIME_TYPE
// We use these types to enforce that we don't have any extra mime types in the EXTENSION_TO_MIME_TYPE map
// and that we don't miss any mime types in the DEFAULT_SUPPORTED_MEDIA_TYPES array
type ExtensionMapMimeTypes =
(typeof EXTENSION_TO_DEFAULT_MIME_TYPE)[keyof typeof EXTENSION_TO_DEFAULT_MIME_TYPE]
type _CheckMimeTypesCovered = DefaultSupportedMimeType extends ExtensionMapMimeTypes ? true : never
type _CheckNoExtraMimeTypes = ExtensionMapMimeTypes extends DefaultSupportedMimeType ? true : never
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const mimeTypesCovered: _CheckMimeTypesCovered = true
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const noExtraMimeTypes: _CheckNoExtraMimeTypes = true
function isSupportedExtension(extension: string): extension is DefaultSupportedExtensions {
return extension in EXTENSION_TO_DEFAULT_MIME_TYPE
}
export function getMimeTypeFromPath(filePath: string): string {
const extension = filePath.split('.').pop()?.toLowerCase()
if (!extension) throw new Error('No extension found in file path')
if (isSupportedExtension(extension)) {
return EXTENSION_TO_DEFAULT_MIME_TYPE[extension]
}
throw new Error(`Unsupported file type: ${extension}`)
}

View File

@@ -0,0 +1,69 @@
import { load } from 'cheerio'
export async function unfurl(url: string) {
// Let's see if this URL was an image to begin with.
if (url.match(/\.(a?png|jpe?g|gif|svg|webp|avif)$/i)) {
return {
title: undefined,
description: undefined,
image: url,
favicon: undefined,
}
}
const response = await fetch(url)
if (response.status >= 400) {
throw new Error(`Error fetching url: ${response.status}`)
}
const contentType = response.headers.get('content-type')
if (!contentType?.includes('text/html')) {
throw new Error(`Content-type not right: ${contentType}`)
}
if (contentType?.startsWith('image/')) {
return {
title: undefined,
description: undefined,
image: url,
favicon: undefined,
}
}
const content = await response.text()
const $ = load(content)
const og: { [key: string]: string | undefined } = {}
const twitter: { [key: string]: string | undefined } = {}
$('meta[property^=og:]').each((_, el) => {
og[$(el).attr('property')!] = $(el).attr('content')
})
$('meta[name^=twitter:]').each((_, el) => {
twitter[$(el).attr('name')!] = $(el).attr('content')
})
const title = og['og:title'] ?? twitter['twitter:title'] ?? $('title').text() ?? undefined
const description =
og['og:description'] ??
twitter['twitter:description'] ??
$('meta[name="description"]').attr('content') ??
undefined
let image = og['og:image:secure_url'] ?? og['og:image'] ?? twitter['twitter:image'] ?? undefined
let favicon =
$('link[rel="apple-touch-icon"]').attr('href') ??
$('link[rel="icon"]').attr('href') ??
undefined
if (image && !image?.startsWith('http')) {
image = new URL(image, url).href
}
if (favicon && !favicon?.startsWith('http')) {
favicon = new URL(favicon, url).href
}
return {
title,
description,
image,
favicon,
}
}

View File

@@ -0,0 +1,8 @@
const DEBUG_EVENTS = false
export function nicelog(...args: any[]) {
if (process.env.NODE_ENV !== 'production' && DEBUG_EVENTS) {
// eslint-disable-next-line no-console
console.log(...args)
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"removeComments": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx",
"incremental": true,
"composite": true,
"importHelpers": false,
"skipDefaultLibCheck": true
},
"include": ["src", "../messages", "scripts"],
"references": [{ "path": "../../../packages/editor" }, { "path": "../../../packages/tldraw" }]
}