security: enforce use of our fetch function and its default referrerpolicy (#3884)

followup to https://github.com/tldraw/tldraw/pull/3881 to enforce this
in the codebase

Describe what your pull request does. If appropriate, add GIFs or images
showing the before and after.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [x] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know
This commit is contained in:
Mime Čuvalo 2024-06-11 14:59:25 +01:00 committed by GitHub
parent 9adb5eec5a
commit 3adae06d9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 300 additions and 119 deletions

View file

@ -2,6 +2,7 @@ module.exports = {
extends: [ extends: [
'prettier', 'prettier',
'eslint:recommended', 'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:@next/next/core-web-vitals', 'plugin:@next/next/core-web-vitals',
], ],
@ -12,6 +13,7 @@ module.exports = {
'import', 'import',
'local', 'local',
'@next/next', '@next/next',
'react',
'react-hooks', 'react-hooks',
'deprecation', 'deprecation',
], ],
@ -23,15 +25,17 @@ module.exports = {
rules: { rules: {
'deprecation/deprecation': 'error', 'deprecation/deprecation': 'error',
'@next/next/no-html-link-for-pages': 'off', '@next/next/no-html-link-for-pages': 'off',
'react/jsx-key': 'off',
'no-non-null-assertion': 'off', 'no-non-null-assertion': 'off',
'no-fallthrough': 'off', 'no-fallthrough': 'off',
'react/jsx-no-target-blank': 'error',
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-fallthrough': 'off', '@typescript-eslint/no-fallthrough': 'off',
'@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/ban-ts-comment': 'off',
'react/display-name': 'off', 'react/display-name': 'off',
'@next/next/no-img-element': 'off', '@next/next/no-img-element': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-extra-semi': 'off', '@typescript-eslint/no-extra-semi': 'off',
'no-mixed-spaces-and-tabs': 'off', 'no-mixed-spaces-and-tabs': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
@ -86,39 +90,107 @@ module.exports = {
}, },
}, },
{ {
files: ['packages/editor/**/*', 'packages/tldraw/**/*'], files: ['packages/editor/**/*', 'packages/tldraw/**/*', 'packages/utils/**/*'],
rules: { rules: {
'no-restricted-globals': [ 'no-restricted-globals': [
'error', 'error',
{ {
name: 'setInterval', name: 'fetch',
message: 'Use the timers from @tldraw/util instead.', message: 'Use the fetch from @tldraw/util instead.',
},
{
name: 'Image',
message: 'Use the Image from @tldraw/util instead.',
}, },
{ {
name: 'setTimeout', name: 'setTimeout',
message: 'Use the timers from @tldraw/util instead.', message: 'Use the timers from editor.timers instead.',
},
{
name: 'setInterval',
message: 'Use the timers from editor.timers instead.',
}, },
{ {
name: 'requestAnimationFrame', name: 'requestAnimationFrame',
message: 'Use the timers from @tldraw/util instead.', message: 'Use the timers from editor.timers instead.',
}, },
{ name: 'structuredClone', message: 'Use structuredClone from @tldraw/util instead' },
], ],
'no-restricted-properties': [ 'no-restricted-properties': [
'error', 'error',
{
object: 'window',
property: 'fetch',
message: 'Use the fetch from @tldraw/util instead.',
},
{
object: 'window',
property: 'Image',
message: 'Use the Image from @tldraw/util instead.',
},
{ {
object: 'window', object: 'window',
property: 'setTimeout', property: 'setTimeout',
message: 'Use the timers from @tldraw/util instead.', message: 'Use the timers from editor.timers instead.',
}, },
{ {
object: 'window', object: 'window',
property: 'setInterval', property: 'setInterval',
message: 'Use the timers from @tldraw/util instead.', message: 'Use the timers from editor.timers instead.',
}, },
{ {
object: 'window', object: 'window',
property: 'requestAnimationFrame', property: 'requestAnimationFrame',
message: 'Use the timers from @tldraw/util instead.', message: 'Use the timers from editor.timers instead.',
},
],
'no-restricted-syntax': [
'error',
{ selector: "MethodDefinition[kind='set']", message: 'Property setters are not allowed' },
{ selector: "MethodDefinition[kind='get']", message: 'Property getters are not allowed' },
{
selector: 'Identifier[name=localStorage]',
message: 'Use the getFromLocalStorage/setInLocalStorage helpers instead',
},
{
selector: 'Identifier[name=sessionStorage]',
message: 'Use the getFromSessionStorage/setInSessionStorage helpers instead',
},
{
selector:
"JSXElement[openingElement.name.name='img']:not(:has(JSXAttribute[name.name='referrerPolicy']))",
message: 'You must pass `referrerPolicy` when creating an <img>.',
},
],
},
},
// This overrides the default config for the given matching paths.
{
files: ['apps/dotcom/**/*'],
rules: {
'no-restricted-globals': [
'error',
{
name: 'fetch',
message: 'Use the fetch from @tldraw/util instead.',
},
{
name: 'Image',
message: 'Use the Image from @tldraw/util instead.',
},
{ name: 'structuredClone', message: 'Use structuredClone from @tldraw/util instead' },
],
'no-restricted-properties': [
'error',
{
object: 'window',
property: 'fetch',
message: 'Use the fetch from @tldraw/util instead.',
},
{
object: 'window',
property: 'Image',
message: 'Use the Image from @tldraw/util instead.',
}, },
], ],
}, },
@ -136,10 +208,12 @@ module.exports = {
}, },
}, },
{ {
files: ['*.test.ts', '*.spec.ts'], files: ['*.test.ts', '*.test.tsx', '*.spec.ts'],
rules: { rules: {
'no-restricted-properties': 'off', 'no-restricted-properties': 'off',
'no-restricted-globals': 'off', 'no-restricted-globals': 'off',
'react/jsx-key': 'off',
'react/no-string-refs': 'off',
}, },
}, },
{ {

View file

@ -28,17 +28,17 @@ export default async function ClaPage() {
You reserve all right, title, and interest in and to Your Contributions.{' '} You reserve all right, title, and interest in and to Your Contributions.{' '}
</p> </p>
<p>1. Definitions. </p> <p>1. Definitions. </p>
<p>"You" (or "Your") means the individual identified above. </p> <p>You (or Your) means the individual identified above. </p>
<p> <p>
"Contribution" means any original work of authorship, including any modifications or Contribution means any original work of authorship, including any modifications or
additions to an existing work, that is intentionally submitted by You to tldraw for additions to an existing work, that is intentionally submitted by You to tldraw for
inclusion in, or documentation of, any of the products owned or managed by tldraw (each, a inclusion in, or documentation of, any of the products owned or managed by tldraw (each, a
"Work"). For the purposes of this definition, "submitted" means any form of electronic, Work). For the purposes of this definition, submitted means any form of electronic,
verbal, or written communication sent to tldraw or its representatives, including but not verbal, or written communication sent to tldraw or its representatives, including but not
limited to communication on electronic mailing lists, source code control systems, and limited to communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, tldraw for the purpose of issue tracking systems that are managed by, or on behalf of, tldraw for the purpose of
discussing and improving the Works, but excluding communication that is conspicuously discussing and improving the Works, but excluding communication that is conspicuously
marked or otherwise designated in writing by You as "Not a Contribution."{' '} marked or otherwise designated in writing by You as Not a Contribution.{' '}
</p> </p>
<p> <p>
2. Grant of Copyright License. You hereby grant to tldraw a perpetual, worldwide, 2. Grant of Copyright License. You hereby grant to tldraw a perpetual, worldwide,
@ -74,7 +74,7 @@ export default async function ClaPage() {
6. You are not expected to provide support for Your Contributions, except to the extent 6. You are not expected to provide support for Your Contributions, except to the extent
You desire to provide support. You may provide support for free, for a fee, or not at all. You desire to provide support. You may provide support for free, for a fee, or not at all.
Unless required by applicable law or agreed to in writing, You provide Your Contributions Unless required by applicable law or agreed to in writing, You provide Your Contributions
on an "as is" basis, without warranties or conditions of any kind, either express or on an as is basis, without warranties or conditions of any kind, either express or
implied, including, without limitation, any warranties or conditions of title, implied, including, without limitation, any warranties or conditions of title,
non-infringement, merchantability, or fitness for a particular purpose.{' '} non-infringement, merchantability, or fitness for a particular purpose.{' '}
</p> </p>

View file

@ -53,13 +53,13 @@ export default async function LicensePage() {
available upon request and do so only under a license that complies with this License. available upon request and do so only under a license that complies with this License.
</p> </p>
<p> <p>
That that the word "tldraw" shall not be used to refer to any derivative works of the That that the word tldraw shall not be used to refer to any derivative works of the
Software except in the phrase "Based on the tldraw library (https://tldraw.com)", provided Software except in the phrase Based on the tldraw library (https://tldraw.com)”, provided
such phrase is not used to promote the derivative works or to imply that tldraw endorses such phrase is not used to promote the derivative works or to imply that tldraw endorses
you or the derivative works. you or the derivative works.
</p> </p>
<p> <p>
THAT THE SOFTWARE COMES "AS IS", WITH NO WARRANTIES. THIS MEANS NO EXPRESS, IMPLIED OR THAT THE SOFTWARE COMES AS IS, WITH NO WARRANTIES. THIS MEANS NO EXPRESS, IMPLIED OR
STATUTORY WARRANTY, INCLUDING WITHOUT LIMITATION, WARRANTIES OF MERCHANTABILITY OR FITNESS STATUTORY WARRANTY, INCLUDING WITHOUT LIMITATION, WARRANTIES OF MERCHANTABILITY OR FITNESS
FOR A PARTICULAR PURPOSE OR ANY WARRANTY OF TITLE OR NON-INFRINGEMENT. ALSO, YOU MUST PASS FOR A PARTICULAR PURPOSE OR ANY WARRANTY OF TITLE OR NON-INFRINGEMENT. ALSO, YOU MUST PASS
THIS DISCLAIMER ON WHENEVER YOU DISTRIBUTE THE SOFTWARE OR DERIVATIVE WORKS. THIS DISCLAIMER ON WHENEVER YOU DISTRIBUTE THE SOFTWARE OR DERIVATIVE WORKS.

View file

@ -15,7 +15,7 @@ export default async function NotFound() {
<div className="page-header"> <div className="page-header">
<h1>Not found.</h1> <h1>Not found.</h1>
</div> </div>
<p>There's nothing here. :(</p> <p>Theres nothing here. :(</p>
</main> </main>
</div> </div>
</div> </div>

View file

@ -24,6 +24,7 @@ export function Header({ sectionId }: { sectionId?: string }) {
href="https://x.com/tldraw/" href="https://x.com/tldraw/"
className="sidebar__button icon-button" className="sidebar__button icon-button"
title="twitter" title="twitter"
rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<Icon icon="twitter" /> <Icon icon="twitter" />
@ -32,6 +33,7 @@ export function Header({ sectionId }: { sectionId?: string }) {
href="https://discord.com/invite/SBBEVCA4PG" href="https://discord.com/invite/SBBEVCA4PG"
className="sidebar__button icon-button" className="sidebar__button icon-button"
title="discord" title="discord"
rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<Icon icon="discord" /> <Icon icon="discord" />
@ -40,6 +42,7 @@ export function Header({ sectionId }: { sectionId?: string }) {
href="https://github.com/tldraw/tldraw" href="https://github.com/tldraw/tldraw"
className="sidebar__button icon-button" className="sidebar__button icon-button"
title="github" title="github"
rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<Icon icon="github" /> <Icon icon="github" />

View file

@ -1,7 +1,7 @@
import { ROOM_PREFIX } from '@tldraw/dotcom-shared' import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/tlsync' import { RoomSnapshot } from '@tldraw/tlsync'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { Tldraw } from 'tldraw' import { Tldraw, fetch } from 'tldraw'
import '../../../styles/core.css' import '../../../styles/core.css'
import { assetUrls } from '../../utils/assetUrls' import { assetUrls } from '../../utils/assetUrls'
import { useFileSystem } from '../../utils/useFileSystem' import { useFileSystem } from '../../utils/useFileSystem'

View file

@ -62,7 +62,7 @@ export function IFrameProtector({
return ( return (
<div className="tldraw__editor tl-container"> <div className="tldraw__editor tl-container">
<div className="iframe-warning__container"> <div className="iframe-warning__container">
<a className="iframe-warning__link" href={url} target="_blank"> <a className="iframe-warning__link" href={url} rel="noopener noreferrer" target="_blank">
{'Visit this page on tldraw.com'} {'Visit this page on tldraw.com'}
<svg <svg
width="15" width="15"

View file

@ -10,6 +10,7 @@ import {
TldrawUiMenuContextProvider, TldrawUiMenuContextProvider,
TldrawUiMenuGroup, TldrawUiMenuGroup,
TldrawUiMenuItem, TldrawUiMenuItem,
fetch,
unwrapLabel, unwrapLabel,
useActions, useActions,
useContainer, useContainer,

View file

@ -4,6 +4,7 @@ import {
MediaHelpers, MediaHelpers,
TLAsset, TLAsset,
TLAssetId, TLAssetId,
fetch,
getHashForString, getHashForString,
uniqueId, uniqueId,
} from 'tldraw' } from 'tldraw'

View file

@ -1,5 +1,6 @@
import { ROOM_PREFIX } from '@tldraw/dotcom-shared' import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/tlsync' import { RoomSnapshot } from '@tldraw/tlsync'
import { fetch } from 'tldraw'
import '../../styles/globals.css' import '../../styles/globals.css'
import { BoardHistorySnapshot } from '../components/BoardHistorySnapshot/BoardHistorySnapshot' import { BoardHistorySnapshot } from '../components/BoardHistorySnapshot/BoardHistorySnapshot'
import { ErrorPage } from '../components/ErrorPage/ErrorPage' import { ErrorPage } from '../components/ErrorPage/ErrorPage'

View file

@ -1,4 +1,5 @@
import { ROOM_PREFIX } from '@tldraw/dotcom-shared' import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
import { fetch } from 'tldraw'
import { BoardHistoryLog } from '../components/BoardHistoryLog/BoardHistoryLog' import { BoardHistoryLog } from '../components/BoardHistoryLog/BoardHistoryLog'
import { ErrorPage } from '../components/ErrorPage/ErrorPage' import { ErrorPage } from '../components/ErrorPage/ErrorPage'
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector' import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'

View file

@ -1,4 +1,4 @@
import { SerializedSchema, TLRecord } from 'tldraw' import { SerializedSchema, TLRecord, fetch } from 'tldraw'
import '../../styles/globals.css' import '../../styles/globals.css'
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector' import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
import { SnapshotsEditor } from '../components/SnapshotsEditor' import { SnapshotsEditor } from '../components/SnapshotsEditor'

View file

@ -2,7 +2,7 @@ import { CreateRoomRequestBody, ROOM_PREFIX, Snapshot } from '@tldraw/dotcom-sha
import { schema } from '@tldraw/tlsync' import { schema } from '@tldraw/tlsync'
import { useState } from 'react' import { useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { TldrawUiButton } from 'tldraw' import { TldrawUiButton, fetch } from 'tldraw'
import '../../styles/globals.css' import '../../styles/globals.css'
import { getParentOrigin } from '../utils/iFrame' import { getParentOrigin } from '../utils/iFrame'

View file

@ -1,4 +1,5 @@
import { getAssetUrlsByImport } from '@tldraw/assets/imports.vite' import { getAssetUrlsByImport } from '@tldraw/assets/imports.vite'
import { Image } from 'tldraw'
export const assetUrls = getAssetUrlsByImport() export const assetUrls = getAssetUrlsByImport()
@ -12,7 +13,7 @@ async function preloadIcons() {
function preloadIcon(url: string) { function preloadIcon(url: string) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const image = new Image() const image = Image()
// this isn't known by typescript but it works // this isn't known by typescript but it works
;(image as any).fetchPriority = 'low' ;(image as any).fetchPriority = 'low'
image.onload = resolve image.onload = resolve

View file

@ -1,4 +1,4 @@
import { TLAsset } from 'tldraw' import { TLAsset, fetch } from 'tldraw'
export async function cloneAssetForShare( export async function cloneAssetForShare(
asset: TLAsset, asset: TLAsset,

View file

@ -3,6 +3,7 @@ import {
MediaHelpers, MediaHelpers,
TLAsset, TLAsset,
TLAssetId, TLAssetId,
fetch,
getHashForString, getHashForString,
uniqueId, uniqueId,
} from 'tldraw' } from 'tldraw'
@ -18,7 +19,6 @@ export async function createAssetFromFile({ file }: { type: 'file'; file: File }
await fetch(url, { await fetch(url, {
method: 'POST', method: 'POST',
body: file, body: file,
referrerPolicy: 'strict-origin-when-cross-origin',
}) })
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url)) const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))

View file

@ -1,4 +1,4 @@
import { AssetRecordType, TLAsset, getHashForString } from 'tldraw' import { AssetRecordType, TLAsset, fetch, getHashForString } from 'tldraw'
import { BOOKMARK_ENDPOINT } from './config' import { BOOKMARK_ENDPOINT } from './config'
interface ResponseBody { interface ResponseBody {
@ -45,7 +45,6 @@ export async function createAssetFromUrl({ url }: { type: 'url'; url: string }):
const resp = await fetch(url, { const resp = await fetch(url, {
method: 'GET', method: 'GET',
mode: 'no-cors', mode: 'no-cors',
referrerPolicy: 'strict-origin-when-cross-origin',
}) })
const html = await resp.text() const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')

View file

@ -1,5 +1,5 @@
import { openDB } from 'idb' import { openDB } from 'idb'
import { Editor, TLV1Document, buildFromV1Document } from 'tldraw' import { Editor, TLV1Document, buildFromV1Document, fetch } from 'tldraw'
export function isEditorEmpty(editor: Editor) { export function isEditorEmpty(editor: Editor) {
const hasAnyShapes = editor.store.allRecords().some((r) => r.typeName === 'shape') const hasAnyShapes = editor.store.allRecords().some((r) => r.typeName === 'shape')

View file

@ -20,6 +20,7 @@ import {
TLUiOverrides, TLUiOverrides,
TLUiToastsContextType, TLUiToastsContextType,
TLUiTranslationKey, TLUiTranslationKey,
fetch,
isShape, isShape,
} from 'tldraw' } from 'tldraw'
import { useMultiplayerAssets } from '../hooks/useMultiplayerAssets' import { useMultiplayerAssets } from '../hooks/useMultiplayerAssets'

View file

@ -45,6 +45,7 @@ export function ExamplePage({
<a <a
target="_blank" target="_blank"
href="https://twitter.com/tldraw" href="https://twitter.com/tldraw"
rel="noopener noreferrer"
title="twitter" title="twitter"
className="hoverable" className="hoverable"
> >
@ -53,6 +54,7 @@ export function ExamplePage({
<a <a
target="_blank" target="_blank"
href="https://github.com/tldraw/tldraw" href="https://github.com/tldraw/tldraw"
rel="noopener noreferrer"
title="github" title="github"
className="hoverable" className="hoverable"
> >
@ -61,6 +63,7 @@ export function ExamplePage({
<a <a
target="_blank" target="_blank"
href="https://discord.com/invite/SBBEVCA4PG" href="https://discord.com/invite/SBBEVCA4PG"
rel="noopener noreferrer"
title="discord" title="discord"
className="hoverable" className="hoverable"
> >
@ -104,6 +107,7 @@ export function ExamplePage({
<a <a
className="example__sidebar__footer-link example__sidebar__footer-link--grey" className="example__sidebar__footer-link example__sidebar__footer-link--grey"
target="_blank" target="_blank"
rel="noopener noreferrer"
href="https://github.com/tldraw/tldraw/issues/new?assignees=&labels=Example%20Request&projects=&template=example_request.yml&title=%5BExample Request%5D%3A+" href="https://github.com/tldraw/tldraw/issues/new?assignees=&labels=Example%20Request&projects=&template=example_request.yml&title=%5BExample Request%5D%3A+"
> >
Request an example Request an example
@ -111,6 +115,7 @@ export function ExamplePage({
<a <a
className="example__sidebar__footer-link example__sidebar__footer-link--grey" className="example__sidebar__footer-link example__sidebar__footer-link--grey"
target="_blank" target="_blank"
rel="noopener noreferrer"
href="https://tldraw.dev" href="https://tldraw.dev"
> >
Visit the docs Visit the docs

View file

@ -42,10 +42,10 @@ function ControlledFocusExample() {
</div> </div>
</div> </div>
<p> <p>
The checkbox controls the editor's <code>instanceState.isFocused</code> property. The checkbox controls the editors <code>instanceState.isFocused</code> property.
</p> </p>
<p> <p>
When the editor is "focused", its keyboard shortcuts will work. When it is not focused, the When the editor is focused, its keyboard shortcuts will work. When it is not focused, the
keyboard shortcuts will not work. keyboard shortcuts will not work.
</p> </p>
<div style={{ width: 800, maxWidth: '100%', height: 500 }}> <div style={{ width: 800, maxWidth: '100%', height: 500 }}>
@ -67,7 +67,7 @@ function FreeFocusExample() {
<div> <div>
<h2>Free Focus</h2> <h2>Free Focus</h2>
<p> <p>
You can use `onBlur` and `onFocus` to control the editor's focus so that it behaves like a You can use `onBlur` and `onFocus` to control the editors focus so that it behaves like a
native form input. native form input.
</p> </p>
<div <div

View file

@ -1,5 +1,6 @@
import { throttle } from 'lodash'
import { useLayoutEffect, useState } from 'react' import { useLayoutEffect, useState } from 'react'
import { Tldraw, createTLStore, getSnapshot, loadSnapshot, throttle } from 'tldraw' import { Tldraw, createTLStore, getSnapshot, loadSnapshot } from 'tldraw'
import 'tldraw/tldraw.css' import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file! // There's a guide at the bottom of this file!

View file

@ -155,13 +155,13 @@ function ABunchOfText() {
The fluorescent lights flickered overhead as John sat hunched over his desk, his fingers The fluorescent lights flickered overhead as John sat hunched over his desk, his fingers
tapping rhythmically on the keyboard. He was a software developer, and tonight, he had a tapping rhythmically on the keyboard. He was a software developer, and tonight, he had a
peculiar mission. A mission that would take him deep into the labyrinthine world of web peculiar mission. A mission that would take him deep into the labyrinthine world of web
development. John had stumbled upon a new whiteboard library called "tldraw," a seemingly development. John had stumbled upon a new whiteboard library called tldraw, a seemingly
simple tool that promised to revolutionize collaborative drawing on the web. Little did he simple tool that promised to revolutionize collaborative drawing on the web. Little did he
know that this discovery would set off a chain of events that would challenge his skills, know that this discovery would set off a chain of events that would challenge his skills,
test his perseverance, and blur the line between reality and imagination. test his perseverance, and blur the line between reality and imagination.
</p> </p>
<p> <p>
With a newfound sense of excitement, John began integrating "tldraw" into his latest With a newfound sense of excitement, John began integrating tldraw into his latest
project. As lines of code danced across his screen, he imagined the possibilities that lay project. As lines of code danced across his screen, he imagined the possibilities that lay
ahead. The potential to create virtual spaces where ideas could be shared, concepts could be ahead. The potential to create virtual spaces where ideas could be shared, concepts could be
visualized, and teams could collaborate seamlessly from different corners of the world. It visualized, and teams could collaborate seamlessly from different corners of the world. It
@ -169,7 +169,7 @@ function ABunchOfText() {
merged into a harmonious symphony. merged into a harmonious symphony.
</p> </p>
<p> <p>
As the night wore on, John's mind became consumed with the whiteboard library. He couldn't As the night wore on, Johns mind became consumed with the whiteboard library. He couldnt
help but marvel at its elegance and simplicity. With each stroke of his keyboard, he felt a help but marvel at its elegance and simplicity. With each stroke of his keyboard, he felt a
surge of inspiration, a connection to something greater than himself. It was as if the lines surge of inspiration, a connection to something greater than himself. It was as if the lines
of code he was writing were transforming into a digital canvas, waiting to be filled with of code he was writing were transforming into a digital canvas, waiting to be filled with
@ -179,7 +179,7 @@ function ABunchOfText() {
take shape. take shape.
</p> </p>
<p> <p>
Little did John know, this integration of "tldraw" was only the beginning. It would lead him Little did John know, this integration of tldraw was only the beginning. It would lead him
down a path filled with unforeseen challenges, where he would confront his own limitations down a path filled with unforeseen challenges, where he would confront his own limitations
and question the very nature of creation. The journey ahead would test his resolve, pushing and question the very nature of creation. The journey ahead would test his resolve, pushing
him to the edge of his sanity. And as he embarked on this perilous adventure, he could not him to the edge of his sanity. And as he embarked on this perilous adventure, he could not

View file

@ -75,7 +75,7 @@ const MyComponentInFront = track(() => {
boxShadow: '0 0 0 1px rgba(0,0,0,0.1), 0 4px 8px rgba(0,0,0,0.1)', boxShadow: '0 0 0 1px rgba(0,0,0,0.1), 0 4px 8px rgba(0,0,0,0.1)',
}} }}
> >
<p>This won't scale with zoom.</p> <p>This wont scale with zoom.</p>
</div> </div>
) )
}) })

View file

@ -1,7 +1,7 @@
export default function YjsExample() { export default function YjsExample() {
return ( return (
<div className="tldraw__editor"> <div className="tldraw__editor">
We've moved! See{' '} Weve moved! See{' '}
<a href="https://github.com/tldraw/tldraw-yjs-example"> <a href="https://github.com/tldraw/tldraw-yjs-example">
https://github.com/tldraw/tldraw-yjs-example https://github.com/tldraw/tldraw-yjs-example
</a> </a>

View file

@ -1,4 +1,4 @@
import { AssetRecordType, TLAsset, TLExternalAssetContent, getHashForString } from 'tldraw' import { AssetRecordType, TLAsset, TLExternalAssetContent, fetch, getHashForString } from 'tldraw'
import { rpc } from './rpc' import { rpc } from './rpc'
export async function onCreateAssetFromUrl({ export async function onCreateAssetFromUrl({
@ -30,7 +30,6 @@ export async function onCreateAssetFromUrl({
const resp = await fetch(url, { const resp = await fetch(url, {
method: 'GET', method: 'GET',
mode: 'no-cors', mode: 'no-cors',
referrerPolicy: 'strict-origin-when-cross-origin',
}) })
const html = await resp.text() const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')

View file

@ -1073,6 +1073,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}; };
interrupt(): this; interrupt(): this;
isAncestorSelected(shape: TLShape | TLShapeId): boolean; isAncestorSelected(shape: TLShape | TLShapeId): boolean;
isDisposed: boolean;
isIn(path: string): boolean; isIn(path: string): boolean;
isInAny(...paths: string[]): boolean; isInAny(...paths: string[]): boolean;
isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: { isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: {

View file

@ -57,8 +57,6 @@
"eventemitter3": "^4.0.7", "eventemitter3": "^4.0.7",
"idb": "^7.1.1", "idb": "^7.1.1",
"is-plain-object": "^5.0.0", "is-plain-object": "^5.0.0",
"lodash.throttle": "^4.1.1",
"lodash.uniq": "^4.5.0",
"nanoid": "4.0.2" "nanoid": "4.0.2"
}, },
"peerDependencies": { "peerDependencies": {
@ -70,8 +68,6 @@
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@types/benchmark": "^2.1.2", "@types/benchmark": "^2.1.2",
"@types/lodash.throttle": "^4.1.7",
"@types/lodash.uniq": "^4.5.7",
"@types/react-test-renderer": "^18.0.0", "@types/react-test-renderer": "^18.0.0",
"@types/wicg-file-system-access": "^2020.9.5", "@types/wicg-file-system-access": "^2020.9.5",
"benchmark": "^2.1.4", "benchmark": "^2.1.4",

View file

@ -596,6 +596,7 @@ function DebugSvgCopy({ id }: { id: TLShapeId }) {
src={image.src} src={image.src}
width={image.bounds.width} width={image.bounds.width}
height={image.bounds.height} height={image.bounds.height}
referrerPolicy="no-referrer"
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,

View file

@ -230,6 +230,7 @@ export class Editor extends EventEmitter<TLEventMap> {
this.options = { ...defaultTldrawOptions, ...options } this.options = { ...defaultTldrawOptions, ...options }
this.store = store this.store = store
this.disposables.add(this.store.dispose.bind(this.store))
this.history = new HistoryManager<TLRecord>({ this.history = new HistoryManager<TLRecord>({
store, store,
annotateError: (error) => { annotateError: (error) => {
@ -718,6 +719,13 @@ export class Editor extends EventEmitter<TLEventMap> {
*/ */
readonly disposables = new Set<() => void>() readonly disposables = new Set<() => void>()
/**
* Whether the editor is disposed.
*
* @public
*/
isDisposed = false
/** @internal */ /** @internal */
private readonly _tickManager private readonly _tickManager
@ -798,6 +806,7 @@ export class Editor extends EventEmitter<TLEventMap> {
dispose() { dispose() {
this.disposables.forEach((dispose) => dispose()) this.disposables.forEach((dispose) => dispose())
this.disposables.clear() this.disposables.clear()
this.isDisposed = true
} }
/* ------------------- Shape Utils ------------------ */ /* ------------------- Shape Utils ------------------ */

View file

@ -1,4 +1,4 @@
import throttle from 'lodash.throttle' import { throttle } from '@tldraw/utils'
import { useLayoutEffect } from 'react' import { useLayoutEffect } from 'react'
import { Box } from '../primitives/Box' import { Box } from '../primitives/Box'
import { useEditor } from './useEditor' import { useEditor } from './useEditor'
@ -62,6 +62,7 @@ export function useScreenBounds(ref: React.RefObject<HTMLElement>) {
window.removeEventListener('resize', updateBounds) window.removeEventListener('resize', updateBounds)
resizeObserver.disconnect() resizeObserver.disconnect()
scrollingParent?.removeEventListener('scroll', updateBounds) scrollingParent?.removeEventListener('scroll', updateBounds)
updateBounds.cancel()
} }
}, [editor, ref]) }, [editor, ref])
} }

View file

@ -26,6 +26,7 @@ export function useZoomCss() {
return () => { return () => {
scheduler.detach() scheduler.detach()
setScaleDebounced.cancel()
} }
}, [editor, container]) }, [editor, container])
} }

View file

@ -1,3 +1,5 @@
import { fetch } from '@tldraw/utils'
/** @public */ /** @public */
export function dataUrlToFile(url: string, filename: string, mimeType: string) { export function dataUrlToFile(url: string, filename: string, mimeType: string) {
return fetch(url) return fetch(url)

View file

@ -1,4 +1,4 @@
import _uniq from 'lodash.uniq' import { uniq as _uniq } from '@tldraw/utils'
/** @public */ /** @public */
export function uniq<T>( export function uniq<T>(

View file

@ -348,6 +348,8 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
clear: () => void; clear: () => void;
createComputedCache: <Result, Record extends R = R>(name: string, derive: (record: Record) => Result | undefined, isEqual?: ((a: Record, b: Record) => boolean) | undefined) => ComputedCache<Result, Record>; createComputedCache: <Result, Record extends R = R>(name: string, derive: (record: Record) => Result | undefined, isEqual?: ((a: Record, b: Record) => boolean) | undefined) => ComputedCache<Result, Record>;
createSelectedComputedCache: <Selection, Result, Record extends R = R>(name: string, selector: (record: Record) => Selection | undefined, derive: (input: Selection) => Result | undefined) => ComputedCache<Result, Record>; createSelectedComputedCache: <Selection, Result, Record extends R = R>(name: string, selector: (record: Record) => Selection | undefined, derive: (input: Selection) => Result | undefined) => ComputedCache<Result, Record>;
// (undocumented)
dispose(): void;
// @internal (undocumented) // @internal (undocumented)
ensureStoreIsUsable(): void; ensureStoreIsUsable(): void;
extractingChanges(fn: () => void): RecordsDiff<R>; extractingChanges(fn: () => void): RecordsDiff<R>;

View file

@ -162,6 +162,15 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
*/ */
private historyReactor: Reactor private historyReactor: Reactor
/**
* Function to dispose of any in-flight timeouts.
*
* @internal
*/
private cancelHistoryReactor: () => void = () => {
/* noop */
}
readonly schema: StoreSchema<R, Props> readonly schema: StoreSchema<R, Props>
readonly props: Props readonly props: Props
@ -209,7 +218,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
// If we have accumulated history, flush it and update listeners // If we have accumulated history, flush it and update listeners
this._flushHistory() this._flushHistory()
}, },
{ scheduleEffect: (cb) => throttleToNextFrame(cb) } { scheduleEffect: (cb) => (this.cancelHistoryReactor = throttleToNextFrame(cb)) }
) )
this.scopedTypes = { this.scopedTypes = {
document: new Set( document: new Set(
@ -264,6 +273,10 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
} }
} }
dispose() {
this.cancelHistoryReactor()
}
/** /**
* Filters out non-document changes from a diff. Returns null if there are no changes left. * Filters out non-document changes from a diff. Returns null if there are no changes left.
* @param change - the records diff * @param change - the records diff

View file

@ -186,7 +186,13 @@ export const TldrawImage = memo(function TldrawImage(props: TldrawImageProps) {
return ( return (
<div ref={setContainer} style={{ position: 'relative', width: '100%', height: '100%' }}> <div ref={setContainer} style={{ position: 'relative', width: '100%', height: '100%' }}>
{url && <img src={url} style={{ width: '100%', height: '100%' }} />} {url && (
<img
src={url}
referrerPolicy="strict-origin-when-cross-origin"
style={{ width: '100%', height: '100%' }}
/>
)}
</div> </div>
) )
}) })

View file

@ -16,6 +16,7 @@ import {
assert, assert,
compact, compact,
createShapeId, createShapeId,
fetch,
getHashForBuffer, getHashForBuffer,
getHashForString, getHashForString,
} from '@tldraw/editor' } from '@tldraw/editor'
@ -116,7 +117,6 @@ export function registerDefaultExternalContentHandlers(
const resp = await fetch(url, { const resp = await fetch(url, {
method: 'GET', method: 'GET',
mode: 'no-cors', mode: 'no-cors',
referrerPolicy: 'strict-origin-when-cross-origin',
}) })
const html = await resp.text() const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')

View file

@ -220,6 +220,8 @@ function updateBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmarkShape)
} }
const createBookmarkAssetOnUrlChange = debounce(async (editor: Editor, shape: TLBookmarkShape) => { const createBookmarkAssetOnUrlChange = debounce(async (editor: Editor, shape: TLBookmarkShape) => {
if (editor.isDisposed) return
const { url } = shape.props const { url } = shape.props
// Create the asset using the external content manager's createAssetFromUrl method. // Create the asset using the external content manager's createAssetFromUrl method.

View file

@ -3,11 +3,13 @@ import {
BaseBoxShapeUtil, BaseBoxShapeUtil,
FileHelpers, FileHelpers,
HTMLContainer, HTMLContainer,
Image,
MediaHelpers, MediaHelpers,
TLImageShape, TLImageShape,
TLOnDoubleClickHandler, TLOnDoubleClickHandler,
TLShapePartial, TLShapePartial,
Vec, Vec,
fetch,
imageShapeMigrations, imageShapeMigrations,
imageShapeProps, imageShapeProps,
structuredClone, structuredClone,
@ -19,7 +21,7 @@ import { HyperlinkButton } from '../shared/HyperlinkButton'
import { usePrefersReducedMotion } from '../shared/usePrefersReducedMotion' import { usePrefersReducedMotion } from '../shared/usePrefersReducedMotion'
async function getDataURIFromURL(url: string): Promise<string> { async function getDataURIFromURL(url: string): Promise<string> {
const response = await fetch(url, { referrerPolicy: 'strict-origin-when-cross-origin' }) const response = await fetch(url)
const blob = await response.blob() const blob = await response.blob()
return FileHelpers.blobToDataUrl(blob) return FileHelpers.blobToDataUrl(blob)
} }
@ -70,7 +72,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
const url = asset.props.src const url = asset.props.src
if (!url) return if (!url) return
const image = new Image() const image = Image()
image.onload = () => { image.onload = () => {
if (cancelled) return if (cancelled) return

View file

@ -9,6 +9,7 @@ import {
TLDefaultFontStyle, TLDefaultFontStyle,
TLShapeUtilCanvasSvgDef, TLShapeUtilCanvasSvgDef,
debugFlags, debugFlags,
fetch,
last, last,
useEditor, useEditor,
useValue, useValue,
@ -28,9 +29,7 @@ export function getFontDefForExport(fontStyle: TLDefaultFontStyle): SvgExportDef
const fontFaceRule: string = (font as any).$$_fontface const fontFaceRule: string = (font as any).$$_fontface
if (!url || !fontFaceRule) return null if (!url || !fontFaceRule) return null
const fontFile = await ( const fontFile = await (await fetch(url)).blob()
await fetch(url, { referrerPolicy: 'strict-origin-when-cross-origin' })
).blob()
const base64FontFile = await FileHelpers.blobToDataUrl(fontFile) const base64FontFile = await FileHelpers.blobToDataUrl(fontFile)
const newFontFaceRule = fontFaceRule.replace(url, base64FontFile) const newFontFaceRule = fontFaceRule.replace(url, base64FontFile)

View file

@ -40,7 +40,6 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
spellCheck="true" spellCheck="true"
wrap="off" wrap="off"
dir="auto" dir="auto"
datatype="wysiwyg"
defaultValue={text} defaultValue={text}
onFocus={handleFocus} onFocus={handleFocus}
onChange={handleChange} onChange={handleChange}

View file

@ -21,6 +21,10 @@ export class Idle extends StateNode {
this.editor.setCursor({ type: 'cross', rotation: 0 }) this.editor.setCursor({ type: 'cross', rotation: 0 })
} }
override onExit = () => {
updateHoveredShapeId.cancel()
}
override onKeyDown: TLEventHandlers['onKeyDown'] = (info) => { override onKeyDown: TLEventHandlers['onKeyDown'] = (info) => {
if (info.key === 'Enter') { if (info.key === 'Enter') {
if (this.editor.getInstanceState().isReadonly) return null if (this.editor.getInstanceState().isReadonly) return null

View file

@ -23,6 +23,8 @@ export class EditingShape extends StateNode {
// Clear the editing shape // Clear the editing shape
this.editor.setEditingShape(null) this.editor.setEditingShape(null)
updateHoveredShapeId.cancel()
const shape = this.editor.getShape(editingShapeId)! const shape = this.editor.getShape(editingShapeId)!
const util = this.editor.getShapeUtil(shape) const util = this.editor.getShapeUtil(shape)

View file

@ -41,6 +41,10 @@ export class Idle extends StateNode {
this.editor.setCursor({ type: 'default', rotation: 0 }) this.editor.setCursor({ type: 'default', rotation: 0 })
} }
override onExit = () => {
updateHoveredShapeId.cancel()
}
override onPointerMove: TLEventHandlers['onPointerMove'] = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
updateHoveredShapeId(this.editor) updateHoveredShapeId(this.editor)
} }

View file

@ -32,5 +32,7 @@ function _updateHoveredShapeId(editor: Editor) {
} }
/** @internal */ /** @internal */
export const updateHoveredShapeId = export const updateHoveredShapeId = throttle(
process.env.NODE_ENV === 'test' ? _updateHoveredShapeId : throttle(_updateHoveredShapeId, 32) _updateHoveredShapeId,
process.env.NODE_ENV === 'test' ? 0 : 32
)

View file

@ -240,7 +240,7 @@ export function ExampleDialog({
style={{ marginRight: 'auto' }} style={{ marginRight: 'auto' }}
> >
<TldrawUiButtonCheck checked={dontShowAgain} /> <TldrawUiButtonCheck checked={dontShowAgain} />
<TldrawUiButtonLabel>Don't show again</TldrawUiButtonLabel> <TldrawUiButtonLabel>Dont show again</TldrawUiButtonLabel>
</TldrawUiButton> </TldrawUiButton>
)} )}
<TldrawUiButton type="normal" onClick={onCancel}> <TldrawUiButton type="normal" onClick={onCancel}>

View file

@ -1,3 +1,4 @@
import { Image } from '@tldraw/editor'
import { createContext, useContext, useEffect } from 'react' import { createContext, useContext, useEffect } from 'react'
import { TLUiAssetUrls } from '../assetUrls' import { TLUiAssetUrls } from '../assetUrls'
@ -16,13 +17,13 @@ export function AssetUrlsProvider({
}) { }) {
useEffect(() => { useEffect(() => {
for (const src of Object.values(assetUrls.icons)) { for (const src of Object.values(assetUrls.icons)) {
const image = new Image() const image = Image()
image.referrerPolicy = 'strict-origin-when-cross-origin' image.referrerPolicy = 'strict-origin-when-cross-origin'
image.src = src image.src = src
image.decode() image.decode()
} }
for (const src of Object.values(assetUrls.embedIcons)) { for (const src of Object.values(assetUrls.embedIcons)) {
const image = new Image() const image = Image()
image.referrerPolicy = 'strict-origin-when-cross-origin' image.referrerPolicy = 'strict-origin-when-cross-origin'
image.src = src image.src = src
image.decode() image.decode()

View file

@ -1,4 +1,4 @@
import { Editor, TLExternalContentSource, VecLike } from '@tldraw/editor' import { Editor, TLExternalContentSource, VecLike, fetch } from '@tldraw/editor'
/** /**
* When the clipboard has a file, create an image shape from the file and paste it into the scene * When the clipboard has a file, create an image shape from the file and paste it into the scene
@ -14,12 +14,7 @@ export async function pasteFiles(
point?: VecLike, point?: VecLike,
sources?: TLExternalContentSource[] sources?: TLExternalContentSource[]
) { ) {
const blobs = await Promise.all( const blobs = await Promise.all(urls.map(async (url) => await (await fetch(url)).blob()))
urls.map(
async (url) =>
await (await fetch(url, { referrerPolicy: 'strict-origin-when-cross-origin' })).blob()
)
)
const files = blobs.map((blob) => new File([blob], 'tldrawFile', { type: blob.type })) const files = blobs.map((blob) => new File([blob], 'tldrawFile', { type: blob.type }))
editor.mark('paste') editor.mark('paste')

View file

@ -1,4 +1,4 @@
import { Editor, TLExternalContentSource, VecLike } from '@tldraw/editor' import { Editor, TLExternalContentSource, VecLike, fetch } from '@tldraw/editor'
import { pasteFiles } from './pasteFiles' import { pasteFiles } from './pasteFiles'
/** /**
@ -22,7 +22,6 @@ export async function pasteUrl(
if (new URL(url).pathname.match(/\.(png|jpe?g|gif|svg|webp)$/i)) { if (new URL(url).pathname.match(/\.(png|jpe?g|gif|svg|webp)$/i)) {
const resp = await fetch(url, { const resp = await fetch(url, {
method: 'HEAD', method: 'HEAD',
referrerPolicy: 'strict-origin-when-cross-origin',
}) })
if (resp.headers.get('content-type')?.match(/^image\//)) { if (resp.headers.get('content-type')?.match(/^image\//)) {
editor.mark('paste') editor.mark('paste')

View file

@ -1,4 +1,4 @@
import { LANGUAGES } from '@tldraw/editor' import { LANGUAGES, fetch } from '@tldraw/editor'
import { TLUiAssetUrls } from '../../assetUrls' import { TLUiAssetUrls } from '../../assetUrls'
import { TLUiTranslationKey } from './TLUiTranslationKey' import { TLUiTranslationKey } from './TLUiTranslationKey'
import { DEFAULT_TRANSLATION } from './defaultTranslation' import { DEFAULT_TRANSLATION } from './defaultTranslation'

View file

@ -1,5 +1,6 @@
import { import {
Editor, Editor,
Image,
PngHelpers, PngHelpers,
TLShapeId, TLShapeId,
TLSvgOptions, TLSvgOptions,
@ -34,7 +35,7 @@ export async function getSvgAsImage(
const svgUrl = URL.createObjectURL(new Blob([svgString], { type: 'image/svg+xml' })) const svgUrl = URL.createObjectURL(new Blob([svgString], { type: 'image/svg+xml' }))
const canvas = await new Promise<HTMLCanvasElement | null>((resolve) => { const canvas = await new Promise<HTMLCanvasElement | null>((resolve) => {
const image = new Image() const image = Image()
image.crossOrigin = 'anonymous' image.crossOrigin = 'anonymous'
image.onload = async () => { image.onload = async () => {

View file

@ -24,6 +24,7 @@ import {
VecModel, VecModel,
clamp, clamp,
createShapeId, createShapeId,
fetch,
structuredClone, structuredClone,
} from '@tldraw/editor' } from '@tldraw/editor'
import { getArrowBindings } from '../../shapes/arrow/shared' import { getArrowBindings } from '../../shapes/arrow/shared'

View file

@ -17,6 +17,7 @@ import {
UnknownRecord, UnknownRecord,
createTLStore, createTLStore,
exhaustiveSwitchError, exhaustiveSwitchError,
fetch,
partition, partition,
} from '@tldraw/editor' } from '@tldraw/editor'
import { TLUiToastsContextType } from '../../ui/context/toasts' import { TLUiToastsContextType } from '../../ui/context/toasts'
@ -188,9 +189,7 @@ export async function serializeTldrawJson(store: TLStore): Promise<string> {
try { try {
// try to save the asset as a base64 string // try to save the asset as a base64 string
assetSrcToSave = await FileHelpers.blobToDataUrl( assetSrcToSave = await FileHelpers.blobToDataUrl(
await ( await (await fetch(record.props.src)).blob()
await fetch(record.props.src, { referrerPolicy: 'strict-origin-when-cross-origin' })
).blob()
) )
} catch { } catch {
// if that fails, just save the original src // if that fails, just save the original src

View file

@ -417,6 +417,8 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
close() { close() {
this.debug('closing') this.debug('closing')
this.disposables.forEach((dispose) => dispose()) this.disposables.forEach((dispose) => dispose())
this.flushPendingPushRequests.cancel?.()
this.scheduleRebase.cancel?.()
} }
lastPushedPresenceState: R | null = null lastPushedPresenceState: R | null = null

View file

@ -4,6 +4,9 @@
```ts ```ts
import { default as throttle } from 'lodash.throttle';
import { default as uniq } from 'lodash.uniq';
// @internal // @internal
export function annotateError(error: unknown, annotations: Partial<ErrorAnnotations>): void; export function annotateError(error: unknown, annotations: Partial<ErrorAnnotations>): void;
@ -76,6 +79,10 @@ export type Expand<T> = T extends infer O ? {
[K in keyof O]: O[K]; [K in keyof O]: O[K];
} : never; } : never;
// @internal
function fetch_2(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
export { fetch_2 as fetch }
// @public // @public
export class FileHelpers { export class FileHelpers {
static blobToDataUrl(file: Blob): Promise<string>; static blobToDataUrl(file: Blob): Promise<string>;
@ -92,7 +99,13 @@ export function filterEntries<Key extends string, Value>(object: {
}; };
// @internal // @internal
export function fpsThrottle(fn: () => void): () => void; export function fpsThrottle(fn: {
(): void;
cancel?(): void;
}): {
(): void;
cancel?(): void;
};
// @internal (undocumented) // @internal (undocumented)
export function getErrorAnnotations(error: Error): ErrorAnnotations; export function getErrorAnnotations(error: Error): ErrorAnnotations;
@ -145,6 +158,10 @@ export function getOwnProperty(obj: object, key: string): unknown;
// @internal (undocumented) // @internal (undocumented)
export function hasOwnProperty(obj: object, key: string): boolean; export function hasOwnProperty(obj: object, key: string): boolean;
// @internal
const Image_2: (width?: number, height?: number) => HTMLImageElement;
export { Image_2 as Image }
// @public // @public
export type IndexKey = string & { export type IndexKey = string & {
__orderKey: true; __orderKey: true;
@ -367,8 +384,7 @@ export const STRUCTURED_CLONE_OBJECT_PROTOTYPE: any;
const structuredClone_2: <T>(i: T) => T; const structuredClone_2: <T>(i: T) => T;
export { structuredClone_2 as structuredClone } export { structuredClone_2 as structuredClone }
// @public export { throttle }
export function throttle<T extends (...args: any) => any>(func: T, limit: number): (...args: Parameters<T>) => ReturnType<T>;
// @internal // @internal
export function throttleToNextFrame(fn: () => void): () => void; export function throttleToNextFrame(fn: () => void): () => void;
@ -385,6 +401,8 @@ export class Timers {
setTimeout(handler: TimerHandler, timeout?: number, ...args: any[]): number; setTimeout(handler: TimerHandler, timeout?: number, ...args: any[]): number;
} }
export { uniq }
// @internal (undocumented) // @internal (undocumented)
export function validateIndexKey(key: string): asserts key is IndexKey; export function validateIndexKey(key: string): asserts key is IndexKey;

View file

@ -49,7 +49,13 @@
"^~(.*)": "<rootDir>/src/$1" "^~(.*)": "<rootDir>/src/$1"
} }
}, },
"dependencies": {
"lodash.throttle": "^4.1.1",
"lodash.uniq": "^4.5.0"
},
"devDependencies": { "devDependencies": {
"@types/lodash.throttle": "^4.1.7",
"@types/lodash.uniq": "^4.5.7",
"jest-environment-jsdom": "^29.4.3", "jest-environment-jsdom": "^29.4.3",
"lazyrepo": "0.0.0-alpha.27" "lazyrepo": "0.0.0-alpha.27"
} }

View file

@ -1,3 +1,5 @@
export { default as throttle } from 'lodash.throttle'
export { default as uniq } from 'lodash.uniq'
export { PerformanceTracker } from './lib/PerformanceTracker' export { PerformanceTracker } from './lib/PerformanceTracker'
export { export {
areArraysShallowEqual, areArraysShallowEqual,
@ -21,7 +23,7 @@ export {
export { debounce } from './lib/debounce' export { debounce } from './lib/debounce'
export { annotateError, getErrorAnnotations, type ErrorAnnotations } from './lib/error' export { annotateError, getErrorAnnotations, type ErrorAnnotations } from './lib/error'
export { FileHelpers } from './lib/file' export { FileHelpers } from './lib/file'
export { noop, omitFromStackTrace, throttle } from './lib/function' export { noop, omitFromStackTrace } from './lib/function'
export { getHashForBuffer, getHashForObject, getHashForString, lns } from './lib/hash' export { getHashForBuffer, getHashForObject, getHashForString, lns } from './lib/hash'
export { getFirstFromIterable } from './lib/iterable' export { getFirstFromIterable } from './lib/iterable'
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value' export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
@ -32,6 +34,7 @@ export {
MediaHelpers, MediaHelpers,
} from './lib/media/media' } from './lib/media/media'
export { PngHelpers } from './lib/media/png' export { PngHelpers } from './lib/media/png'
export { Image, fetch } from './lib/network'
export { invLerp, lerp, modulate, rng } from './lib/number' export { invLerp, lerp, modulate, rng } from './lib/number'
export { export {
areObjectsShallowEqual, areObjectsShallowEqual,

View file

@ -11,6 +11,7 @@ export class PerformanceTracker {
recordFrame = () => { recordFrame = () => {
this.frames++ this.frames++
if (!this.started) return if (!this.started) return
// eslint-disable-next-line no-restricted-globals
this.frame = requestAnimationFrame(this.recordFrame) this.frame = requestAnimationFrame(this.recordFrame)
} }
@ -19,6 +20,7 @@ export class PerformanceTracker {
this.frames = 0 this.frames = 0
this.started = true this.started = true
if (this.frame !== null) cancelAnimationFrame(this.frame) if (this.frame !== null) cancelAnimationFrame(this.frame)
// eslint-disable-next-line no-restricted-globals
this.frame = requestAnimationFrame(this.recordFrame) this.frame = requestAnimationFrame(this.recordFrame)
this.startTime = performance.now() this.startTime = performance.now()
} }

View file

@ -17,6 +17,7 @@ export function debounce<T extends unknown[], U>(
let state: let state:
| undefined | undefined
| { | {
// eslint-disable-next-line no-restricted-globals
timeout: ReturnType<typeof setTimeout> timeout: ReturnType<typeof setTimeout>
promise: Promise<U> promise: Promise<U>
resolve: (value: U | PromiseLike<U>) => void resolve: (value: U | PromiseLike<U>) => void
@ -34,6 +35,8 @@ export function debounce<T extends unknown[], U>(
} }
clearTimeout(state!.timeout) clearTimeout(state!.timeout)
state!.latestArgs = args state!.latestArgs = args
// It's up to the consumer of debounce to call `cancel`
// eslint-disable-next-line no-restricted-globals
state!.timeout = setTimeout(() => { state!.timeout = setTimeout(() => {
const s = state! const s = state!
state = undefined state = undefined

View file

@ -1,3 +1,5 @@
import { fetch } from './network'
/** /**
* Helpers for files * Helpers for files
* *
@ -10,11 +12,9 @@ export class FileHelpers {
* from https://stackoverflow.com/a/53817185 * from https://stackoverflow.com/a/53817185
*/ */
static async dataUrlToArrayBuffer(dataURL: string) { static async dataUrlToArrayBuffer(dataURL: string) {
return fetch(dataURL, { referrerPolicy: 'strict-origin-when-cross-origin' }).then( return fetch(dataURL).then(function (result) {
function (result) { return result.arrayBuffer()
return result.arrayBuffer() })
}
)
} }
/** /**

View file

@ -1,31 +1,3 @@
/**
* Throttle a function.
*
* @example
*
* ```ts
* const A = throttle(myFunction, 1000)
* ```
*
* @public
* @see source - https://github.com/bameyrick/throttle-typescript
*/
export function throttle<T extends (...args: any) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => ReturnType<T> {
let inThrottle: boolean
let lastResult: ReturnType<T>
return function (this: any, ...args: any[]): ReturnType<T> {
if (!inThrottle) {
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
lastResult = func(...args)
}
return lastResult
}
}
/** /**
* When a function is wrapped in `omitFromStackTrace`, if it throws an error the stack trace won't * When a function is wrapped in `omitFromStackTrace`, if it throws an error the stack trace won't
* include the function itself or any stack frames above it. Useful for assertion-style function * include the function itself or any stack frames above it. Useful for assertion-style function

View file

@ -1,3 +1,4 @@
import { Image } from '../network'
import { isApngAnimated } from './apng' import { isApngAnimated } from './apng'
import { isAvifAnimated } from './avif' import { isAvifAnimated } from './avif'
import { isGifAnimated } from './gif' import { isGifAnimated } from './gif'
@ -65,7 +66,7 @@ export class MediaHelpers {
*/ */
static loadImage(src: string): Promise<HTMLImageElement> { static loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image() const img = Image()
img.onload = () => resolve(img) img.onload = () => resolve(img)
img.onerror = (e) => { img.onerror = (e) => {
console.error(e) console.error(e)

View file

@ -0,0 +1,26 @@
/**
* Just a wrapper around `window.fetch` that sets the `referrerPolicy` to `strict-origin-when-cross-origin`.
*
* @internal
*/
export async function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
// eslint-disable-next-line no-restricted-properties
return window.fetch(input, {
// We want to make sure that the referrer is not sent to other domains.
referrerPolicy: 'strict-origin-when-cross-origin',
...init,
})
}
/**
* Just a wrapper around `new Image`, and yeah, it's a bit strange that it's in the network.ts file
* but the main concern here is the referrerPolicy and setting it correctly.
*
* @internal
*/
export const Image = (width?: number, height?: number) => {
// eslint-disable-next-line no-restricted-properties
const img = new window.Image(width, height)
img.referrerPolicy = 'strict-origin-when-cross-origin'
return img
}

View file

@ -26,12 +26,16 @@ function tick() {
const elapsed = now - last const elapsed = now - last
if (time + elapsed < targetTimePerFrame) { if (time + elapsed < targetTimePerFrame) {
// It's up to the consumer of debounce to call `cancel`
// eslint-disable-next-line no-restricted-globals
frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => {
frame = undefined frame = undefined
tick() tick()
}) })
return return
} }
// It's up to the consumer of debounce to call `cancel`
// eslint-disable-next-line no-restricted-globals
frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => {
frame = undefined frame = undefined
last = now last = now
@ -51,12 +55,16 @@ let started = false
* @returns * @returns
* @internal * @internal
*/ */
export function fpsThrottle(fn: () => void) { export function fpsThrottle(fn: { (): void; cancel?(): void }): {
(): void
cancel?(): void
} {
if (isTest()) { if (isTest()) {
fn.cancel = () => frame && cancelAnimationFrame(frame)
return fn return fn
} }
return () => { const throttledFn = () => {
if (fpsQueue.includes(fn)) { if (fpsQueue.includes(fn)) {
return return
} }
@ -68,6 +76,13 @@ export function fpsThrottle(fn: () => void) {
} }
tick() tick()
} }
throttledFn.cancel = () => {
const index = fpsQueue.indexOf(fn)
if (index > -1) {
fpsQueue.splice(index, 1)
}
}
return throttledFn
} }
/** /**

View file

@ -1,3 +1,5 @@
/* eslint-disable no-restricted-properties */
/** @public */ /** @public */
export class Timers { export class Timers {
private timeouts: number[] = [] private timeouts: number[] = []

View file

@ -6,6 +6,8 @@ import { nicelog } from './lib/nicelog'
import { getAllWorkspacePackages } from './lib/workspace' import { getAllWorkspacePackages } from './lib/workspace'
const packagesOurTypesCanDependOn = [ const packagesOurTypesCanDependOn = [
'@types/lodash.throttle',
'@types/lodash.uniq',
'@types/react', '@types/react',
'@types/react-dom', '@types/react-dom',
'eventemitter3', 'eventemitter3',

View file

@ -6129,8 +6129,6 @@ __metadata:
"@tldraw/validate": "workspace:*" "@tldraw/validate": "workspace:*"
"@types/benchmark": "npm:^2.1.2" "@types/benchmark": "npm:^2.1.2"
"@types/core-js": "npm:^2.5.5" "@types/core-js": "npm:^2.5.5"
"@types/lodash.throttle": "npm:^4.1.7"
"@types/lodash.uniq": "npm:^4.5.7"
"@types/react-test-renderer": "npm:^18.0.0" "@types/react-test-renderer": "npm:^18.0.0"
"@types/wicg-file-system-access": "npm:^2020.9.5" "@types/wicg-file-system-access": "npm:^2020.9.5"
"@use-gesture/react": "npm:^10.2.27" "@use-gesture/react": "npm:^10.2.27"
@ -6144,8 +6142,6 @@ __metadata:
jest-canvas-mock: "npm:^2.5.2" jest-canvas-mock: "npm:^2.5.2"
jest-environment-jsdom: "npm:^29.4.3" jest-environment-jsdom: "npm:^29.4.3"
lazyrepo: "npm:0.0.0-alpha.27" lazyrepo: "npm:0.0.0-alpha.27"
lodash.throttle: "npm:^4.1.1"
lodash.uniq: "npm:^4.5.0"
nanoid: "npm:4.0.2" nanoid: "npm:4.0.2"
react-test-renderer: "npm:^18.2.0" react-test-renderer: "npm:^18.2.0"
resize-observer-polyfill: "npm:^1.5.1" resize-observer-polyfill: "npm:^1.5.1"
@ -6322,8 +6318,12 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@tldraw/utils@workspace:packages/utils" resolution: "@tldraw/utils@workspace:packages/utils"
dependencies: dependencies:
"@types/lodash.throttle": "npm:^4.1.7"
"@types/lodash.uniq": "npm:^4.5.7"
jest-environment-jsdom: "npm:^29.4.3" jest-environment-jsdom: "npm:^29.4.3"
lazyrepo: "npm:0.0.0-alpha.27" lazyrepo: "npm:0.0.0-alpha.27"
lodash.throttle: "npm:^4.1.1"
lodash.uniq: "npm:^4.5.0"
languageName: unknown languageName: unknown
linkType: soft linkType: soft