Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/docs/components/common/tldraw-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@ import { useEffect, useState } from 'react'
export function TldrawLink(props: React.ComponentProps<'a'>) {
const { href, children } = props
const windowObject = typeof window !== 'undefined' ? window : null
const [sessionId, setSessionId] = useState(windowObject?.posthog?.get_session_id())
const [distinctId, setDistinctId] = useState(windowObject?.posthog?.get_distinct_id())
// Start empty so the first client render matches the server HTML — reading
// posthog during render appends `#session_id` to hrefs before hydration and
// causes a hydration mismatch whenever posthog loads first.
const [sessionId, setSessionId] = useState<string | undefined>(undefined)
const [distinctId, setDistinctId] = useState<string | undefined>(undefined)

useEffect(() => {
// Note this href check is intentionally loose, just to avoid
// doing this setInterval for every link. We do the real check below.
if (sessionId || !href?.includes('tldraw.com')) return

if (windowObject?.posthog) {
setSessionId(windowObject.posthog.get_session_id())
setDistinctId(windowObject.posthog.get_distinct_id())
return
}

// XXX: have to wait a bit for posthog to be ready.
// there's unfortunately no event callback for this.
const timeout = setInterval(() => {
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/components/content/feature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export function Feature({
<Icon icon={icon} className="h-5 w-5 text-zinc-900 dark:text-zinc-100" />
<h3 className="font-bold text-zinc-900 dark:text-zinc-100">{title}</h3>
</div>
<p className="leading-relaxed text-zinc-600 dark:text-zinc-400">{children}</p>
{/* MDX renders block children as their own `<p>`s, so this wrapper must not be a `<p>` — nested `<p>`s break hydration. */}
<div className="leading-relaxed text-zinc-600 dark:text-zinc-400">{children}</div>
</div>
)
}
6 changes: 1 addition & 5 deletions apps/docs/content/starter-kits/agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,7 @@ With its default configuration, the agent can perform the following actions:

### Architecture

<img
src="/images/starter-kits/chart-agent.png"
alt="Flowchart showing how user input is processed by an agent, which gathers a canvas screenshot, analyzes shapes, and builds context. These feed into an AI model, which performs further shape analysis, updates the canvas, and produces results that go back to the user as feedback."
style={{ maxWidth: '400px', height: 'auto', display: 'block', margin: '0 auto' }}
/>
![Flowchart showing how user input is processed by an agent, which gathers a canvas screenshot, analyzes shapes, and builds context. These feed into an AI model, which performs further shape analysis, updates the canvas, and produces results that go back to the user as feedback.](/images/starter-kits/chart-agent.png)

### 1. User input

Expand Down
4 changes: 3 additions & 1 deletion apps/docs/scripts/lib/getApiMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,9 @@ function getItemTitle(item: ApiItem) {
return 'Constructor'
}

const name = item.displayName
// Escape square brackets so a name like `[Symbol.iterator]` followed by the
// `(\u00A0)` call parens below doesn't parse as a markdown link.
const name = item.displayName.replaceAll('[', '\\[').replaceAll(']', '\\]')
if (item.kind === ApiItemKind.Method || item.kind === ApiItemKind.Function) {
return `${name}(\u00A0)`
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,17 @@ const pixelFont: TLThemeFont = {
],
}

// Custom font — use a Google Font loaded via full URLs.
// Custom font — use a Google Font loaded via full URLs. These versioned
// gstatic URLs expire when Google revs the font; refresh them from
// https://fonts.googleapis.com/css2?family=Comic+Neue:wght@400;700 if they 404.
const cursiveFont: TLThemeFont = {
fontFamily: "'Comic Neue', cursive",
icon: <div style={{ fontFamily: "'Comic Neue', cursive", fontSize: 16, lineHeight: 1 }}>Aa</div>,
faces: [
{
family: 'Comic Neue',
src: {
url: 'https://fonts.gstatic.com/s/comicneue/v8/4UaErEJDsxBrF37olUeD_wHLwpteLwtHJlc.woff2',
url: 'https://fonts.gstatic.com/s/comicneue/v9/4UaHrEJDsxBrF37olUeD96rp57F2IwM.woff2',
format: 'woff2',
},
weight: 'normal',
Expand All @@ -96,7 +98,7 @@ const cursiveFont: TLThemeFont = {
{
family: 'Comic Neue',
src: {
url: 'https://fonts.gstatic.com/s/comicneue/v8/4UaFrEJDsxBrF37olUeD96_RTplUKylCNlcw_Q.woff2',
url: 'https://fonts.gstatic.com/s/comicneue/v9/4UaErEJDsxBrF37olUeD_xHM8pxULilENlY.woff2',
format: 'woff2',
},
weight: 'bold',
Expand Down
56 changes: 56 additions & 0 deletions packages/editor/src/lib/utils/sync/TLLocalSyncClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,59 @@ test('writes that come in during a persist operation will get persisted afterwar
await tick()
expect(client.db.storeChanges).toHaveBeenCalledTimes(1)
})

test('pagehide flushes pending changes without waiting for the throttle', async () => {
const { client, tick } = testClient()
await tick()
client.store.put([PageRecordType.create({ name: 'test', index: 'a0' as IndexKey })])
await tick()
expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)

client.store.put([PageRecordType.create({ name: 'test2', index: 'a1' as IndexKey })])
expect(client.db.storeChanges).not.toHaveBeenCalled()

window.dispatchEvent(new Event('pagehide'))
expect(client.db.storeChanges).toHaveBeenCalledTimes(1)
})

test('hiding the tab flushes pending changes without waiting for the throttle', async () => {
const { client, tick } = testClient()
await tick()
client.store.put([PageRecordType.create({ name: 'test', index: 'a0' as IndexKey })])
await tick()
expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)

client.store.put([PageRecordType.create({ name: 'test2', index: 'a1' as IndexKey })])
expect(client.db.storeChanges).not.toHaveBeenCalled()

const visibilityState = vi.spyOn(document, 'visibilityState', 'get')
try {
visibilityState.mockReturnValue('hidden')
document.dispatchEvent(new Event('visibilitychange'))
expect(client.db.storeChanges).toHaveBeenCalledTimes(1)
} finally {
visibilityState.mockRestore()
}
})

test('pagehide does not write before the initial load has completed', async () => {
const { client } = testClient()
window.dispatchEvent(new Event('pagehide'))
expect(client.db.storeSnapshot).not.toHaveBeenCalled()
expect(client.db.storeChanges).not.toHaveBeenCalled()
})

test('pagehide and visibilitychange listeners are removed when the client closes', async () => {
const removeWindowListener = vi.spyOn(window, 'removeEventListener')
const removeDocumentListener = vi.spyOn(document, 'removeEventListener')
try {
const { client, tick } = testClient()
await tick()
client.close()
expect(removeWindowListener).toHaveBeenCalledWith('pagehide', expect.any(Function))
expect(removeDocumentListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
} finally {
removeWindowListener.mockRestore()
removeDocumentListener.mockRestore()
}
})
24 changes: 24 additions & 0 deletions packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export class TLLocalSyncClient {
private disposables = new Set<() => void>()
private diffQueue: Array<RecordsDiff<UnknownRecord> | typeof UPDATE_INSTANCE_STATE> = []
private didDispose = false
private didLoad = false
private shouldDoFullDBWrite = true
private isReloading = false
readonly persistenceKey: string
Expand Down Expand Up @@ -143,6 +144,28 @@ export class TLLocalSyncClient {
)
)

if (typeof window !== 'undefined') {
// Persists are throttled, so without this the last PERSIST_THROTTLE_MS of edits are lost
// whenever the tab is closed or reloaded. `pagehide` is the last reliable event before
// unload on desktop; on mobile the page can be discarded without it firing, so we also
// flush when the tab is hidden.
const flush = () => {
// Before the initial load completes a full write would overwrite the saved
// document with an empty store.
if (!this.didLoad) return
this.persistIfNeeded()
}
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flush()
}
window.addEventListener('pagehide', flush)
document.addEventListener('visibilitychange', onVisibilityChange)
this.disposables.add(() => {
window.removeEventListener('pagehide', flush)
document.removeEventListener('visibilitychange', onVisibilityChange)
})
}

this.connect(onLoad, onLoadError)

this.documentTypes = new Set(
Expand Down Expand Up @@ -249,6 +272,7 @@ export class TLLocalSyncClient {
this.disposables.add(() => {
this.channel.close()
})
this.didLoad = true
onLoad(this)
} catch (e: any) {
this.debug('error loading data from store', e)
Expand Down
10 changes: 9 additions & 1 deletion packages/tlschema/src/TLStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createTLSchema } from './createTLSchema'
import { CameraRecordType } from './records/TLCamera'
import { TLDOCUMENT_ID } from './records/TLDocument'
import { TLINSTANCE_ID } from './records/TLInstance'
import { createInstanceRecordType, TLINSTANCE_ID } from './records/TLInstance'
import { PageRecordType, TLPageId } from './records/TLPage'
import { InstancePageStateRecordType } from './records/TLPageState'
import { TLPOINTER_ID } from './records/TLPointer'
Expand Down Expand Up @@ -439,6 +439,14 @@ describe('createIntegrityChecker', () => {
expect(instance!.exportBackground).toBe(true)
})

it('should default exportBackground to true when the instance omits it', () => {
const instance = createInstanceRecordType(new Map()).create({
id: TLINSTANCE_ID,
currentPageId: 'page:whatever' as TLPageId,
})
expect(instance.exportBackground).toBe(true)
})

it('should update instance to reference valid page when current page is invalid', () => {
// Create a valid page
const validPageId = 'page:valid' as TLPageId
Expand Down
1 change: 0 additions & 1 deletion packages/tlschema/src/TLStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,6 @@ export function createIntegrityChecker(store: Store<TLRecord, TLStoreProps>): ()
store.schema.types.instance.create({
id: TLINSTANCE_ID,
currentPageId: getFirstPageId(),
exportBackground: true,
}),
])

Expand Down
2 changes: 1 addition & 1 deletion packages/tlschema/src/records/TLInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
rotation: 0,
},
isFocusMode: false,
exportBackground: false,
exportBackground: true,
isDebugMode: false,
isToolLocked: false,
screenBounds: { x: 0, y: 0, w: 1080, h: 720 },
Expand Down
Loading