@100mslive/hms-whiteboard 0.0.6-alpha.0 → 0.0.6-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ErrorFallback.js +1 -1
- package/dist/ErrorFallback.js.map +1 -1
- package/dist/Whiteboard.js +1 -1
- package/dist/Whiteboard.js.map +1 -1
- package/dist/hooks/StoreClient.d.ts +2 -2
- package/dist/hooks/StoreClient.js +1 -1
- package/dist/hooks/StoreClient.js.map +1 -1
- package/dist/hooks/useCollaboration.js +1 -1
- package/dist/hooks/useCollaboration.js.map +1 -1
- package/dist/index.cjs.js +2 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/utils.js.map +1 -1
- package/package.json +3 -2
- package/src/ErrorFallback.tsx +169 -0
- package/src/Whiteboard.tsx +60 -0
- package/src/grpc/sessionstore.client.ts +174 -0
- package/src/grpc/sessionstore.ts +1128 -0
- package/src/hooks/StoreClient.ts +136 -0
- package/src/hooks/default_store.ts +141 -0
- package/src/hooks/useCollaboration.ts +257 -0
- package/src/hooks/useSessionStore.ts +32 -0
- package/src/hooks/useSetEditorPermissions.tsx +38 -0
- package/src/index.css +26 -0
- package/src/index.ts +2 -0
- package/src/utils.ts +22 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport';
|
|
2
|
+
import { Value_Type } from '../grpc/sessionstore';
|
|
3
|
+
import { StoreClient } from '../grpc/sessionstore.client';
|
|
4
|
+
|
|
5
|
+
interface OpenCallbacks<T> {
|
|
6
|
+
handleOpen: (values: T[]) => void;
|
|
7
|
+
handleChange: (key: string, value?: T) => void;
|
|
8
|
+
handleError: (error: Error, isTerminal?: boolean) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const WHITEBOARD_CLOSE_MESSAGE = 'client whiteboard abort';
|
|
12
|
+
const RETRY_ERROR_MESSAGES = ['network error', 'failed to fetch'];
|
|
13
|
+
|
|
14
|
+
export class SessionStore<T> {
|
|
15
|
+
private storeClient: StoreClient;
|
|
16
|
+
|
|
17
|
+
constructor(endpoint: string, token: string) {
|
|
18
|
+
const transport = new GrpcWebFetchTransport({
|
|
19
|
+
baseUrl: endpoint,
|
|
20
|
+
meta: { Authorization: `Bearer ${token}` },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
this.storeClient = new StoreClient(transport);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async open({ handleOpen, handleChange, handleError }: OpenCallbacks<T>) {
|
|
27
|
+
const abortController = new AbortController();
|
|
28
|
+
const call = this.storeClient.open(
|
|
29
|
+
{
|
|
30
|
+
changeId: '',
|
|
31
|
+
select: [],
|
|
32
|
+
},
|
|
33
|
+
{ abort: abortController.signal },
|
|
34
|
+
);
|
|
35
|
+
let count: number | undefined = undefined;
|
|
36
|
+
const initialValues: T[] = [];
|
|
37
|
+
|
|
38
|
+
call.responses.onMessage(message => {
|
|
39
|
+
if (message.value) {
|
|
40
|
+
if (message.value?.data.oneofKind === 'str') {
|
|
41
|
+
const record = JSON.parse(message.value.data.str) as T;
|
|
42
|
+
if (initialValues.length === count) {
|
|
43
|
+
handleChange(message.key, record);
|
|
44
|
+
} else {
|
|
45
|
+
initialValues.push(record);
|
|
46
|
+
if (initialValues.length === count) {
|
|
47
|
+
handleOpen(initialValues);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
handleChange(message.key);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
call.responses.onError(error => {
|
|
57
|
+
const canRecover = RETRY_ERROR_MESSAGES.includes(error.message.toLowerCase());
|
|
58
|
+
const shouldRetryInstantly = error.message.toLowerCase() === 'network error';
|
|
59
|
+
|
|
60
|
+
const openCallback = () => {
|
|
61
|
+
abortController.abort(`closing to open new conn`);
|
|
62
|
+
this.open({ handleOpen, handleChange, handleError });
|
|
63
|
+
window.removeEventListener('online', openCallback);
|
|
64
|
+
};
|
|
65
|
+
if (canRecover) {
|
|
66
|
+
shouldRetryInstantly && openCallback();
|
|
67
|
+
window.addEventListener('online', openCallback);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!error.message.includes('abort')) {
|
|
71
|
+
handleError(error, !canRecover);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
count = await this.getKeysCountWithDelay();
|
|
77
|
+
handleOpen(count ? initialValues : []);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const canRecover = RETRY_ERROR_MESSAGES.includes((error as unknown as Error).message.toLowerCase());
|
|
80
|
+
handleError(error as unknown as Error, canRecover);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
abortController.abort(WHITEBOARD_CLOSE_MESSAGE);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
set(key: string, value?: T) {
|
|
89
|
+
const valueStr = value ? JSON.stringify(value) : undefined;
|
|
90
|
+
return this.storeClient.set({
|
|
91
|
+
key,
|
|
92
|
+
value: valueStr
|
|
93
|
+
? {
|
|
94
|
+
data: { str: valueStr, oneofKind: 'str' },
|
|
95
|
+
type: Value_Type.STRING,
|
|
96
|
+
}
|
|
97
|
+
: {
|
|
98
|
+
data: { oneofKind: undefined },
|
|
99
|
+
type: Value_Type.NONE,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async get(key: string) {
|
|
105
|
+
const { response } = await this.storeClient.get({ key });
|
|
106
|
+
|
|
107
|
+
if (response.value?.data.oneofKind === 'str') {
|
|
108
|
+
return JSON.parse(response.value.data.str) as T;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getKeysCount() {
|
|
113
|
+
const { response } = await this.storeClient.count({});
|
|
114
|
+
return Number(response.count);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
delete(key: string) {
|
|
118
|
+
return this.storeClient.delete({ key });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private async getKeysCountWithDelay() {
|
|
122
|
+
const MAX_RETRIES = 3;
|
|
123
|
+
const DELAY = 200;
|
|
124
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
125
|
+
try {
|
|
126
|
+
await new Promise(resolve => setTimeout(resolve, DELAY));
|
|
127
|
+
return await this.getKeysCount();
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.warn(error);
|
|
130
|
+
if ((error as unknown as Error).message !== 'peer not found' || i === MAX_RETRIES - 1) {
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export const DEFAULT_STORE = {
|
|
2
|
+
store: {
|
|
3
|
+
'document:document': {
|
|
4
|
+
gridSize: 10,
|
|
5
|
+
name: '',
|
|
6
|
+
meta: {},
|
|
7
|
+
id: 'document:document',
|
|
8
|
+
typeName: 'document',
|
|
9
|
+
},
|
|
10
|
+
'pointer:pointer': {
|
|
11
|
+
id: 'pointer:pointer',
|
|
12
|
+
typeName: 'pointer',
|
|
13
|
+
x: 0,
|
|
14
|
+
y: 0,
|
|
15
|
+
lastActivityTimestamp: 0,
|
|
16
|
+
meta: {},
|
|
17
|
+
},
|
|
18
|
+
'page:page': {
|
|
19
|
+
meta: {},
|
|
20
|
+
id: 'page:page',
|
|
21
|
+
name: 'Page 1',
|
|
22
|
+
index: 'a1',
|
|
23
|
+
typeName: 'page',
|
|
24
|
+
},
|
|
25
|
+
'camera:page:page': {
|
|
26
|
+
x: 0,
|
|
27
|
+
y: 0,
|
|
28
|
+
z: 1,
|
|
29
|
+
meta: {},
|
|
30
|
+
id: 'camera:page:page',
|
|
31
|
+
typeName: 'camera',
|
|
32
|
+
},
|
|
33
|
+
'instance_page_state:page:page': {
|
|
34
|
+
editingShapeId: null,
|
|
35
|
+
croppingShapeId: null,
|
|
36
|
+
selectedShapeIds: [],
|
|
37
|
+
hoveredShapeId: null,
|
|
38
|
+
erasingShapeIds: [],
|
|
39
|
+
hintingShapeIds: [],
|
|
40
|
+
focusedGroupId: null,
|
|
41
|
+
meta: {},
|
|
42
|
+
id: 'instance_page_state:page:page',
|
|
43
|
+
pageId: 'page:page',
|
|
44
|
+
typeName: 'instance_page_state',
|
|
45
|
+
},
|
|
46
|
+
'instance:instance': {
|
|
47
|
+
followingUserId: null,
|
|
48
|
+
opacityForNextShape: 1,
|
|
49
|
+
stylesForNextShape: {},
|
|
50
|
+
brush: null,
|
|
51
|
+
scribble: null,
|
|
52
|
+
cursor: {
|
|
53
|
+
type: 'default',
|
|
54
|
+
rotation: 0,
|
|
55
|
+
},
|
|
56
|
+
isFocusMode: false,
|
|
57
|
+
exportBackground: true,
|
|
58
|
+
isDebugMode: false,
|
|
59
|
+
isToolLocked: false,
|
|
60
|
+
screenBounds: {
|
|
61
|
+
x: 0,
|
|
62
|
+
y: 0,
|
|
63
|
+
w: 720,
|
|
64
|
+
h: 400,
|
|
65
|
+
},
|
|
66
|
+
zoomBrush: null,
|
|
67
|
+
isGridMode: false,
|
|
68
|
+
isPenMode: false,
|
|
69
|
+
chatMessage: '',
|
|
70
|
+
isChatting: false,
|
|
71
|
+
highlightedUserIds: [],
|
|
72
|
+
canMoveCamera: true,
|
|
73
|
+
isFocused: true,
|
|
74
|
+
devicePixelRatio: 2,
|
|
75
|
+
isCoarsePointer: false,
|
|
76
|
+
isHoveringCanvas: false,
|
|
77
|
+
openMenus: [],
|
|
78
|
+
isChangingStyle: false,
|
|
79
|
+
isReadonly: false,
|
|
80
|
+
meta: {},
|
|
81
|
+
id: 'instance:instance',
|
|
82
|
+
currentPageId: 'page:page',
|
|
83
|
+
typeName: 'instance',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
schema: {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
storeVersion: 4,
|
|
89
|
+
recordVersions: {
|
|
90
|
+
asset: {
|
|
91
|
+
version: 1,
|
|
92
|
+
subTypeKey: 'type',
|
|
93
|
+
subTypeVersions: {
|
|
94
|
+
image: 2,
|
|
95
|
+
video: 2,
|
|
96
|
+
bookmark: 0,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
camera: {
|
|
100
|
+
version: 1,
|
|
101
|
+
},
|
|
102
|
+
document: {
|
|
103
|
+
version: 2,
|
|
104
|
+
},
|
|
105
|
+
instance: {
|
|
106
|
+
version: 21,
|
|
107
|
+
},
|
|
108
|
+
instance_page_state: {
|
|
109
|
+
version: 5,
|
|
110
|
+
},
|
|
111
|
+
page: {
|
|
112
|
+
version: 1,
|
|
113
|
+
},
|
|
114
|
+
shape: {
|
|
115
|
+
version: 3,
|
|
116
|
+
subTypeKey: 'type',
|
|
117
|
+
subTypeVersions: {
|
|
118
|
+
group: 0,
|
|
119
|
+
text: 1,
|
|
120
|
+
bookmark: 1,
|
|
121
|
+
draw: 1,
|
|
122
|
+
geo: 7,
|
|
123
|
+
note: 4,
|
|
124
|
+
line: 1,
|
|
125
|
+
frame: 0,
|
|
126
|
+
arrow: 1,
|
|
127
|
+
highlight: 0,
|
|
128
|
+
embed: 4,
|
|
129
|
+
image: 2,
|
|
130
|
+
video: 1,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
instance_presence: {
|
|
134
|
+
version: 5,
|
|
135
|
+
},
|
|
136
|
+
pointer: {
|
|
137
|
+
version: 1,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
createTLStore,
|
|
4
|
+
debounce,
|
|
5
|
+
defaultShapeUtils,
|
|
6
|
+
Editor,
|
|
7
|
+
HistoryEntry,
|
|
8
|
+
throttle,
|
|
9
|
+
TLAnyShapeUtilConstructor,
|
|
10
|
+
TLInstance,
|
|
11
|
+
TLINSTANCE_ID,
|
|
12
|
+
TLPage,
|
|
13
|
+
TLRecord,
|
|
14
|
+
TLStoreWithStatus,
|
|
15
|
+
transact,
|
|
16
|
+
} from '@tldraw/tldraw';
|
|
17
|
+
import { DEFAULT_STORE } from './default_store';
|
|
18
|
+
import { useSessionStore } from './useSessionStore';
|
|
19
|
+
import { useSetEditorPermissions } from './useSetEditorPermissions';
|
|
20
|
+
import { CURRENT_PAGE_KEY, PAGES_DEBOUNCE_TIME, SHAPES_THROTTLE_TIME } from '../utils';
|
|
21
|
+
|
|
22
|
+
// mandatory record types required for initialisation of the whiteboard and for a full remote sync
|
|
23
|
+
const FULL_SYNC_REQUIRED_RECORD_TYPES: TLRecord['typeName'][] = [
|
|
24
|
+
'camera',
|
|
25
|
+
'document',
|
|
26
|
+
'instance',
|
|
27
|
+
'instance_page_state',
|
|
28
|
+
'page',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function useCollaboration({
|
|
32
|
+
endpoint,
|
|
33
|
+
token,
|
|
34
|
+
editor,
|
|
35
|
+
shapeUtils = [],
|
|
36
|
+
zoomToContent = false,
|
|
37
|
+
}: {
|
|
38
|
+
endpoint?: string;
|
|
39
|
+
token?: string;
|
|
40
|
+
editor?: Editor;
|
|
41
|
+
shapeUtils?: TLAnyShapeUtilConstructor[];
|
|
42
|
+
zoomToContent?: boolean;
|
|
43
|
+
}) {
|
|
44
|
+
const [store] = useState(() => {
|
|
45
|
+
const store = createTLStore({
|
|
46
|
+
shapeUtils: [...defaultShapeUtils, ...shapeUtils],
|
|
47
|
+
});
|
|
48
|
+
store.loadSnapshot(DEFAULT_STORE);
|
|
49
|
+
return store;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const [currentPage, setCurrentPage] = useState<TLPage | undefined>(editor?.getCurrentPage());
|
|
53
|
+
|
|
54
|
+
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({
|
|
55
|
+
status: 'loading',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const handleError = useCallback(
|
|
59
|
+
(error: Error, isTerminal?: boolean) => {
|
|
60
|
+
setStoreWithStatus(
|
|
61
|
+
isTerminal
|
|
62
|
+
? {
|
|
63
|
+
error,
|
|
64
|
+
status: 'error',
|
|
65
|
+
}
|
|
66
|
+
: {
|
|
67
|
+
store,
|
|
68
|
+
status: 'synced-remote',
|
|
69
|
+
connectionStatus: 'offline',
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
[store],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const sessionStore = useSessionStore({ token, endpoint, handleError });
|
|
77
|
+
const permissions = useSetEditorPermissions({ token, editor, zoomToContent, handleError });
|
|
78
|
+
|
|
79
|
+
const handleOpen = useCallback(
|
|
80
|
+
(initialRecords: TLRecord[]) => {
|
|
81
|
+
if (!sessionStore) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Initialize the tldraw store with the session store server records—or, if the session store
|
|
86
|
+
// is empty, initialize the session store server with the default tldraw store records.
|
|
87
|
+
const shouldUseServerRecords = FULL_SYNC_REQUIRED_RECORD_TYPES.every(
|
|
88
|
+
type => initialRecords.filter(record => record.typeName === type).length > 0,
|
|
89
|
+
);
|
|
90
|
+
if (shouldUseServerRecords) {
|
|
91
|
+
// Replace the tldraw store records with session store
|
|
92
|
+
store.mergeRemoteChanges(() => {
|
|
93
|
+
// The records here should be compatible with what's in the store
|
|
94
|
+
store.clear();
|
|
95
|
+
store.put(initialRecords);
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
// Create the initial store records
|
|
99
|
+
// Sync the local tldraw store records to session store
|
|
100
|
+
for (const record of store.allRecords()) {
|
|
101
|
+
sessionStore.set(record.id, record);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
setStoreWithStatus({
|
|
105
|
+
store,
|
|
106
|
+
status: 'synced-remote',
|
|
107
|
+
connectionStatus: 'online',
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
[store, sessionStore],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const handleChange = useCallback(
|
|
114
|
+
(key: string, value?: TLRecord) => {
|
|
115
|
+
// put / remove the records in the store
|
|
116
|
+
store.mergeRemoteChanges(() => {
|
|
117
|
+
if (!value) {
|
|
118
|
+
return store.remove([key as TLRecord['id']]);
|
|
119
|
+
}
|
|
120
|
+
if (key === CURRENT_PAGE_KEY) {
|
|
121
|
+
setCurrentPage(value as TLPage);
|
|
122
|
+
} else {
|
|
123
|
+
transact(() => {
|
|
124
|
+
store.put([value]);
|
|
125
|
+
if (key === TLINSTANCE_ID) {
|
|
126
|
+
store.put([
|
|
127
|
+
{ ...value, canMoveCamera: !!zoomToContent, isReadonly: !permissions.includes('write') } as TLInstance,
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
[store, permissions, zoomToContent],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!sessionStore) return;
|
|
139
|
+
|
|
140
|
+
setStoreWithStatus({ status: 'loading' });
|
|
141
|
+
|
|
142
|
+
const unsubs: (() => void)[] = [];
|
|
143
|
+
|
|
144
|
+
// Open session and sync the session store changes to the store
|
|
145
|
+
sessionStore
|
|
146
|
+
.open({
|
|
147
|
+
handleOpen,
|
|
148
|
+
handleChange,
|
|
149
|
+
handleError,
|
|
150
|
+
})
|
|
151
|
+
.then(unsub => unsubs.push(unsub));
|
|
152
|
+
|
|
153
|
+
// Sync store changes to the yjs doc
|
|
154
|
+
unsubs.push(
|
|
155
|
+
store.listen(
|
|
156
|
+
throttle(function syncStoreChangesToYjsDoc({ changes }: HistoryEntry<TLRecord>) {
|
|
157
|
+
Object.values(changes.added).forEach(record => {
|
|
158
|
+
sessionStore.set(record.id, record);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
Object.values(changes.updated).forEach(([, record]) => {
|
|
162
|
+
sessionStore.set(record.id, record);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
Object.values(changes.removed).forEach(record => {
|
|
166
|
+
sessionStore.delete(record.id);
|
|
167
|
+
});
|
|
168
|
+
}, SHAPES_THROTTLE_TIME),
|
|
169
|
+
{ source: 'user', scope: 'document' }, // only sync user's document changes
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// let hasConnectedBefore = false;
|
|
174
|
+
|
|
175
|
+
// function handleStatusChange({
|
|
176
|
+
// status,
|
|
177
|
+
// }: {
|
|
178
|
+
// status: "disconnected" | "connected";
|
|
179
|
+
// }) {
|
|
180
|
+
// // If we're disconnected, set the store status to 'synced-remote' and the connection status to 'offline'
|
|
181
|
+
// if (status === "disconnected") {
|
|
182
|
+
// setStoreWithStatus({
|
|
183
|
+
// store,
|
|
184
|
+
// status: "synced-remote",
|
|
185
|
+
// connectionStatus: "offline",
|
|
186
|
+
// });
|
|
187
|
+
// return;
|
|
188
|
+
// }
|
|
189
|
+
|
|
190
|
+
// room.off("synced", handleSync);
|
|
191
|
+
|
|
192
|
+
// if (status === "connected") {
|
|
193
|
+
// if (hasConnectedBefore) return;
|
|
194
|
+
// hasConnectedBefore = true;
|
|
195
|
+
// room.on("synced", handleSync);
|
|
196
|
+
// unsubs.push(() => room.off("synced", handleSync));
|
|
197
|
+
// }
|
|
198
|
+
// }
|
|
199
|
+
|
|
200
|
+
// room.on("status", handleStatusChange);
|
|
201
|
+
// unsubs.push(() => room.off("status", handleStatusChange));
|
|
202
|
+
|
|
203
|
+
return () => {
|
|
204
|
+
unsubs.forEach(fn => fn());
|
|
205
|
+
unsubs.length = 0;
|
|
206
|
+
};
|
|
207
|
+
}, [store, sessionStore, handleChange, handleOpen, handleError]);
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!editor || !sessionStore) return;
|
|
211
|
+
|
|
212
|
+
const unsubs: (() => void)[] = [];
|
|
213
|
+
|
|
214
|
+
unsubs.push(
|
|
215
|
+
store.listen(
|
|
216
|
+
debounce(({ changes }) => {
|
|
217
|
+
Object.keys(changes.updated).forEach(key => {
|
|
218
|
+
// Only update the current page id from the instance changes, ignore pointer changes
|
|
219
|
+
if (!key.includes('instance')) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const newPage = editor?.getCurrentPage();
|
|
223
|
+
|
|
224
|
+
if (newPage?.id !== currentPage?.id) {
|
|
225
|
+
sessionStore.get(TLINSTANCE_ID).then(instance => {
|
|
226
|
+
if (instance) {
|
|
227
|
+
sessionStore?.set(instance.id, { ...instance, currentPageId: newPage?.id } as TLInstance);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
setCurrentPage(newPage);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}, PAGES_DEBOUNCE_TIME),
|
|
234
|
+
{ source: 'user', scope: 'session' },
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
return () => {
|
|
239
|
+
unsubs.forEach(fn => fn());
|
|
240
|
+
unsubs.length = 0;
|
|
241
|
+
};
|
|
242
|
+
}, [currentPage, editor, sessionStore, store]);
|
|
243
|
+
|
|
244
|
+
// zoom to fit on remote changes for hls viewer
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (!editor || !editor.getInstanceState()?.isReadonly || !zoomToContent) return;
|
|
247
|
+
|
|
248
|
+
store.listen(
|
|
249
|
+
() => {
|
|
250
|
+
editor.zoomToFit();
|
|
251
|
+
},
|
|
252
|
+
{ source: 'remote', scope: 'document' },
|
|
253
|
+
);
|
|
254
|
+
}, [editor, store, zoomToContent]);
|
|
255
|
+
|
|
256
|
+
return storeWithStatus;
|
|
257
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { TLRecord } from '@tldraw/tldraw';
|
|
3
|
+
import { SessionStore } from './StoreClient';
|
|
4
|
+
import decodeJWT from '../utils';
|
|
5
|
+
|
|
6
|
+
export const useSessionStore = ({
|
|
7
|
+
token,
|
|
8
|
+
endpoint = 'https://store-prod-in3-grpc.100ms.live',
|
|
9
|
+
handleError,
|
|
10
|
+
}: {
|
|
11
|
+
token?: string;
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
handleError: (error: Error) => void;
|
|
14
|
+
}) => {
|
|
15
|
+
const sessionStore = useMemo(() => {
|
|
16
|
+
try {
|
|
17
|
+
decodeJWT(token);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return handleError(err as Error);
|
|
20
|
+
}
|
|
21
|
+
if (!endpoint || !token) {
|
|
22
|
+
return handleError(new Error('Missing GRPC endpoint or token'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sessionStore = new SessionStore<TLRecord>(endpoint, token);
|
|
26
|
+
// @ts-expect-error - for debugging
|
|
27
|
+
window.sessionStore = sessionStore;
|
|
28
|
+
return sessionStore;
|
|
29
|
+
}, [endpoint, token, handleError]);
|
|
30
|
+
|
|
31
|
+
return sessionStore;
|
|
32
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react';
|
|
2
|
+
import { Editor } from '@tldraw/tldraw';
|
|
3
|
+
import decodeJWT from '../utils';
|
|
4
|
+
|
|
5
|
+
export const useSetEditorPermissions = ({
|
|
6
|
+
token,
|
|
7
|
+
editor,
|
|
8
|
+
zoomToContent = false,
|
|
9
|
+
handleError,
|
|
10
|
+
}: {
|
|
11
|
+
token?: string;
|
|
12
|
+
editor?: Editor;
|
|
13
|
+
zoomToContent?: boolean;
|
|
14
|
+
handleError: (error: Error) => void;
|
|
15
|
+
}) => {
|
|
16
|
+
const { permissions } = useMemo(() => {
|
|
17
|
+
try {
|
|
18
|
+
return decodeJWT(token);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
handleError(err as Error);
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}, [token, handleError]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
// disable scroll and zoom
|
|
27
|
+
editor?.updateInstanceState({ canMoveCamera: !!zoomToContent });
|
|
28
|
+
|
|
29
|
+
if (!permissions) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isReadonly = !permissions.includes('write');
|
|
34
|
+
editor?.updateInstanceState({ isReadonly });
|
|
35
|
+
}, [permissions, zoomToContent, editor]);
|
|
36
|
+
|
|
37
|
+
return permissions;
|
|
38
|
+
};
|
package/src/index.css
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap');
|
|
2
|
+
@import url('@tldraw/tldraw/tldraw.css');
|
|
3
|
+
|
|
4
|
+
.tlui-navigation-zone,
|
|
5
|
+
.tlui-navigation-panel {
|
|
6
|
+
display: none;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.transparent-canvas {
|
|
10
|
+
--color-background: rgba(0, 0, 0, 0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.tl-container {
|
|
14
|
+
--layer-background: 4; /* 100 */
|
|
15
|
+
--layer-grid: 5; /* 150 */
|
|
16
|
+
--layer-canvas: 6; /* 200 */
|
|
17
|
+
--layer-shapes: 7; /* 300 */
|
|
18
|
+
--layer-overlays: 8; /* 400 */
|
|
19
|
+
--layer-following-indicator: 12; /* 1000 */
|
|
20
|
+
|
|
21
|
+
--layer-panels: 7;
|
|
22
|
+
--layer-menus: 8;
|
|
23
|
+
--layer-overlays: 9;
|
|
24
|
+
--layer-toasts: 10;
|
|
25
|
+
--layer-cursor: 11;
|
|
26
|
+
}
|
package/src/index.ts
ADDED
package/src/utils.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export default function decodeJWT(token?: string) {
|
|
2
|
+
if (!token || token.length === 0) {
|
|
3
|
+
throw Error('Token cannot be an empty string or undefined or null');
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const parts = token.split('.');
|
|
7
|
+
if (parts.length !== 3) {
|
|
8
|
+
throw Error(`Expected 3 '.' separate fields - header, payload and signature respectively`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const payloadStr = atob(parts[1]);
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(payloadStr);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
throw Error(`couldn't parse to json - ${(err as Error).message}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const CURRENT_PAGE_KEY = 'currentPage';
|
|
20
|
+
export const SHAPES_THROTTLE_TIME = 11;
|
|
21
|
+
export const PAGES_DEBOUNCE_TIME = 200;
|
|
22
|
+
export const OPEN_WAIT_TIMEOUT = 1000;
|