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: [
'prettier',
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@next/next/core-web-vitals',
],
@ -12,6 +13,7 @@ module.exports = {
'import',
'local',
'@next/next',
'react',
'react-hooks',
'deprecation',
],
@ -23,15 +25,17 @@ module.exports = {
rules: {
'deprecation/deprecation': 'error',
'@next/next/no-html-link-for-pages': 'off',
'react/jsx-key': 'off',
'no-non-null-assertion': 'off',
'no-fallthrough': 'off',
'react/jsx-no-target-blank': 'error',
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-fallthrough': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'react/display-name': 'off',
'@next/next/no-img-element': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-extra-semi': 'off',
'no-mixed-spaces-and-tabs': 'off',
'@typescript-eslint/no-unused-vars': [
@ -86,39 +90,107 @@ module.exports = {
},
},
{
files: ['packages/editor/**/*', 'packages/tldraw/**/*'],
files: ['packages/editor/**/*', 'packages/tldraw/**/*', 'packages/utils/**/*'],
rules: {
'no-restricted-globals': [
'error',
{
name: 'setInterval',
message: 'Use the timers from @tldraw/util instead.',
name: 'fetch',
message: 'Use the fetch from @tldraw/util instead.',
},
{
name: 'Image',
message: 'Use the Image from @tldraw/util instead.',
},
{
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',
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': [
'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',
property: 'setTimeout',
message: 'Use the timers from @tldraw/util instead.',
message: 'Use the timers from editor.timers instead.',
},
{
object: 'window',
property: 'setInterval',
message: 'Use the timers from @tldraw/util instead.',
message: 'Use the timers from editor.timers instead.',
},
{
object: 'window',
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: {
'no-restricted-properties': '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.{' '}
</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>
"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
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
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
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>
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
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
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,
non-infringement, merchantability, or fitness for a particular purpose.{' '}
</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.
</p>
<p>
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
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
such phrase is not used to promote the derivative works or to imply that tldraw endorses
you or the derivative works.
</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
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.

View file

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

View file

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

View file

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

View file

@ -62,7 +62,7 @@ export function IFrameProtector({
return (
<div className="tldraw__editor tl-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'}
<svg
width="15"

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
import { fetch } from 'tldraw'
import { BoardHistoryLog } from '../components/BoardHistoryLog/BoardHistoryLog'
import { ErrorPage } from '../components/ErrorPage/ErrorPage'
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 { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
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 { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { TldrawUiButton } from 'tldraw'
import { TldrawUiButton, fetch } from 'tldraw'
import '../../styles/globals.css'
import { getParentOrigin } from '../utils/iFrame'

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import {
MediaHelpers,
TLAsset,
TLAssetId,
fetch,
getHashForString,
uniqueId,
} from 'tldraw'
@ -18,7 +19,6 @@ export async function createAssetFromFile({ file }: { type: 'file'; file: File }
await fetch(url, {
method: 'POST',
body: file,
referrerPolicy: 'strict-origin-when-cross-origin',
})
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'
interface ResponseBody {
@ -45,7 +45,6 @@ export async function createAssetFromUrl({ url }: { type: 'url'; url: string }):
const resp = await fetch(url, {
method: 'GET',
mode: 'no-cors',
referrerPolicy: 'strict-origin-when-cross-origin',
})
const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html')

View file

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

View file

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

View file

@ -45,6 +45,7 @@ export function ExamplePage({
<a
target="_blank"
href="https://twitter.com/tldraw"
rel="noopener noreferrer"
title="twitter"
className="hoverable"
>
@ -53,6 +54,7 @@ export function ExamplePage({
<a
target="_blank"
href="https://github.com/tldraw/tldraw"
rel="noopener noreferrer"
title="github"
className="hoverable"
>
@ -61,6 +63,7 @@ export function ExamplePage({
<a
target="_blank"
href="https://discord.com/invite/SBBEVCA4PG"
rel="noopener noreferrer"
title="discord"
className="hoverable"
>
@ -104,6 +107,7 @@ export function ExamplePage({
<a
className="example__sidebar__footer-link example__sidebar__footer-link--grey"
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+"
>
Request an example
@ -111,6 +115,7 @@ export function ExamplePage({
<a
className="example__sidebar__footer-link example__sidebar__footer-link--grey"
target="_blank"
rel="noopener noreferrer"
href="https://tldraw.dev"
>
Visit the docs

View file

@ -42,10 +42,10 @@ function ControlledFocusExample() {
</div>
</div>
<p>
The checkbox controls the editor's <code>instanceState.isFocused</code> property.
The checkbox controls the editors <code>instanceState.isFocused</code> property.
</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.
</p>
<div style={{ width: 800, maxWidth: '100%', height: 500 }}>
@ -67,7 +67,7 @@ function FreeFocusExample() {
<div>
<h2>Free Focus</h2>
<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.
</p>
<div

View file

@ -1,5 +1,6 @@
import { throttle } from 'lodash'
import { useLayoutEffect, useState } from 'react'
import { Tldraw, createTLStore, getSnapshot, loadSnapshot, throttle } from 'tldraw'
import { Tldraw, createTLStore, getSnapshot, loadSnapshot } from 'tldraw'
import 'tldraw/tldraw.css'
// 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
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
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
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.
</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
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
@ -169,7 +169,7 @@ function ABunchOfText() {
merged into a harmonious symphony.
</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
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
@ -179,7 +179,7 @@ function ABunchOfText() {
take shape.
</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
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

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)',
}}
>
<p>This won't scale with zoom.</p>
<p>This wont scale with zoom.</p>
</div>
)
})

View file

@ -1,7 +1,7 @@
export default function YjsExample() {
return (
<div className="tldraw__editor">
We've moved! See{' '}
Weve moved! See{' '}
<a href="https://github.com/tldraw/tldraw-yjs-example">
https://github.com/tldraw/tldraw-yjs-example
</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'
export async function onCreateAssetFromUrl({
@ -30,7 +30,6 @@ export async function onCreateAssetFromUrl({
const resp = await fetch(url, {
method: 'GET',
mode: 'no-cors',
referrerPolicy: 'strict-origin-when-cross-origin',
})
const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html')

View file

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

View file

@ -57,8 +57,6 @@
"eventemitter3": "^4.0.7",
"idb": "^7.1.1",
"is-plain-object": "^5.0.0",
"lodash.throttle": "^4.1.1",
"lodash.uniq": "^4.5.0",
"nanoid": "4.0.2"
},
"peerDependencies": {
@ -70,8 +68,6 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@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/wicg-file-system-access": "^2020.9.5",
"benchmark": "^2.1.4",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -348,6 +348,8 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
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>;
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)
ensureStoreIsUsable(): void;
extractingChanges(fn: () => void): RecordsDiff<R>;

View file

@ -162,6 +162,15 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
*/
private historyReactor: Reactor
/**
* Function to dispose of any in-flight timeouts.
*
* @internal
*/
private cancelHistoryReactor: () => void = () => {
/* noop */
}
readonly schema: StoreSchema<R, 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
this._flushHistory()
},
{ scheduleEffect: (cb) => throttleToNextFrame(cb) }
{ scheduleEffect: (cb) => (this.cancelHistoryReactor = throttleToNextFrame(cb)) }
)
this.scopedTypes = {
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.
* @param change - the records diff

View file

@ -186,7 +186,13 @@ export const TldrawImage = memo(function TldrawImage(props: TldrawImageProps) {
return (
<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>
)
})

View file

@ -16,6 +16,7 @@ import {
assert,
compact,
createShapeId,
fetch,
getHashForBuffer,
getHashForString,
} from '@tldraw/editor'
@ -116,7 +117,6 @@ export function registerDefaultExternalContentHandlers(
const resp = await fetch(url, {
method: 'GET',
mode: 'no-cors',
referrerPolicy: 'strict-origin-when-cross-origin',
})
const html = await resp.text()
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) => {
if (editor.isDisposed) return
const { url } = shape.props
// Create the asset using the external content manager's createAssetFromUrl method.

View file

@ -3,11 +3,13 @@ import {
BaseBoxShapeUtil,
FileHelpers,
HTMLContainer,
Image,
MediaHelpers,
TLImageShape,
TLOnDoubleClickHandler,
TLShapePartial,
Vec,
fetch,
imageShapeMigrations,
imageShapeProps,
structuredClone,
@ -19,7 +21,7 @@ import { HyperlinkButton } from '../shared/HyperlinkButton'
import { usePrefersReducedMotion } from '../shared/usePrefersReducedMotion'
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()
return FileHelpers.blobToDataUrl(blob)
}
@ -70,7 +72,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
const url = asset.props.src
if (!url) return
const image = new Image()
const image = Image()
image.onload = () => {
if (cancelled) return

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import { Image } from '@tldraw/editor'
import { createContext, useContext, useEffect } from 'react'
import { TLUiAssetUrls } from '../assetUrls'
@ -16,13 +17,13 @@ export function AssetUrlsProvider({
}) {
useEffect(() => {
for (const src of Object.values(assetUrls.icons)) {
const image = new Image()
const image = Image()
image.referrerPolicy = 'strict-origin-when-cross-origin'
image.src = src
image.decode()
}
for (const src of Object.values(assetUrls.embedIcons)) {
const image = new Image()
const image = Image()
image.referrerPolicy = 'strict-origin-when-cross-origin'
image.src = src
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
@ -14,12 +14,7 @@ export async function pasteFiles(
point?: VecLike,
sources?: TLExternalContentSource[]
) {
const blobs = await Promise.all(
urls.map(
async (url) =>
await (await fetch(url, { referrerPolicy: 'strict-origin-when-cross-origin' })).blob()
)
)
const blobs = await Promise.all(urls.map(async (url) => await (await fetch(url)).blob()))
const files = blobs.map((blob) => new File([blob], 'tldrawFile', { type: blob.type }))
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'
/**
@ -22,7 +22,6 @@ export async function pasteUrl(
if (new URL(url).pathname.match(/\.(png|jpe?g|gif|svg|webp)$/i)) {
const resp = await fetch(url, {
method: 'HEAD',
referrerPolicy: 'strict-origin-when-cross-origin',
})
if (resp.headers.get('content-type')?.match(/^image\//)) {
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 { TLUiTranslationKey } from './TLUiTranslationKey'
import { DEFAULT_TRANSLATION } from './defaultTranslation'

View file

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

View file

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

View file

@ -17,6 +17,7 @@ import {
UnknownRecord,
createTLStore,
exhaustiveSwitchError,
fetch,
partition,
} from '@tldraw/editor'
import { TLUiToastsContextType } from '../../ui/context/toasts'
@ -188,9 +189,7 @@ export async function serializeTldrawJson(store: TLStore): Promise<string> {
try {
// try to save the asset as a base64 string
assetSrcToSave = await FileHelpers.blobToDataUrl(
await (
await fetch(record.props.src, { referrerPolicy: 'strict-origin-when-cross-origin' })
).blob()
await (await fetch(record.props.src)).blob()
)
} catch {
// 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() {
this.debug('closing')
this.disposables.forEach((dispose) => dispose())
this.flushPendingPushRequests.cancel?.()
this.scheduleRebase.cancel?.()
}
lastPushedPresenceState: R | null = null

View file

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

View file

@ -49,7 +49,13 @@
"^~(.*)": "<rootDir>/src/$1"
}
},
"dependencies": {
"lodash.throttle": "^4.1.1",
"lodash.uniq": "^4.5.0"
},
"devDependencies": {
"@types/lodash.throttle": "^4.1.7",
"@types/lodash.uniq": "^4.5.7",
"jest-environment-jsdom": "^29.4.3",
"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 {
areArraysShallowEqual,
@ -21,7 +23,7 @@ export {
export { debounce } from './lib/debounce'
export { annotateError, getErrorAnnotations, type ErrorAnnotations } from './lib/error'
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 { getFirstFromIterable } from './lib/iterable'
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
@ -32,6 +34,7 @@ export {
MediaHelpers,
} from './lib/media/media'
export { PngHelpers } from './lib/media/png'
export { Image, fetch } from './lib/network'
export { invLerp, lerp, modulate, rng } from './lib/number'
export {
areObjectsShallowEqual,

View file

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

View file

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

View file

@ -1,3 +1,5 @@
import { fetch } from './network'
/**
* Helpers for files
*
@ -10,11 +12,9 @@ export class FileHelpers {
* from https://stackoverflow.com/a/53817185
*/
static async dataUrlToArrayBuffer(dataURL: string) {
return fetch(dataURL, { referrerPolicy: 'strict-origin-when-cross-origin' }).then(
function (result) {
return fetch(dataURL).then(function (result) {
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
* 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 { isAvifAnimated } from './avif'
import { isGifAnimated } from './gif'
@ -65,7 +66,7 @@ export class MediaHelpers {
*/
static loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
const img = Image()
img.onload = () => resolve(img)
img.onerror = (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
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 = undefined
tick()
})
return
}
// It's up to the consumer of debounce to call `cancel`
// eslint-disable-next-line no-restricted-globals
frame = requestAnimationFrame(() => {
frame = undefined
last = now
@ -51,12 +55,16 @@ let started = false
* @returns
* @internal
*/
export function fpsThrottle(fn: () => void) {
export function fpsThrottle(fn: { (): void; cancel?(): void }): {
(): void
cancel?(): void
} {
if (isTest()) {
fn.cancel = () => frame && cancelAnimationFrame(frame)
return fn
}
return () => {
const throttledFn = () => {
if (fpsQueue.includes(fn)) {
return
}
@ -68,6 +76,13 @@ export function fpsThrottle(fn: () => void) {
}
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 */
export class Timers {
private timeouts: number[] = []

View file

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

View file

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