@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.
@@ -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 page bounds, so
15
- * the pin keeps its spot as the shape moves and resizes. `isPrecise` mirrors arrow bindings: when
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
- | { type: 'region'; x: number; y: number; w: number; h: number }
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 comments sync. Register both types together — a comment without its thread type
217
- * (or vice versa) will fail schema validation on one side of the connection.
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
  }
@@ -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
  })