@tldraw/tlschema 5.3.0-canary.fa3355c81e86 → 5.3.0-internal.1640468db8fd

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.
@@ -0,0 +1,330 @@
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 page bounds, so
15
+ * the pin keeps its spot as the shape moves and resizes. `isPrecise` mirrors arrow bindings: when
16
+ * true the pin sits at exactly `x`/`y`; when false (the default) it sits at a consumer-defined
17
+ * spot (top-right out of the 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
+ * - `text-range` — pinned to a character range inside a shape's text
22
+ *
23
+ * @public
24
+ */
25
+ export type TLCommentAnchor =
26
+ | { type: 'shape'; shapeId: TLShapeId; x: number; y: number; isPrecise: boolean }
27
+ | { type: 'point'; x: number; y: 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 their configured corner. */
36
+ pinX?: number
37
+ pinY?: number
38
+ }
39
+ | { type: 'page' }
40
+ | { type: 'text-range'; shapeId: TLShapeId; from: number; to: number }
41
+
42
+ /**
43
+ * A comment thread. The thread owns the anchor (where the conversation lives on the canvas) and
44
+ * the resolution state; the messages themselves are `TLComment` records pointing at the thread
45
+ * via `threadId`, ordered by `createdAt`. v1 threads are flat — no nested replies.
46
+ *
47
+ * Threads and comments are document records, but they're intended to be served through the sync
48
+ * server's object-store lane: gated by the session's `objectAccess` rather than `isReadonly`
49
+ * (so "can comment but not edit" is expressible), excluded from document snapshots and `.tldr`
50
+ * exports server-side, and persisted in a separate lane from the main document.
51
+ *
52
+ * Opt-in: register with `createTLSchema({ records: commentSchemaRecords })` on the server and
53
+ * the matching `records` option on the client — neither type is part of the default schema, and
54
+ * both sides must register them identically.
55
+ *
56
+ * @public
57
+ */
58
+ export interface TLCommentThread extends BaseRecord<'comment-thread', TLCommentThreadId> {
59
+ /** The page the thread lives on. */
60
+ pageId: TLPageId
61
+ /** Where the thread is anchored on that page. */
62
+ anchor: TLCommentAnchor
63
+ /**
64
+ * Who started the thread. Client-supplied for now; the sync server should stamp/verify this
65
+ * from the session's authenticated identity once record-level authorization lands (the same
66
+ * model presence already uses for self-ownership).
67
+ */
68
+ createdBy: string
69
+ createdAt: number
70
+ /** Resolution state: when and by whom the thread was resolved, or null while open. */
71
+ resolved: { at: number; by: string } | null
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
+ meta: JsonObject
101
+ }
102
+
103
+ /** @public */
104
+ export type TLCommentId = RecordId<TLComment>
105
+
106
+ const commentAnchorValidator: T.Validator<TLCommentAnchor> = T.union('type', {
107
+ shape: T.object({
108
+ type: T.literal('shape'),
109
+ shapeId: idValidator<TLShapeId>('shape'),
110
+ x: T.number,
111
+ y: T.number,
112
+ isPrecise: T.boolean,
113
+ }),
114
+ point: T.object({
115
+ type: T.literal('point'),
116
+ x: T.number,
117
+ y: T.number,
118
+ }),
119
+ region: T.object({
120
+ type: T.literal('region'),
121
+ x: T.number,
122
+ y: T.number,
123
+ w: T.number,
124
+ h: T.number,
125
+ pinX: T.number.optional(),
126
+ pinY: T.number.optional(),
127
+ }),
128
+ page: T.object({
129
+ type: T.literal('page'),
130
+ }),
131
+ 'text-range': T.object({
132
+ type: T.literal('text-range'),
133
+ shapeId: idValidator<TLShapeId>('shape'),
134
+ from: T.number,
135
+ to: T.number,
136
+ }),
137
+ })
138
+
139
+ /**
140
+ * Guard migrations for the comment record types. Each sequence is retroactive and starts with an
141
+ * identity migration that has no `down`: a sync server whose schema registers the comment types
142
+ * cannot down-migrate records for a session whose schema predates them, so such sessions are
143
+ * rejected with CLIENT_TOO_OLD (prompting a refresh) instead of being sent record types their
144
+ * store cannot represent.
145
+ */
146
+ function createCommentGuardMigrations(
147
+ typeName: 'comment' | 'comment-thread',
148
+ extra: Parameters<typeof createRecordMigrationSequence>[0]['sequence'] = []
149
+ ) {
150
+ return createRecordMigrationSequence({
151
+ sequenceId: `com.tldraw.${typeName}`,
152
+ recordType: typeName,
153
+ retroactive: true,
154
+ sequence: [
155
+ {
156
+ id: `com.tldraw.${typeName}/1`,
157
+ up: (record) => record,
158
+ },
159
+ ...extra,
160
+ ],
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Config for registering the `comment-thread` record type in a tldraw schema. Pass via
166
+ * `commentSchemaRecords`; see `TLCommentThread`.
167
+ *
168
+ * @public
169
+ */
170
+ export const commentThreadRecordConfig: CustomRecordInfo = {
171
+ scope: 'document',
172
+ migrations: createCommentGuardMigrations('comment-thread', [
173
+ {
174
+ // Shape anchors gained normalized x/y + isPrecise; existing ones were imprecise (top-right).
175
+ id: 'com.tldraw.comment-thread/2',
176
+ up: (record) => {
177
+ const anchor = (record as any).anchor
178
+ if (anchor?.type === 'shape' && anchor.x === undefined) {
179
+ ;(record as any).anchor = { ...anchor, x: 1, y: 0, isPrecise: false }
180
+ }
181
+ return record
182
+ },
183
+ down: (record) => {
184
+ const anchor = (record as any).anchor
185
+ if (anchor?.type === 'shape') {
186
+ ;(record as any).anchor = { type: 'shape', shapeId: anchor.shapeId }
187
+ }
188
+ return record
189
+ },
190
+ },
191
+ {
192
+ // Region anchors gained an optional pin corner (where the creating drag released).
193
+ id: 'com.tldraw.comment-thread/3',
194
+ up: (record) => record,
195
+ down: (record) => {
196
+ const anchor = (record as any).anchor
197
+ if (anchor?.type === 'region') {
198
+ const { pinX: _pinX, pinY: _pinY, ...rest } = anchor
199
+ ;(record as any).anchor = rest
200
+ }
201
+ return record
202
+ },
203
+ },
204
+ ]),
205
+ validator: T.object({
206
+ id: idValidator<TLCommentThreadId>('comment-thread'),
207
+ typeName: T.literal('comment-thread'),
208
+ pageId: idValidator<TLPageId>('page'),
209
+ anchor: commentAnchorValidator,
210
+ createdBy: T.string,
211
+ createdAt: T.number,
212
+ resolved: T.object({ at: T.number, by: T.string }).nullable(),
213
+ meta: T.jsonValue,
214
+ }),
215
+ }
216
+
217
+ /**
218
+ * Config for registering the `comment` record type in a tldraw schema. Pass via
219
+ * `commentSchemaRecords`; see `TLComment`.
220
+ *
221
+ * @public
222
+ */
223
+ export const commentRecordConfig: CustomRecordInfo = {
224
+ scope: 'document',
225
+ migrations: createCommentGuardMigrations('comment'),
226
+ validator: T.object({
227
+ id: idValidator<TLCommentId>('comment'),
228
+ typeName: T.literal('comment'),
229
+ threadId: idValidator<TLCommentThreadId>('comment-thread'),
230
+ pageId: idValidator<TLPageId>('page'),
231
+ authorId: T.string,
232
+ createdAt: T.number,
233
+ editedAt: T.number.nullable(),
234
+ body: richTextValidator,
235
+ meta: T.jsonValue,
236
+ }),
237
+ }
238
+
239
+ /**
240
+ * The `records` map to pass to `createTLSchema` / the client `records` option so comment
241
+ * threads and comments sync. Register both types together — a comment without its thread type
242
+ * (or vice versa) will fail schema validation on one side of the connection.
243
+ *
244
+ * @public
245
+ */
246
+ export const commentSchemaRecords = {
247
+ 'comment-thread': commentThreadRecordConfig,
248
+ comment: commentRecordConfig,
249
+ }
250
+
251
+ /** @public */
252
+ export function createCommentThreadId(id?: string): TLCommentThreadId {
253
+ return createCustomRecordId('comment-thread', id) as TLCommentThreadId
254
+ }
255
+
256
+ /** @public */
257
+ export function createCommentId(id?: string): TLCommentId {
258
+ return createCustomRecordId('comment', id) as TLCommentId
259
+ }
260
+
261
+ /**
262
+ * Type guard for `TLCommentThreadId`. Note `isCommentId` does not accept these: the prefixes
263
+ * are `comment-thread:` vs `comment:`, and the character after `comment` differs (`-` vs `:`),
264
+ * so the two never overlap.
265
+ *
266
+ * @public
267
+ */
268
+ export function isCommentThreadId(id: string): id is TLCommentThreadId {
269
+ return isCustomRecordId('comment-thread', id)
270
+ }
271
+
272
+ /**
273
+ * Type guard for `TLCommentId`. See `isCommentThreadId` for why `comment-thread:...` ids are
274
+ * correctly rejected here.
275
+ *
276
+ * @public
277
+ */
278
+ export function isCommentId(id: string): id is TLCommentId {
279
+ return isCustomRecordId('comment', id)
280
+ }
281
+
282
+ /**
283
+ * Create a new comment thread record. Pair with `createComment` for the thread's first message.
284
+ *
285
+ * @public
286
+ */
287
+ export function createCommentThread(props: {
288
+ pageId: TLPageId
289
+ anchor: TLCommentAnchor
290
+ createdBy: string
291
+ now?: number
292
+ meta?: JsonObject
293
+ }): TLCommentThread {
294
+ return {
295
+ id: createCommentThreadId(),
296
+ typeName: 'comment-thread',
297
+ pageId: props.pageId,
298
+ anchor: props.anchor,
299
+ createdBy: props.createdBy,
300
+ createdAt: props.now ?? Date.now(),
301
+ resolved: null,
302
+ meta: props.meta ?? {},
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Create a new comment record within a thread.
308
+ *
309
+ * @public
310
+ */
311
+ export function createComment(props: {
312
+ threadId: TLCommentThreadId
313
+ pageId: TLPageId
314
+ authorId: string
315
+ body: TLRichText
316
+ now?: number
317
+ meta?: JsonObject
318
+ }): TLComment {
319
+ return {
320
+ id: createCommentId(),
321
+ typeName: 'comment',
322
+ threadId: props.threadId,
323
+ pageId: props.pageId,
324
+ authorId: props.authorId,
325
+ createdAt: props.now ?? Date.now(),
326
+ editedAt: null,
327
+ body: props.body,
328
+ meta: props.meta ?? {},
329
+ }
330
+ }
@@ -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
  })