@tldraw/tlschema 5.3.0-next.fc37bd96825b → 5.3.1
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-cjs/index.d.ts +265 -1
- package/dist-cjs/index.js +15 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/misc/TLCursor.js +1 -0
- package/dist-cjs/misc/TLCursor.js.map +2 -2
- package/dist-cjs/records/TLComment.js +232 -0
- package/dist-cjs/records/TLComment.js.map +7 -0
- package/dist-cjs/shapes/TLGeoShape.js +15 -1
- package/dist-cjs/shapes/TLGeoShape.js.map +2 -2
- package/dist-esm/index.d.mts +265 -1
- package/dist-esm/index.mjs +29 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/misc/TLCursor.mjs +1 -0
- package/dist-esm/misc/TLCursor.mjs.map +2 -2
- package/dist-esm/records/TLComment.mjs +212 -0
- package/dist-esm/records/TLComment.mjs.map +7 -0
- package/dist-esm/shapes/TLGeoShape.mjs +15 -1
- package/dist-esm/shapes/TLGeoShape.mjs.map +2 -2
- package/package.json +5 -5
- package/src/index.ts +22 -0
- package/src/migrations.test.ts +12 -0
- package/src/misc/TLCursor.ts +1 -0
- package/src/records/TLComment.test.ts +255 -0
- package/src/records/TLComment.ts +446 -0
- package/src/shapes/TLGeoShape.ts +21 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createTLSchema } from '../createTLSchema'
|
|
3
|
+
import { toRichText } from '../misc/TLRichText'
|
|
4
|
+
import {
|
|
5
|
+
commentReactionRecordConfig,
|
|
6
|
+
commentRecordConfig,
|
|
7
|
+
commentSchemaRecords,
|
|
8
|
+
commentThreadRecordConfig,
|
|
9
|
+
createComment,
|
|
10
|
+
createCommentId,
|
|
11
|
+
createCommentReaction,
|
|
12
|
+
createCommentReactionId,
|
|
13
|
+
createCommentThread,
|
|
14
|
+
createCommentThreadId,
|
|
15
|
+
isCommentId,
|
|
16
|
+
isCommentReactionId,
|
|
17
|
+
isCommentThreadId,
|
|
18
|
+
TLCommentAnchor,
|
|
19
|
+
} from './TLComment'
|
|
20
|
+
import { TLPageId } from './TLPage'
|
|
21
|
+
import { TLShapeId } from './TLShape'
|
|
22
|
+
|
|
23
|
+
const pageId = 'page:page1' as TLPageId
|
|
24
|
+
const shapeId = 'shape:box1' as TLShapeId
|
|
25
|
+
|
|
26
|
+
const anchors: TLCommentAnchor[] = [
|
|
27
|
+
{ type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
|
|
28
|
+
{ type: 'point', x: 100, y: 200 },
|
|
29
|
+
{ type: 'region', x: 0, y: 0, w: 300, h: 150 },
|
|
30
|
+
{ type: 'page' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
describe('TLCommentThread', () => {
|
|
34
|
+
it.each(anchors)('creates a valid thread anchored to %j', (anchor) => {
|
|
35
|
+
const thread = createCommentThread({ pageId, anchor, createdBy: 'user1', now: 1000 })
|
|
36
|
+
expect(commentThreadRecordConfig.validator.validate(thread)).toEqual(thread)
|
|
37
|
+
expect(thread).toMatchObject({
|
|
38
|
+
typeName: 'comment-thread',
|
|
39
|
+
pageId,
|
|
40
|
+
anchor,
|
|
41
|
+
createdAt: 1000,
|
|
42
|
+
resolved: null,
|
|
43
|
+
meta: {},
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('validates resolution state', () => {
|
|
48
|
+
const thread = createCommentThread({
|
|
49
|
+
pageId,
|
|
50
|
+
anchor: { type: 'page' },
|
|
51
|
+
createdBy: 'user1',
|
|
52
|
+
now: 1000,
|
|
53
|
+
})
|
|
54
|
+
const resolved = { ...thread, resolved: { at: 2000, by: 'user2' } }
|
|
55
|
+
expect(commentThreadRecordConfig.validator.validate(resolved)).toEqual(resolved)
|
|
56
|
+
expect(() =>
|
|
57
|
+
commentThreadRecordConfig.validator.validate({ ...thread, resolved: { at: 2000 } })
|
|
58
|
+
).toThrow()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('rejects unknown anchor kinds and malformed anchors', () => {
|
|
62
|
+
const thread = createCommentThread({
|
|
63
|
+
pageId,
|
|
64
|
+
anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
|
|
65
|
+
createdBy: 'user1',
|
|
66
|
+
now: 1000,
|
|
67
|
+
})
|
|
68
|
+
expect(() =>
|
|
69
|
+
commentThreadRecordConfig.validator.validate({
|
|
70
|
+
...thread,
|
|
71
|
+
anchor: { type: 'volume', level: 11 },
|
|
72
|
+
})
|
|
73
|
+
).toThrow()
|
|
74
|
+
expect(() =>
|
|
75
|
+
commentThreadRecordConfig.validator.validate({
|
|
76
|
+
...thread,
|
|
77
|
+
// shape anchors must reference a shape id
|
|
78
|
+
anchor: { type: 'shape', shapeId: 'page:nope', x: 1, y: 0, isPrecise: false },
|
|
79
|
+
})
|
|
80
|
+
).toThrow()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('TLComment', () => {
|
|
85
|
+
const threadId = createCommentThreadId('thread1')
|
|
86
|
+
|
|
87
|
+
it('creates a valid comment with a rich text body', () => {
|
|
88
|
+
const comment = createComment({
|
|
89
|
+
threadId,
|
|
90
|
+
pageId,
|
|
91
|
+
authorId: 'user1',
|
|
92
|
+
body: toRichText('hello'),
|
|
93
|
+
now: 1000,
|
|
94
|
+
})
|
|
95
|
+
expect(commentRecordConfig.validator.validate(comment)).toEqual(comment)
|
|
96
|
+
expect(comment).toMatchObject({
|
|
97
|
+
typeName: 'comment',
|
|
98
|
+
threadId,
|
|
99
|
+
pageId,
|
|
100
|
+
createdAt: 1000,
|
|
101
|
+
editedAt: null,
|
|
102
|
+
isDeleted: false,
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('validates soft-deletion state', () => {
|
|
107
|
+
const comment = createComment({
|
|
108
|
+
threadId,
|
|
109
|
+
pageId,
|
|
110
|
+
authorId: 'user1',
|
|
111
|
+
body: toRichText('hello'),
|
|
112
|
+
now: 1000,
|
|
113
|
+
})
|
|
114
|
+
const deleted = { ...comment, isDeleted: true }
|
|
115
|
+
expect(commentRecordConfig.validator.validate(deleted)).toEqual(deleted)
|
|
116
|
+
expect(() => commentRecordConfig.validator.validate({ ...comment, isDeleted: 'yes' })).toThrow()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('accepts an editedAt timestamp after edits', () => {
|
|
120
|
+
const comment = createComment({
|
|
121
|
+
threadId,
|
|
122
|
+
pageId,
|
|
123
|
+
authorId: 'user1',
|
|
124
|
+
body: toRichText('hello'),
|
|
125
|
+
now: 1000,
|
|
126
|
+
})
|
|
127
|
+
const edited = { ...comment, editedAt: 2000, body: toRichText('hello, edited') }
|
|
128
|
+
expect(commentRecordConfig.validator.validate(edited)).toEqual(edited)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('rejects a threadId that is not a comment-thread id', () => {
|
|
132
|
+
const comment = createComment({
|
|
133
|
+
threadId,
|
|
134
|
+
pageId,
|
|
135
|
+
authorId: 'user1',
|
|
136
|
+
body: toRichText('hello'),
|
|
137
|
+
now: 1000,
|
|
138
|
+
})
|
|
139
|
+
expect(() =>
|
|
140
|
+
commentRecordConfig.validator.validate({ ...comment, threadId: 'comment:not-a-thread' })
|
|
141
|
+
).toThrow()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('schema registration', () => {
|
|
146
|
+
it('registers both record types via commentSchemaRecords', () => {
|
|
147
|
+
const schema = createTLSchema({ records: commentSchemaRecords })
|
|
148
|
+
// custom record types aren't part of the default TLRecord union, so index untyped
|
|
149
|
+
const types = schema.types as Record<string, { scope: string } | undefined>
|
|
150
|
+
expect(types['comment-thread']?.scope).toBe('document')
|
|
151
|
+
expect(types['comment']?.scope).toBe('document')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('isCommentThreadId / isCommentId', () => {
|
|
156
|
+
it('isCommentThreadId accepts comment-thread ids and rejects everything else', () => {
|
|
157
|
+
expect(isCommentThreadId(createCommentThreadId())).toBe(true)
|
|
158
|
+
expect(isCommentThreadId(createCommentId())).toBe(false)
|
|
159
|
+
expect(isCommentThreadId('shape:box1')).toBe(false)
|
|
160
|
+
expect(isCommentThreadId('not-an-id')).toBe(false)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('isCommentId accepts comment ids and rejects everything else, including comment-thread ids', () => {
|
|
164
|
+
expect(isCommentId(createCommentId())).toBe(true)
|
|
165
|
+
expect(isCommentId(createCommentThreadId())).toBe(false)
|
|
166
|
+
expect(isCommentId('shape:box1')).toBe(false)
|
|
167
|
+
expect(isCommentId('not-an-id')).toBe(false)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('comment-thread migrations', () => {
|
|
172
|
+
const migration = (commentThreadRecordConfig.migrations as any).sequence.find(
|
|
173
|
+
(m: any) => m.id === 'com.tldraw.comment-thread/2'
|
|
174
|
+
) as { up(r: any): any; down(r: any): any }
|
|
175
|
+
|
|
176
|
+
it('stamps existing shape anchors with the top-right position (x:1, y:0)', () => {
|
|
177
|
+
expect(migration.up({ anchor: { type: 'shape', shapeId } })).toEqual({
|
|
178
|
+
anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('leaves shape anchors that already have x/y unchanged', () => {
|
|
183
|
+
const anchor = { type: 'shape', shapeId, x: 0.25, y: 0.75, isPrecise: true }
|
|
184
|
+
expect(migration.up({ anchor })).toEqual({ anchor })
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('leaves non-shape anchors untouched', () => {
|
|
188
|
+
const anchor = { type: 'point', x: 5, y: 6 }
|
|
189
|
+
expect(migration.up({ anchor })).toEqual({ anchor })
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('down-migrates by dropping x/y', () => {
|
|
193
|
+
expect(
|
|
194
|
+
migration.down({ anchor: { type: 'shape', shapeId, x: 0.3, y: 0.4, isPrecise: true } })
|
|
195
|
+
).toEqual({
|
|
196
|
+
anchor: { type: 'shape', shapeId },
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('comment-reaction record', () => {
|
|
202
|
+
it('createCommentReaction derives its id from the comment, user, and emoji', () => {
|
|
203
|
+
const commentId = createCommentId('c1')
|
|
204
|
+
const make = (emoji: string) =>
|
|
205
|
+
createCommentReaction({
|
|
206
|
+
commentId,
|
|
207
|
+
threadId: createCommentThreadId('t1'),
|
|
208
|
+
pageId,
|
|
209
|
+
userId: 'user1',
|
|
210
|
+
emoji,
|
|
211
|
+
})
|
|
212
|
+
// same emoji → same record (re-picking toggles it); different emoji → its own record (multi)
|
|
213
|
+
expect(make('👍').id).toBe(make('👍').id)
|
|
214
|
+
expect(make('👍').id).not.toBe(make('🎉').id)
|
|
215
|
+
expect(isCommentReactionId(make('👍').id)).toBe(true)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('validates a well-formed reaction record', () => {
|
|
219
|
+
const reaction = createCommentReaction({
|
|
220
|
+
commentId: createCommentId('c1'),
|
|
221
|
+
threadId: createCommentThreadId('t1'),
|
|
222
|
+
pageId,
|
|
223
|
+
userId: 'user1',
|
|
224
|
+
emoji: '👍',
|
|
225
|
+
})
|
|
226
|
+
expect(commentReactionRecordConfig.validator.validate(reaction)).toEqual(reaction)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('rejects an empty or oversized emoji token', () => {
|
|
230
|
+
const reaction = createCommentReaction({
|
|
231
|
+
commentId: createCommentId('c1'),
|
|
232
|
+
threadId: createCommentThreadId('t1'),
|
|
233
|
+
pageId,
|
|
234
|
+
userId: 'user1',
|
|
235
|
+
emoji: '👍',
|
|
236
|
+
})
|
|
237
|
+
// the reaction id embeds the emoji verbatim, so an unbounded token means an unbounded id
|
|
238
|
+
expect(() =>
|
|
239
|
+
commentReactionRecordConfig.validator.validate({ ...reaction, emoji: '' })
|
|
240
|
+
).toThrow()
|
|
241
|
+
expect(() =>
|
|
242
|
+
commentReactionRecordConfig.validator.validate({ ...reaction, emoji: 'x'.repeat(65) })
|
|
243
|
+
).toThrow()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('createCommentReactionId is injective when a part contains a colon', () => {
|
|
247
|
+
// two different triples that a naive slice+join would collapse onto one id:
|
|
248
|
+
// comment "foo" + user "bar:baz" + 👍 vs comment "foo:bar" + user "baz" + 👍
|
|
249
|
+
const a = createCommentReactionId(createCommentId('foo'), 'bar:baz', '👍')
|
|
250
|
+
const b = createCommentReactionId(createCommentId('foo:bar'), 'baz', '👍')
|
|
251
|
+
expect(a).not.toBe(b)
|
|
252
|
+
expect(isCommentReactionId(a)).toBe(true)
|
|
253
|
+
expect(isCommentReactionId(b)).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { BaseRecord, RecordId, createRecordMigrationSequence } from '@tldraw/store'
|
|
2
|
+
import { JsonObject } from '@tldraw/utils'
|
|
3
|
+
import { T } from '@tldraw/validate'
|
|
4
|
+
import { idValidator } from '../misc/id-validator'
|
|
5
|
+
import { richTextValidator, TLRichText } from '../misc/TLRichText'
|
|
6
|
+
import { CustomRecordInfo, createCustomRecordId, isCustomRecordId } from './TLCustomRecord'
|
|
7
|
+
import { TLPageId } from './TLPage'
|
|
8
|
+
import { TLShapeId } from './TLShape'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where a comment thread is anchored on the canvas. Modeled as a discriminated union so new
|
|
12
|
+
* anchor kinds can be added without breaking existing threads:
|
|
13
|
+
*
|
|
14
|
+
* - `shape` — pinned to a shape. `x`/`y` are normalized (0–1) within the shape's bounds, so the pin
|
|
15
|
+
* keeps its spot as the shape moves, resizes, and rotates. `isPrecise` mirrors arrow bindings:
|
|
16
|
+
* when false (the default) the pin sits at a consumer-defined spot instead, top-right out of the
|
|
17
|
+
* box, and `x`/`y` are the remembered precise position
|
|
18
|
+
* - `point` — pinned to a fixed point on the page, in page coordinates
|
|
19
|
+
* - `region` — pinned to a rectangular area of the page, in page coordinates
|
|
20
|
+
* - `page` — a page-level thread with no spatial anchor
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
export type TLCommentAnchor =
|
|
25
|
+
| { type: 'shape'; shapeId: TLShapeId; x: number; y: number; isPrecise: boolean }
|
|
26
|
+
| { type: 'point'; x: number; y: number }
|
|
27
|
+
| {
|
|
28
|
+
type: 'region'
|
|
29
|
+
x: number
|
|
30
|
+
y: number
|
|
31
|
+
w: number
|
|
32
|
+
h: number
|
|
33
|
+
/** The normalized (0–1) corner the pin sits on — where the creating drag was released.
|
|
34
|
+
* Absent on older records; consumers fall back to a corner of their own choosing. */
|
|
35
|
+
pinX?: number
|
|
36
|
+
pinY?: number
|
|
37
|
+
}
|
|
38
|
+
| { type: 'page' }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A comment thread. The thread owns the anchor (where the conversation lives on the canvas) and
|
|
42
|
+
* the resolution state; the messages themselves are `TLComment` records pointing at the thread
|
|
43
|
+
* via `threadId`, ordered by `createdAt`. v1 threads are flat — no nested replies.
|
|
44
|
+
*
|
|
45
|
+
* Threads and comments are document records, but they're intended to be served through the sync
|
|
46
|
+
* server's object-store lane: gated by the session's `objectAccess` rather than `isReadonly`
|
|
47
|
+
* (so "can comment but not edit" is expressible), excluded from document snapshots and `.tldr`
|
|
48
|
+
* exports server-side, and persisted in a separate lane from the main document.
|
|
49
|
+
*
|
|
50
|
+
* Opt-in: register with `createTLSchema({ records: commentSchemaRecords })` on the server and
|
|
51
|
+
* the matching `records` option on the client — neither type is part of the default schema, and
|
|
52
|
+
* both sides must register them identically.
|
|
53
|
+
*
|
|
54
|
+
* @public
|
|
55
|
+
*/
|
|
56
|
+
export interface TLCommentThread extends BaseRecord<'comment-thread', TLCommentThreadId> {
|
|
57
|
+
/** The page the thread lives on. */
|
|
58
|
+
pageId: TLPageId
|
|
59
|
+
/** Where the thread is anchored on that page. */
|
|
60
|
+
anchor: TLCommentAnchor
|
|
61
|
+
/** Who started the thread. Client-supplied; sync servers are expected to stamp/verify it. */
|
|
62
|
+
createdBy: string
|
|
63
|
+
createdAt: number
|
|
64
|
+
/** Resolution state: when and by whom the thread was resolved, or null while open. */
|
|
65
|
+
resolved: { at: number; by: string } | null
|
|
66
|
+
/**
|
|
67
|
+
* Whether the thread is soft-deleted. Clients set the flag and leave the record in place. Sync
|
|
68
|
+
* servers are expected to enforce it as write-once and creator-only, reject client hard
|
|
69
|
+
* deletes, and drop flagged threads from future room loads.
|
|
70
|
+
*/
|
|
71
|
+
isDeleted: boolean
|
|
72
|
+
meta: JsonObject
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @public */
|
|
76
|
+
export type TLCommentThreadId = RecordId<TLCommentThread>
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A single comment message within a thread. See `TLCommentThread` for the overall model and
|
|
80
|
+
* sync/registration notes.
|
|
81
|
+
*
|
|
82
|
+
* Comment mutations are deliberately not undoable — create/edit them with
|
|
83
|
+
* `{ history: 'ignore' }` to avoid multiplayer surprises like a comment reappearing after
|
|
84
|
+
* someone else deleted it.
|
|
85
|
+
*
|
|
86
|
+
* @public
|
|
87
|
+
*/
|
|
88
|
+
export interface TLComment extends BaseRecord<'comment', TLCommentId> {
|
|
89
|
+
/** The thread this comment belongs to. */
|
|
90
|
+
threadId: TLCommentThreadId
|
|
91
|
+
/** Denormalized from the thread so per-page queries don't need a join. */
|
|
92
|
+
pageId: TLPageId
|
|
93
|
+
/** See `TLCommentThread.createdBy` — same server-stamping caveat applies. */
|
|
94
|
+
authorId: string
|
|
95
|
+
createdAt: number
|
|
96
|
+
/** Null until the comment is first edited. */
|
|
97
|
+
editedAt: number | null
|
|
98
|
+
/** Rich text body. Use `toRichText(...)` for plaintext input. */
|
|
99
|
+
body: TLRichText
|
|
100
|
+
/** Whether the comment is soft-deleted. Same model as `TLCommentThread.isDeleted`. */
|
|
101
|
+
isDeleted: boolean
|
|
102
|
+
meta: JsonObject
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @public */
|
|
106
|
+
export type TLCommentId = RecordId<TLComment>
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One person's emoji reaction to one comment.
|
|
110
|
+
*
|
|
111
|
+
* A reaction is its own record rather than a field on the comment: comment records are owner-only
|
|
112
|
+
* for updates, and a shared field would make concurrent reactions race to overwrite each other.
|
|
113
|
+
*
|
|
114
|
+
* A user holds at most one record per (comment, emoji) pair, enforced by the derived id — see
|
|
115
|
+
* `createCommentReactionId`. Limiting a user to one reaction overall is client-side policy (the
|
|
116
|
+
* commenting package's `reactionMode`), not a property of this record.
|
|
117
|
+
*
|
|
118
|
+
* @public
|
|
119
|
+
*/
|
|
120
|
+
export interface TLCommentReaction extends BaseRecord<'comment-reaction', TLCommentReactionId> {
|
|
121
|
+
/** The comment being reacted to. */
|
|
122
|
+
commentId: TLCommentId
|
|
123
|
+
/** Denormalized from the comment, so a thread's reactions can be found without a join. */
|
|
124
|
+
threadId: TLCommentThreadId
|
|
125
|
+
/** Denormalized from the comment — see `TLComment.pageId`. */
|
|
126
|
+
pageId: TLPageId
|
|
127
|
+
/** Who reacted. See `TLCommentThread.createdBy` — same server-stamping caveat applies. */
|
|
128
|
+
userId: string
|
|
129
|
+
/** The emoji itself, as a string (e.g. `'👍'`), not a shortcode. */
|
|
130
|
+
emoji: string
|
|
131
|
+
createdAt: number
|
|
132
|
+
meta: JsonObject
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @public */
|
|
136
|
+
export type TLCommentReactionId = RecordId<TLCommentReaction>
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* An emoji shortcode or literal. Bounded because reaction ids embed the emoji verbatim
|
|
140
|
+
* (see {@link createCommentReactionId}), so an unbounded value means an unbounded record id.
|
|
141
|
+
*/
|
|
142
|
+
const emojiValidator = T.string.check((value) => {
|
|
143
|
+
if (value.length === 0 || value.length > 64) {
|
|
144
|
+
throw new T.ValidationError(`Expected an emoji of 1-64 characters, got ${value.length}`)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const commentAnchorValidator: T.Validator<TLCommentAnchor> = T.union('type', {
|
|
149
|
+
shape: T.object({
|
|
150
|
+
type: T.literal('shape'),
|
|
151
|
+
shapeId: idValidator<TLShapeId>('shape'),
|
|
152
|
+
x: T.number,
|
|
153
|
+
y: T.number,
|
|
154
|
+
isPrecise: T.boolean,
|
|
155
|
+
}),
|
|
156
|
+
point: T.object({
|
|
157
|
+
type: T.literal('point'),
|
|
158
|
+
x: T.number,
|
|
159
|
+
y: T.number,
|
|
160
|
+
}),
|
|
161
|
+
region: T.object({
|
|
162
|
+
type: T.literal('region'),
|
|
163
|
+
x: T.number,
|
|
164
|
+
y: T.number,
|
|
165
|
+
w: T.number,
|
|
166
|
+
h: T.number,
|
|
167
|
+
pinX: T.number.optional(),
|
|
168
|
+
pinY: T.number.optional(),
|
|
169
|
+
}),
|
|
170
|
+
page: T.object({
|
|
171
|
+
type: T.literal('page'),
|
|
172
|
+
}),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Guard migrations for the comment record types. Each sequence starts with an identity migration
|
|
177
|
+
* that has no `down`, so a session whose schema predates the comment types is rejected with
|
|
178
|
+
* CLIENT_TOO_OLD instead of being sent records its store can't represent.
|
|
179
|
+
*/
|
|
180
|
+
function createCommentGuardMigrations(
|
|
181
|
+
typeName: 'comment' | 'comment-thread' | 'comment-reaction',
|
|
182
|
+
extra: Parameters<typeof createRecordMigrationSequence>[0]['sequence'] = []
|
|
183
|
+
) {
|
|
184
|
+
return createRecordMigrationSequence({
|
|
185
|
+
sequenceId: `com.tldraw.${typeName}`,
|
|
186
|
+
recordType: typeName,
|
|
187
|
+
retroactive: true,
|
|
188
|
+
sequence: [
|
|
189
|
+
{
|
|
190
|
+
id: `com.tldraw.${typeName}/1`,
|
|
191
|
+
up: (record) => record,
|
|
192
|
+
},
|
|
193
|
+
...extra,
|
|
194
|
+
],
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Config for registering the `comment-thread` record type in a tldraw schema. Pass via
|
|
200
|
+
* `commentSchemaRecords`; see `TLCommentThread`.
|
|
201
|
+
*
|
|
202
|
+
* @public
|
|
203
|
+
*/
|
|
204
|
+
export const commentThreadRecordConfig: CustomRecordInfo = {
|
|
205
|
+
scope: 'document',
|
|
206
|
+
migrations: createCommentGuardMigrations('comment-thread', [
|
|
207
|
+
{
|
|
208
|
+
// Shape anchors gained normalized x/y + isPrecise; existing ones were imprecise (top-right).
|
|
209
|
+
id: 'com.tldraw.comment-thread/2',
|
|
210
|
+
up: (record) => {
|
|
211
|
+
const anchor = (record as any).anchor
|
|
212
|
+
if (anchor?.type === 'shape' && anchor.x === undefined) {
|
|
213
|
+
;(record as any).anchor = { ...anchor, x: 1, y: 0, isPrecise: false }
|
|
214
|
+
}
|
|
215
|
+
return record
|
|
216
|
+
},
|
|
217
|
+
down: (record) => {
|
|
218
|
+
const anchor = (record as any).anchor
|
|
219
|
+
if (anchor?.type === 'shape') {
|
|
220
|
+
;(record as any).anchor = { type: 'shape', shapeId: anchor.shapeId }
|
|
221
|
+
}
|
|
222
|
+
return record
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
// Region anchors gained an optional pin corner (where the creating drag released).
|
|
227
|
+
id: 'com.tldraw.comment-thread/3',
|
|
228
|
+
up: (record) => record,
|
|
229
|
+
down: (record) => {
|
|
230
|
+
const anchor = (record as any).anchor
|
|
231
|
+
if (anchor?.type === 'region') {
|
|
232
|
+
const { pinX: _pinX, pinY: _pinY, ...rest } = anchor
|
|
233
|
+
;(record as any).anchor = rest
|
|
234
|
+
}
|
|
235
|
+
return record
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
]),
|
|
239
|
+
validator: T.object({
|
|
240
|
+
id: idValidator<TLCommentThreadId>('comment-thread'),
|
|
241
|
+
typeName: T.literal('comment-thread'),
|
|
242
|
+
pageId: idValidator<TLPageId>('page'),
|
|
243
|
+
anchor: commentAnchorValidator,
|
|
244
|
+
createdBy: T.string,
|
|
245
|
+
createdAt: T.number,
|
|
246
|
+
resolved: T.object({ at: T.number, by: T.string }).nullable(),
|
|
247
|
+
isDeleted: T.boolean,
|
|
248
|
+
meta: T.jsonValue,
|
|
249
|
+
}),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Config for registering the `comment` record type in a tldraw schema. Pass via
|
|
254
|
+
* `commentSchemaRecords`; see `TLComment`.
|
|
255
|
+
*
|
|
256
|
+
* @public
|
|
257
|
+
*/
|
|
258
|
+
export const commentRecordConfig: CustomRecordInfo = {
|
|
259
|
+
scope: 'document',
|
|
260
|
+
migrations: createCommentGuardMigrations('comment'),
|
|
261
|
+
validator: T.object({
|
|
262
|
+
id: idValidator<TLCommentId>('comment'),
|
|
263
|
+
typeName: T.literal('comment'),
|
|
264
|
+
threadId: idValidator<TLCommentThreadId>('comment-thread'),
|
|
265
|
+
pageId: idValidator<TLPageId>('page'),
|
|
266
|
+
authorId: T.string,
|
|
267
|
+
createdAt: T.number,
|
|
268
|
+
editedAt: T.number.nullable(),
|
|
269
|
+
body: richTextValidator,
|
|
270
|
+
isDeleted: T.boolean,
|
|
271
|
+
meta: T.jsonValue,
|
|
272
|
+
}),
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Config for registering the `comment-reaction` record type in a tldraw schema. Pass via
|
|
277
|
+
* `commentSchemaRecords`; see `TLCommentReaction`.
|
|
278
|
+
*
|
|
279
|
+
* @public
|
|
280
|
+
*/
|
|
281
|
+
export const commentReactionRecordConfig: CustomRecordInfo = {
|
|
282
|
+
scope: 'document',
|
|
283
|
+
migrations: createCommentGuardMigrations('comment-reaction'),
|
|
284
|
+
validator: T.object({
|
|
285
|
+
id: idValidator<TLCommentReactionId>('comment-reaction'),
|
|
286
|
+
typeName: T.literal('comment-reaction'),
|
|
287
|
+
commentId: idValidator<TLCommentId>('comment'),
|
|
288
|
+
threadId: idValidator<TLCommentThreadId>('comment-thread'),
|
|
289
|
+
pageId: idValidator<TLPageId>('page'),
|
|
290
|
+
userId: T.string,
|
|
291
|
+
emoji: emojiValidator,
|
|
292
|
+
createdAt: T.number,
|
|
293
|
+
meta: T.jsonValue,
|
|
294
|
+
}),
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The `records` map to pass to `createTLSchema` / the client `records` option so comment
|
|
299
|
+
* threads, comments, and reactions sync. Register the types together — one without the others
|
|
300
|
+
* will fail schema validation on one side of the connection.
|
|
301
|
+
*
|
|
302
|
+
* @public
|
|
303
|
+
*/
|
|
304
|
+
export const commentSchemaRecords = {
|
|
305
|
+
'comment-thread': commentThreadRecordConfig,
|
|
306
|
+
comment: commentRecordConfig,
|
|
307
|
+
'comment-reaction': commentReactionRecordConfig,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** @public */
|
|
311
|
+
export function createCommentThreadId(id?: string): TLCommentThreadId {
|
|
312
|
+
return createCustomRecordId('comment-thread', id) as TLCommentThreadId
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** @public */
|
|
316
|
+
export function createCommentId(id?: string): TLCommentId {
|
|
317
|
+
return createCustomRecordId('comment', id) as TLCommentId
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The id of one user's reaction to one comment, derived from the (comment, user, emoji) triple
|
|
322
|
+
* rather than random, so the same triple always addresses the same record and two tabs converge
|
|
323
|
+
* instead of racing to create duplicates. The sync authorizer leans on this, so it must be
|
|
324
|
+
* injective: all three parts are URI-encoded before joining, so a `:` in any of them can't shift
|
|
325
|
+
* the boundary and collapse two triples onto one id.
|
|
326
|
+
*
|
|
327
|
+
* @public
|
|
328
|
+
*/
|
|
329
|
+
export function createCommentReactionId(
|
|
330
|
+
commentId: TLCommentId,
|
|
331
|
+
userId: string,
|
|
332
|
+
emoji: string
|
|
333
|
+
): TLCommentReactionId {
|
|
334
|
+
return createCustomRecordId(
|
|
335
|
+
'comment-reaction',
|
|
336
|
+
`${encodeURIComponent(commentId)}:${encodeURIComponent(userId)}:${encodeURIComponent(emoji)}`
|
|
337
|
+
) as TLCommentReactionId
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Type guard for `TLCommentThreadId`. `isCommentId` rejects these — `comment-thread:` and
|
|
342
|
+
* `comment:` differ in the character after `comment`, so the prefixes never overlap.
|
|
343
|
+
*
|
|
344
|
+
* @public
|
|
345
|
+
*/
|
|
346
|
+
export function isCommentThreadId(id: string): id is TLCommentThreadId {
|
|
347
|
+
return isCustomRecordId('comment-thread', id)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Type guard for `TLCommentId`. See `isCommentThreadId`.
|
|
352
|
+
*
|
|
353
|
+
* @public
|
|
354
|
+
*/
|
|
355
|
+
export function isCommentId(id: string): id is TLCommentId {
|
|
356
|
+
return isCustomRecordId('comment', id)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Type guard for `TLCommentReactionId`. See `isCommentThreadId`.
|
|
361
|
+
*
|
|
362
|
+
* @public
|
|
363
|
+
*/
|
|
364
|
+
export function isCommentReactionId(id: string): id is TLCommentReactionId {
|
|
365
|
+
return isCustomRecordId('comment-reaction', id)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Create a new comment thread record. Pair with `createComment` for the thread's first message.
|
|
370
|
+
*
|
|
371
|
+
* @public
|
|
372
|
+
*/
|
|
373
|
+
export function createCommentThread(props: {
|
|
374
|
+
pageId: TLPageId
|
|
375
|
+
anchor: TLCommentAnchor
|
|
376
|
+
createdBy: string
|
|
377
|
+
now?: number
|
|
378
|
+
meta?: JsonObject
|
|
379
|
+
}): TLCommentThread {
|
|
380
|
+
return {
|
|
381
|
+
id: createCommentThreadId(),
|
|
382
|
+
typeName: 'comment-thread',
|
|
383
|
+
pageId: props.pageId,
|
|
384
|
+
anchor: props.anchor,
|
|
385
|
+
createdBy: props.createdBy,
|
|
386
|
+
createdAt: props.now ?? Date.now(),
|
|
387
|
+
resolved: null,
|
|
388
|
+
isDeleted: false,
|
|
389
|
+
meta: props.meta ?? {},
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Create a new comment record within a thread.
|
|
395
|
+
*
|
|
396
|
+
* @public
|
|
397
|
+
*/
|
|
398
|
+
export function createComment(props: {
|
|
399
|
+
threadId: TLCommentThreadId
|
|
400
|
+
pageId: TLPageId
|
|
401
|
+
authorId: string
|
|
402
|
+
body: TLRichText
|
|
403
|
+
now?: number
|
|
404
|
+
meta?: JsonObject
|
|
405
|
+
}): TLComment {
|
|
406
|
+
return {
|
|
407
|
+
id: createCommentId(),
|
|
408
|
+
typeName: 'comment',
|
|
409
|
+
threadId: props.threadId,
|
|
410
|
+
pageId: props.pageId,
|
|
411
|
+
authorId: props.authorId,
|
|
412
|
+
createdAt: props.now ?? Date.now(),
|
|
413
|
+
editedAt: null,
|
|
414
|
+
body: props.body,
|
|
415
|
+
isDeleted: false,
|
|
416
|
+
meta: props.meta ?? {},
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Create a reaction record for one user's reaction to one comment. The id is derived from the
|
|
422
|
+
* comment and user (see `createCommentReactionId`), so re-reacting overwrites rather than adding.
|
|
423
|
+
*
|
|
424
|
+
* @public
|
|
425
|
+
*/
|
|
426
|
+
export function createCommentReaction(props: {
|
|
427
|
+
commentId: TLCommentId
|
|
428
|
+
threadId: TLCommentThreadId
|
|
429
|
+
pageId: TLPageId
|
|
430
|
+
userId: string
|
|
431
|
+
emoji: string
|
|
432
|
+
now?: number
|
|
433
|
+
meta?: JsonObject
|
|
434
|
+
}): TLCommentReaction {
|
|
435
|
+
return {
|
|
436
|
+
id: createCommentReactionId(props.commentId, props.userId, props.emoji),
|
|
437
|
+
typeName: 'comment-reaction',
|
|
438
|
+
commentId: props.commentId,
|
|
439
|
+
threadId: props.threadId,
|
|
440
|
+
pageId: props.pageId,
|
|
441
|
+
userId: props.userId,
|
|
442
|
+
emoji: props.emoji,
|
|
443
|
+
createdAt: props.now ?? Date.now(),
|
|
444
|
+
meta: props.meta ?? {},
|
|
445
|
+
}
|
|
446
|
+
}
|