Skip to content

Commit a03de71

Browse files
steveruizokds300
andauthored
Unsaved changes examples (tldraw#6533)
This pull request introduces a new example for tracking and handling unsaved changes in `tldraw` documents. It keeps track of the set of diffs that have accumulated since the last save point and checks whether the diff is empty ### Change type - [ ] `bugfix` - [ ] `improvement` - [ ] `feature` - [x] `api` ### Release notes - Add an option to `squashRecordDiffs` to allow mutating the first diff instead of creating a new diff. ### API changes - Add an option to `squashRecordDiffs` to allow mutating the first diff instead of creating a new diff. --------- Co-authored-by: David Sheldrick <d.j.sheldrick@gmail.com>
1 parent f763b5a commit a03de71

4 files changed

Lines changed: 154 additions & 4 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
title: Unsaved changes
3+
component: ./UnsavedChangesExample.tsx
4+
category: events
5+
keywords: [save, unsaved, changes, document, listen, state]
6+
---
7+
8+
Track unsaved changes and enable save functionality.
9+
10+
---
11+
12+
This example shows how to track when the document has unsaved changes by listening to document scope events. A save button is enabled only when there are unsaved changes, and clicking it clears the unsaved state. The example uses `Editor.store.listen` with the `document` scope to monitor changes to the tldraw document.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import {
3+
RecordsDiff,
4+
TLComponents,
5+
TLEditorSnapshot,
6+
TLEventMapHandler,
7+
TLRecord,
8+
Tldraw,
9+
squashRecordDiffs,
10+
useEditor,
11+
} from 'tldraw'
12+
import 'tldraw/tldraw.css'
13+
14+
// There's a guide at the bottom of this file!
15+
16+
function SaveButton() {
17+
const editor = useEditor()
18+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
19+
20+
const rUnsavedChanges = useRef<RecordsDiff<TLRecord>>({ added: {}, removed: {}, updated: {} })
21+
22+
useEffect(() => {
23+
// [1]
24+
const handleDocumentChange: TLEventMapHandler<'change'> = (diff) => {
25+
squashRecordDiffs([rUnsavedChanges.current, diff.changes], { mutateFirstDiff: true })
26+
setHasUnsavedChanges(
27+
!isPlainObjectEmpty(rUnsavedChanges.current.added) ||
28+
!isPlainObjectEmpty(rUnsavedChanges.current.removed) ||
29+
!isPlainObjectEmpty(rUnsavedChanges.current.updated)
30+
)
31+
}
32+
33+
// [2]
34+
return editor.store.listen(handleDocumentChange, { scope: 'document' })
35+
}, [editor])
36+
37+
// [3]
38+
const handleSave = useCallback(() => {
39+
// The diff is the difference between the current document and the last saved document
40+
const diff = rUnsavedChanges.current
41+
42+
// Maybe also get the current document / schema snapshot
43+
const snapshot = editor.getSnapshot()
44+
45+
// Save everything somewhere...
46+
saveChanges(diff, snapshot)
47+
48+
// Clear the unsaved changes state
49+
setHasUnsavedChanges(false)
50+
51+
// Reset the diff
52+
rUnsavedChanges.current = {
53+
added: {},
54+
removed: {},
55+
updated: {},
56+
}
57+
}, [editor])
58+
59+
return (
60+
<button
61+
onClick={handleSave}
62+
disabled={!hasUnsavedChanges}
63+
style={{
64+
pointerEvents: 'all',
65+
padding: '8px 16px',
66+
marginTop: '6px',
67+
backgroundColor: hasUnsavedChanges ? '#2d7d32' : '#ccc',
68+
color: hasUnsavedChanges ? 'white' : '#666',
69+
border: 'none',
70+
borderRadius: '4px',
71+
cursor: hasUnsavedChanges ? 'pointer' : 'not-allowed',
72+
fontWeight: '500',
73+
}}
74+
>
75+
{hasUnsavedChanges ? 'Save Changes' : 'No Changes'}
76+
</button>
77+
)
78+
}
79+
80+
function saveChanges(_diff: RecordsDiff<TLRecord>, _snapshot: TLEditorSnapshot) {
81+
// todo: do something with the diff, or save the whole document snapshot somewhere
82+
}
83+
84+
function isPlainObjectEmpty(obj: object) {
85+
for (const key in obj) return false
86+
return true
87+
}
88+
89+
// [4]
90+
const components: TLComponents = {
91+
TopPanel: SaveButton,
92+
}
93+
94+
export default function UnsavedChangesExample() {
95+
return (
96+
<div className="tldraw__editor">
97+
<Tldraw components={components} />
98+
</div>
99+
)
100+
}
101+
102+
/*
103+
This example shows how to track unsaved changes in a tldraw document using the store's
104+
listen method with document scope, and how to accumulate a diff of all changes since
105+
the last save.
106+
107+
[1]
108+
We create a handler that will be called whenever there are changes to the document.
109+
The handler receives a diff of the changes that occurred. We use `squashRecordDiffs`
110+
to accumulate all changes since the last save into a single diff object. This gives
111+
us a complete picture of what has changed without storing redundant intermediate states.
112+
113+
[2]
114+
We listen to store changes with the 'document' scope, which means we only get notified
115+
about changes to document content (shapes, pages, etc.) and not to instance data like
116+
camera position or selected shapes.
117+
118+
[3]
119+
The save function demonstrates how you might handle saving in a real application. We
120+
pass both the accumulated diff (showing exactly what changed since last save) and a
121+
complete snapshot of the current document state to our save function. After saving,
122+
we reset both the unsaved changes flag and the accumulated diff. In a real application,
123+
you might send just the diff to minimize bandwidth, or save the full snapshot for
124+
simpler server-side handling.
125+
126+
[4]
127+
We define our component overrides outside of the React component to keep them static.
128+
This prevents unnecessary re-renders and follows React best practices. The SaveButton
129+
component is placed in the TopPanel to provide a prominent save interface.
130+
*/

packages/store/api-report.api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,9 @@ export interface SerializedSchemaV2 {
366366
export type SerializedStore<R extends UnknownRecord> = Record<IdOf<R>, R>;
367367

368368
// @public
369-
export function squashRecordDiffs<T extends UnknownRecord>(diffs: RecordsDiff<T>[]): RecordsDiff<T>;
369+
export function squashRecordDiffs<T extends UnknownRecord>(diffs: RecordsDiff<T>[], options?: {
370+
mutateFirstDiff?: boolean;
371+
}): RecordsDiff<T>;
370372

371373
// @internal
372374
export function squashRecordDiffsMutable<T extends UnknownRecord>(target: RecordsDiff<T>, diffs: RecordsDiff<T>[]): void;

packages/store/src/lib/RecordsDiff.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,21 @@ export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>
4242
* Squash a collection of diffs into a single diff.
4343
*
4444
* @param diffs - An array of diffs to squash.
45+
* @param options - An optional object with a `mutateFirstDiff` property. If `mutateFirstDiff` is true, the first diff in the array will be mutated in-place.
4546
* @returns A single diff that represents the squashed diffs.
4647
* @public
4748
*/
4849
export function squashRecordDiffs<T extends UnknownRecord>(
49-
diffs: RecordsDiff<T>[]
50+
diffs: RecordsDiff<T>[],
51+
options?: {
52+
mutateFirstDiff?: boolean
53+
}
5054
): RecordsDiff<T> {
51-
const result = { added: {}, removed: {}, updated: {} } as RecordsDiff<T>
55+
const result = options?.mutateFirstDiff
56+
? diffs[0]
57+
: ({ added: {}, removed: {}, updated: {} } as RecordsDiff<T>)
5258

53-
squashRecordDiffsMutable(result, diffs)
59+
squashRecordDiffsMutable(result, options?.mutateFirstDiff ? diffs.slice(1) : diffs)
5460
return result
5561
}
5662

0 commit comments

Comments
 (0)