@tldraw/tlschema 5.3.0-internal.fba91ed55f6c → 5.3.0-next.2fa9c61a8de6
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 +115 -11
- package/dist-cjs/index.js +5 -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 +68 -8
- package/dist-cjs/records/TLComment.js.map +2 -2
- package/dist-cjs/shapes/TLGeoShape.js +15 -1
- package/dist-cjs/shapes/TLGeoShape.js.map +2 -2
- package/dist-esm/index.d.mts +115 -11
- package/dist-esm/index.mjs +9 -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 +68 -8
- package/dist-esm/records/TLComment.mjs.map +2 -2
- 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 +6 -0
- package/src/migrations.test.ts +12 -0
- package/src/misc/TLCursor.ts +1 -0
- package/src/records/TLComment.test.ts +74 -1
- package/src/records/TLComment.ts +182 -14
- package/src/shapes/TLGeoShape.ts +21 -1
package/src/records/TLComment.ts
CHANGED
|
@@ -11,23 +11,32 @@ import { TLShapeId } from './TLShape'
|
|
|
11
11
|
* Where a comment thread is anchored on the canvas. Modeled as a discriminated union so new
|
|
12
12
|
* anchor kinds can be added without breaking existing threads:
|
|
13
13
|
*
|
|
14
|
-
* - `shape` — pinned to a shape. `x`/`y` are normalized (0–1) within the shape's
|
|
15
|
-
* the pin keeps its spot as the shape moves
|
|
14
|
+
* - `shape` — pinned to a shape. `x`/`y` are normalized (0–1) within the shape's own bounds and
|
|
15
|
+
* resolved through the shape's page transform, so the pin keeps its spot as the shape moves,
|
|
16
|
+
* resizes, and rotates. `isPrecise` mirrors arrow bindings: when
|
|
16
17
|
* true the pin sits at exactly `x`/`y`; when false (the default) it sits at a consumer-defined
|
|
17
18
|
* spot (top-right out of the box), and `x`/`y` are the remembered precise position
|
|
18
19
|
* - `point` — pinned to a fixed point on the page, in page coordinates
|
|
19
20
|
* - `region` — pinned to a rectangular area of the page, in page coordinates
|
|
20
21
|
* - `page` — a page-level thread with no spatial anchor
|
|
21
|
-
* - `text-range` — pinned to a character range inside a shape's text
|
|
22
22
|
*
|
|
23
23
|
* @public
|
|
24
24
|
*/
|
|
25
25
|
export type TLCommentAnchor =
|
|
26
26
|
| { type: 'shape'; shapeId: TLShapeId; x: number; y: number; isPrecise: boolean }
|
|
27
27
|
| { type: 'point'; x: number; y: number }
|
|
28
|
-
| {
|
|
28
|
+
| {
|
|
29
|
+
type: 'region'
|
|
30
|
+
x: number
|
|
31
|
+
y: number
|
|
32
|
+
w: number
|
|
33
|
+
h: number
|
|
34
|
+
/** The normalized (0–1) corner the pin sits on — where the creating drag was released.
|
|
35
|
+
* Absent on older records; consumers fall back to a corner of their own choosing. */
|
|
36
|
+
pinX?: number
|
|
37
|
+
pinY?: number
|
|
38
|
+
}
|
|
29
39
|
| { type: 'page' }
|
|
30
|
-
| { type: 'text-range'; shapeId: TLShapeId; from: number; to: number }
|
|
31
40
|
|
|
32
41
|
/**
|
|
33
42
|
* A comment thread. The thread owns the anchor (where the conversation lives on the canvas) and
|
|
@@ -59,6 +68,13 @@ export interface TLCommentThread extends BaseRecord<'comment-thread', TLCommentT
|
|
|
59
68
|
createdAt: number
|
|
60
69
|
/** Resolution state: when and by whom the thread was resolved, or null while open. */
|
|
61
70
|
resolved: { at: number; by: string } | null
|
|
71
|
+
/**
|
|
72
|
+
* Whether the thread is soft-deleted. Clients delete a thread by setting this flag and
|
|
73
|
+
* leaving the record (and its comments) in place, hidden from rendering. Sync servers are
|
|
74
|
+
* expected to enforce the flag as write-once and creator-only, reject hard deletes from
|
|
75
|
+
* clients, and drop flagged threads from future room loads.
|
|
76
|
+
*/
|
|
77
|
+
isDeleted: boolean
|
|
62
78
|
meta: JsonObject
|
|
63
79
|
}
|
|
64
80
|
|
|
@@ -87,12 +103,64 @@ export interface TLComment extends BaseRecord<'comment', TLCommentId> {
|
|
|
87
103
|
editedAt: number | null
|
|
88
104
|
/** Rich text body. Use `toRichText(...)` for plaintext input. */
|
|
89
105
|
body: TLRichText
|
|
106
|
+
/**
|
|
107
|
+
* Whether the comment is soft-deleted. Same model as `TLCommentThread.isDeleted` — clients
|
|
108
|
+
* delete a comment by setting this flag and leaving the record in place, hidden from
|
|
109
|
+
* rendering. Sync servers are expected to enforce the flag as write-once and author-only,
|
|
110
|
+
* reject hard deletes from clients, and drop flagged comments from future room loads.
|
|
111
|
+
*/
|
|
112
|
+
isDeleted: boolean
|
|
90
113
|
meta: JsonObject
|
|
91
114
|
}
|
|
92
115
|
|
|
93
116
|
/** @public */
|
|
94
117
|
export type TLCommentId = RecordId<TLComment>
|
|
95
118
|
|
|
119
|
+
/**
|
|
120
|
+
* One person's emoji reaction to one comment.
|
|
121
|
+
*
|
|
122
|
+
* A reaction is its own record rather than a field on the comment, for the same reason read
|
|
123
|
+
* receipts are their own row: it's one person's data about someone else's comment. Comment records
|
|
124
|
+
* are owner-only for updates, so a reaction could never be written onto them; and holding
|
|
125
|
+
* everyone's reactions in one shared field would mean each write carried the whole set, so two
|
|
126
|
+
* people reacting at once would race to overwrite each other. A record per person keeps every
|
|
127
|
+
* write to its own record, which is what lets concurrent reactions coexist.
|
|
128
|
+
*
|
|
129
|
+
* A user holds at most one record per (comment, emoji) pair — so one user can react with several
|
|
130
|
+
* different emoji to the same comment. That's enforced by the id: see `createCommentReactionId`.
|
|
131
|
+
* Restricting a user to a single reaction overall is a client-side policy layered on top (see the
|
|
132
|
+
* commenting package's `reactionMode`), not a property of this record.
|
|
133
|
+
*
|
|
134
|
+
* @public
|
|
135
|
+
*/
|
|
136
|
+
export interface TLCommentReaction extends BaseRecord<'comment-reaction', TLCommentReactionId> {
|
|
137
|
+
/** The comment being reacted to. */
|
|
138
|
+
commentId: TLCommentId
|
|
139
|
+
/** Denormalized from the comment, so a thread's reactions can be found without a join. */
|
|
140
|
+
threadId: TLCommentThreadId
|
|
141
|
+
/** Denormalized from the comment — see `TLComment.pageId`. */
|
|
142
|
+
pageId: TLPageId
|
|
143
|
+
/** Who reacted. See `TLCommentThread.createdBy` — same server-stamping caveat applies. */
|
|
144
|
+
userId: string
|
|
145
|
+
/** The emoji itself, as a string (e.g. `'👍'`), not a shortcode. */
|
|
146
|
+
emoji: string
|
|
147
|
+
createdAt: number
|
|
148
|
+
meta: JsonObject
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @public */
|
|
152
|
+
export type TLCommentReactionId = RecordId<TLCommentReaction>
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* An emoji shortcode or literal. Bounded because reaction ids embed the emoji verbatim
|
|
156
|
+
* (see {@link createCommentReactionId}), so an unbounded value means an unbounded record id.
|
|
157
|
+
*/
|
|
158
|
+
const emojiValidator = T.string.check((value) => {
|
|
159
|
+
if (value.length === 0 || value.length > 64) {
|
|
160
|
+
throw new T.ValidationError(`Expected an emoji of 1-64 characters, got ${value.length}`)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
96
164
|
const commentAnchorValidator: T.Validator<TLCommentAnchor> = T.union('type', {
|
|
97
165
|
shape: T.object({
|
|
98
166
|
type: T.literal('shape'),
|
|
@@ -112,16 +180,12 @@ const commentAnchorValidator: T.Validator<TLCommentAnchor> = T.union('type', {
|
|
|
112
180
|
y: T.number,
|
|
113
181
|
w: T.number,
|
|
114
182
|
h: T.number,
|
|
183
|
+
pinX: T.number.optional(),
|
|
184
|
+
pinY: T.number.optional(),
|
|
115
185
|
}),
|
|
116
186
|
page: T.object({
|
|
117
187
|
type: T.literal('page'),
|
|
118
188
|
}),
|
|
119
|
-
'text-range': T.object({
|
|
120
|
-
type: T.literal('text-range'),
|
|
121
|
-
shapeId: idValidator<TLShapeId>('shape'),
|
|
122
|
-
from: T.number,
|
|
123
|
-
to: T.number,
|
|
124
|
-
}),
|
|
125
189
|
})
|
|
126
190
|
|
|
127
191
|
/**
|
|
@@ -132,7 +196,7 @@ const commentAnchorValidator: T.Validator<TLCommentAnchor> = T.union('type', {
|
|
|
132
196
|
* store cannot represent.
|
|
133
197
|
*/
|
|
134
198
|
function createCommentGuardMigrations(
|
|
135
|
-
typeName: 'comment' | 'comment-thread',
|
|
199
|
+
typeName: 'comment' | 'comment-thread' | 'comment-reaction',
|
|
136
200
|
extra: Parameters<typeof createRecordMigrationSequence>[0]['sequence'] = []
|
|
137
201
|
) {
|
|
138
202
|
return createRecordMigrationSequence({
|
|
@@ -176,6 +240,19 @@ export const commentThreadRecordConfig: CustomRecordInfo = {
|
|
|
176
240
|
return record
|
|
177
241
|
},
|
|
178
242
|
},
|
|
243
|
+
{
|
|
244
|
+
// Region anchors gained an optional pin corner (where the creating drag released).
|
|
245
|
+
id: 'com.tldraw.comment-thread/3',
|
|
246
|
+
up: (record) => record,
|
|
247
|
+
down: (record) => {
|
|
248
|
+
const anchor = (record as any).anchor
|
|
249
|
+
if (anchor?.type === 'region') {
|
|
250
|
+
const { pinX: _pinX, pinY: _pinY, ...rest } = anchor
|
|
251
|
+
;(record as any).anchor = rest
|
|
252
|
+
}
|
|
253
|
+
return record
|
|
254
|
+
},
|
|
255
|
+
},
|
|
179
256
|
]),
|
|
180
257
|
validator: T.object({
|
|
181
258
|
id: idValidator<TLCommentThreadId>('comment-thread'),
|
|
@@ -185,6 +262,7 @@ export const commentThreadRecordConfig: CustomRecordInfo = {
|
|
|
185
262
|
createdBy: T.string,
|
|
186
263
|
createdAt: T.number,
|
|
187
264
|
resolved: T.object({ at: T.number, by: T.string }).nullable(),
|
|
265
|
+
isDeleted: T.boolean,
|
|
188
266
|
meta: T.jsonValue,
|
|
189
267
|
}),
|
|
190
268
|
}
|
|
@@ -207,20 +285,44 @@ export const commentRecordConfig: CustomRecordInfo = {
|
|
|
207
285
|
createdAt: T.number,
|
|
208
286
|
editedAt: T.number.nullable(),
|
|
209
287
|
body: richTextValidator,
|
|
288
|
+
isDeleted: T.boolean,
|
|
289
|
+
meta: T.jsonValue,
|
|
290
|
+
}),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Config for registering the `comment-reaction` record type in a tldraw schema. Pass via
|
|
295
|
+
* `commentSchemaRecords`; see `TLCommentReaction`.
|
|
296
|
+
*
|
|
297
|
+
* @public
|
|
298
|
+
*/
|
|
299
|
+
export const commentReactionRecordConfig: CustomRecordInfo = {
|
|
300
|
+
scope: 'document',
|
|
301
|
+
migrations: createCommentGuardMigrations('comment-reaction'),
|
|
302
|
+
validator: T.object({
|
|
303
|
+
id: idValidator<TLCommentReactionId>('comment-reaction'),
|
|
304
|
+
typeName: T.literal('comment-reaction'),
|
|
305
|
+
commentId: idValidator<TLCommentId>('comment'),
|
|
306
|
+
threadId: idValidator<TLCommentThreadId>('comment-thread'),
|
|
307
|
+
pageId: idValidator<TLPageId>('page'),
|
|
308
|
+
userId: T.string,
|
|
309
|
+
emoji: emojiValidator,
|
|
310
|
+
createdAt: T.number,
|
|
210
311
|
meta: T.jsonValue,
|
|
211
312
|
}),
|
|
212
313
|
}
|
|
213
314
|
|
|
214
315
|
/**
|
|
215
316
|
* The `records` map to pass to `createTLSchema` / the client `records` option so comment
|
|
216
|
-
* threads and
|
|
217
|
-
*
|
|
317
|
+
* threads, comments, and reactions sync. Register the types together — one without the others
|
|
318
|
+
* will fail schema validation on one side of the connection.
|
|
218
319
|
*
|
|
219
320
|
* @public
|
|
220
321
|
*/
|
|
221
322
|
export const commentSchemaRecords = {
|
|
222
323
|
'comment-thread': commentThreadRecordConfig,
|
|
223
324
|
comment: commentRecordConfig,
|
|
325
|
+
'comment-reaction': commentReactionRecordConfig,
|
|
224
326
|
}
|
|
225
327
|
|
|
226
328
|
/** @public */
|
|
@@ -233,6 +335,32 @@ export function createCommentId(id?: string): TLCommentId {
|
|
|
233
335
|
return createCustomRecordId('comment', id) as TLCommentId
|
|
234
336
|
}
|
|
235
337
|
|
|
338
|
+
/**
|
|
339
|
+
* The id of one user's reaction to one comment, derived from the (comment, user, emoji) triple
|
|
340
|
+
* rather than random. This is what makes reaction identity structural: the same triple always
|
|
341
|
+
* addresses the same record, so re-picking an emoji toggles that one record and two tabs converge
|
|
342
|
+
* instead of racing to create duplicates. A user can hold one record per emoji on a comment
|
|
343
|
+
* (multiple reactions); enforcing "at most one reaction total" is a client concern layered on top,
|
|
344
|
+
* not a property of the id. The sync authorizer leans on this derivation, so it must be injective.
|
|
345
|
+
*
|
|
346
|
+
* `emoji` here is the reaction's token — the emoji glyph for the default palette, or whatever
|
|
347
|
+
* opaque string a consumer's palette uses. All three parts are URI-encoded before joining, so a
|
|
348
|
+
* `:` in any of them can't shift the boundary and collapse two different triples onto one id. The
|
|
349
|
+
* id is only ever recomputed for comparison, never parsed back, so the encoding needs no inverse.
|
|
350
|
+
*
|
|
351
|
+
* @public
|
|
352
|
+
*/
|
|
353
|
+
export function createCommentReactionId(
|
|
354
|
+
commentId: TLCommentId,
|
|
355
|
+
userId: string,
|
|
356
|
+
emoji: string
|
|
357
|
+
): TLCommentReactionId {
|
|
358
|
+
return createCustomRecordId(
|
|
359
|
+
'comment-reaction',
|
|
360
|
+
`${encodeURIComponent(commentId)}:${encodeURIComponent(userId)}:${encodeURIComponent(emoji)}`
|
|
361
|
+
) as TLCommentReactionId
|
|
362
|
+
}
|
|
363
|
+
|
|
236
364
|
/**
|
|
237
365
|
* Type guard for `TLCommentThreadId`. Note `isCommentId` does not accept these: the prefixes
|
|
238
366
|
* are `comment-thread:` vs `comment:`, and the character after `comment` differs (`-` vs `:`),
|
|
@@ -254,6 +382,16 @@ export function isCommentId(id: string): id is TLCommentId {
|
|
|
254
382
|
return isCustomRecordId('comment', id)
|
|
255
383
|
}
|
|
256
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Type guard for `TLCommentReactionId`. As with `isCommentThreadId`, the `comment-reaction:`
|
|
387
|
+
* prefix never overlaps `comment:` — the character after `comment` differs (`-` vs `:`).
|
|
388
|
+
*
|
|
389
|
+
* @public
|
|
390
|
+
*/
|
|
391
|
+
export function isCommentReactionId(id: string): id is TLCommentReactionId {
|
|
392
|
+
return isCustomRecordId('comment-reaction', id)
|
|
393
|
+
}
|
|
394
|
+
|
|
257
395
|
/**
|
|
258
396
|
* Create a new comment thread record. Pair with `createComment` for the thread's first message.
|
|
259
397
|
*
|
|
@@ -274,6 +412,7 @@ export function createCommentThread(props: {
|
|
|
274
412
|
createdBy: props.createdBy,
|
|
275
413
|
createdAt: props.now ?? Date.now(),
|
|
276
414
|
resolved: null,
|
|
415
|
+
isDeleted: false,
|
|
277
416
|
meta: props.meta ?? {},
|
|
278
417
|
}
|
|
279
418
|
}
|
|
@@ -300,6 +439,35 @@ export function createComment(props: {
|
|
|
300
439
|
createdAt: props.now ?? Date.now(),
|
|
301
440
|
editedAt: null,
|
|
302
441
|
body: props.body,
|
|
442
|
+
isDeleted: false,
|
|
443
|
+
meta: props.meta ?? {},
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Create a reaction record for one user's reaction to one comment. The id is derived from the
|
|
449
|
+
* comment and user (see `createCommentReactionId`), so re-reacting overwrites rather than adding.
|
|
450
|
+
*
|
|
451
|
+
* @public
|
|
452
|
+
*/
|
|
453
|
+
export function createCommentReaction(props: {
|
|
454
|
+
commentId: TLCommentId
|
|
455
|
+
threadId: TLCommentThreadId
|
|
456
|
+
pageId: TLPageId
|
|
457
|
+
userId: string
|
|
458
|
+
emoji: string
|
|
459
|
+
now?: number
|
|
460
|
+
meta?: JsonObject
|
|
461
|
+
}): TLCommentReaction {
|
|
462
|
+
return {
|
|
463
|
+
id: createCommentReactionId(props.commentId, props.userId, props.emoji),
|
|
464
|
+
typeName: 'comment-reaction',
|
|
465
|
+
commentId: props.commentId,
|
|
466
|
+
threadId: props.threadId,
|
|
467
|
+
pageId: props.pageId,
|
|
468
|
+
userId: props.userId,
|
|
469
|
+
emoji: props.emoji,
|
|
470
|
+
createdAt: props.now ?? Date.now(),
|
|
303
471
|
meta: props.meta ?? {},
|
|
304
472
|
}
|
|
305
473
|
}
|
package/src/shapes/TLGeoShape.ts
CHANGED
|
@@ -90,6 +90,10 @@ export interface TLGeoShapeProps {
|
|
|
90
90
|
growY: number
|
|
91
91
|
/** Scale factor applied to the shape */
|
|
92
92
|
scale: number
|
|
93
|
+
/** Whether to mirror the shape horizontally */
|
|
94
|
+
flipX: boolean
|
|
95
|
+
/** Whether to mirror the shape vertically */
|
|
96
|
+
flipY: boolean
|
|
93
97
|
|
|
94
98
|
/** Color style for text label */
|
|
95
99
|
labelColor: TLDefaultColorStyle
|
|
@@ -142,7 +146,9 @@ export interface TLGeoShapeProps {
|
|
|
142
146
|
* labelColor: 'black',
|
|
143
147
|
* url: '',
|
|
144
148
|
* growY: 0,
|
|
145
|
-
* scale: 1
|
|
149
|
+
* scale: 1,
|
|
150
|
+
* flipX: false,
|
|
151
|
+
* flipY: false
|
|
146
152
|
* },
|
|
147
153
|
* meta: {}
|
|
148
154
|
* }
|
|
@@ -170,6 +176,8 @@ export const geoShapeProps: RecordProps<TLGeoShape> = {
|
|
|
170
176
|
h: T.nonZeroNumber,
|
|
171
177
|
growY: T.positiveNumber,
|
|
172
178
|
scale: T.nonZeroNumber,
|
|
179
|
+
flipX: T.boolean,
|
|
180
|
+
flipY: T.boolean,
|
|
173
181
|
|
|
174
182
|
// Text properties
|
|
175
183
|
labelColor: DefaultLabelColorStyle,
|
|
@@ -194,6 +202,7 @@ const geoShapeVersions = createShapePropsMigrationIds('geo', {
|
|
|
194
202
|
AddScale: 9,
|
|
195
203
|
AddRichText: 10,
|
|
196
204
|
AddRichTextAttrs: 11,
|
|
205
|
+
AddFlipProps: 12,
|
|
197
206
|
})
|
|
198
207
|
|
|
199
208
|
/**
|
|
@@ -318,5 +327,16 @@ export const geoShapeMigrations = createShapePropsMigrationSequence({
|
|
|
318
327
|
}
|
|
319
328
|
},
|
|
320
329
|
},
|
|
330
|
+
{
|
|
331
|
+
id: geoShapeVersions.AddFlipProps,
|
|
332
|
+
up: (props) => {
|
|
333
|
+
props.flipX = false
|
|
334
|
+
props.flipY = false
|
|
335
|
+
},
|
|
336
|
+
down: (props) => {
|
|
337
|
+
delete props.flipX
|
|
338
|
+
delete props.flipY
|
|
339
|
+
},
|
|
340
|
+
},
|
|
321
341
|
],
|
|
322
342
|
})
|