Files
upstream-tldraw/packages/tlschema/src/records/TLComment.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

447 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BaseRecord, RecordId, createRecordMigrationSequence } from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
import { richTextValidator, TLRichText } from '../misc/TLRichText'
import { CustomRecordInfo, createCustomRecordId, isCustomRecordId } from './TLCustomRecord'
import { TLPageId } from './TLPage'
import { TLShapeId } from './TLShape'
/**
* Where a comment thread is anchored on the canvas. Modeled as a discriminated union so new
* anchor kinds can be added without breaking existing threads:
*
* - `shape` — pinned to a shape. `x`/`y` are normalized (0–1) within the shape's bounds, so the pin
* keeps its spot as the shape moves, resizes, and rotates. `isPrecise` mirrors arrow bindings:
* when false (the default) the pin sits at a consumer-defined spot instead, top-right out of the
* box, and `x`/`y` are the remembered precise position
* - `point` — pinned to a fixed point on the page, in page coordinates
* - `region` — pinned to a rectangular area of the page, in page coordinates
* - `page` — a page-level thread with no spatial anchor
*
* @public
*/
export type TLCommentAnchor =
| { type: 'shape'; shapeId: TLShapeId; x: number; y: number; isPrecise: boolean }
| { type: 'point'; x: number; y: number }
| {
type: 'region'
x: number
y: number
w: number
h: number
/** The normalized (0–1) corner the pin sits on — where the creating drag was released.
* Absent on older records; consumers fall back to a corner of their own choosing. */
pinX?: number
pinY?: number
}
| { type: 'page' }
/**
* A comment thread. The thread owns the anchor (where the conversation lives on the canvas) and
* the resolution state; the messages themselves are `TLComment` records pointing at the thread
* via `threadId`, ordered by `createdAt`. v1 threads are flat — no nested replies.
*
* Threads and comments are document records, but they're intended to be served through the sync
* server's object-store lane: gated by the session's `objectAccess` rather than `isReadonly`
* (so "can comment but not edit" is expressible), excluded from document snapshots and `.tldr`
* exports server-side, and persisted in a separate lane from the main document.
*
* Opt-in: register with `createTLSchema({ records: commentSchemaRecords })` on the server and
* the matching `records` option on the client — neither type is part of the default schema, and
* both sides must register them identically.
*
* @public
*/
export interface TLCommentThread extends BaseRecord<'comment-thread', TLCommentThreadId> {
/** The page the thread lives on. */
pageId: TLPageId
/** Where the thread is anchored on that page. */
anchor: TLCommentAnchor
/** Who started the thread. Client-supplied; sync servers are expected to stamp/verify it. */
createdBy: string
createdAt: number
/** Resolution state: when and by whom the thread was resolved, or null while open. */
resolved: { at: number; by: string } | null
/**
* Whether the thread is soft-deleted. Clients set the flag and leave the record in place. Sync
* servers are expected to enforce it as write-once and creator-only, reject client hard
* deletes, and drop flagged threads from future room loads.
*/
isDeleted: boolean
meta: JsonObject
}
/** @public */
export type TLCommentThreadId = RecordId<TLCommentThread>
/**
* A single comment message within a thread. See `TLCommentThread` for the overall model and
* sync/registration notes.
*
* Comment mutations are deliberately not undoable — create/edit them with
* `{ history: 'ignore' }` to avoid multiplayer surprises like a comment reappearing after
* someone else deleted it.
*
* @public
*/
export interface TLComment extends BaseRecord<'comment', TLCommentId> {
/** The thread this comment belongs to. */
threadId: TLCommentThreadId
/** Denormalized from the thread so per-page queries don't need a join. */
pageId: TLPageId
/** See `TLCommentThread.createdBy` — same server-stamping caveat applies. */
authorId: string
createdAt: number
/** Null until the comment is first edited. */
editedAt: number | null
/** Rich text body. Use `toRichText(...)` for plaintext input. */
body: TLRichText
/** Whether the comment is soft-deleted. Same model as `TLCommentThread.isDeleted`. */
isDeleted: boolean
meta: JsonObject
}
/** @public */
export type TLCommentId = RecordId<TLComment>
/**
* One person's emoji reaction to one comment.
*
* A reaction is its own record rather than a field on the comment: comment records are owner-only
* for updates, and a shared field would make concurrent reactions race to overwrite each other.
*
* A user holds at most one record per (comment, emoji) pair, enforced by the derived id — see
* `createCommentReactionId`. Limiting a user to one reaction overall is client-side policy (the
* commenting package's `reactionMode`), not a property of this record.
*
* @public
*/
export interface TLCommentReaction extends BaseRecord<'comment-reaction', TLCommentReactionId> {
/** The comment being reacted to. */
commentId: TLCommentId
/** Denormalized from the comment, so a thread's reactions can be found without a join. */
threadId: TLCommentThreadId
/** Denormalized from the comment — see `TLComment.pageId`. */
pageId: TLPageId
/** Who reacted. See `TLCommentThread.createdBy` — same server-stamping caveat applies. */
userId: string
/** The emoji itself, as a string (e.g. `'👍'`), not a shortcode. */
emoji: string
createdAt: number
meta: JsonObject
}
/** @public */
export type TLCommentReactionId = RecordId<TLCommentReaction>
/**
* An emoji shortcode or literal. Bounded because reaction ids embed the emoji verbatim
* (see {@link createCommentReactionId}), so an unbounded value means an unbounded record id.
*/
const emojiValidator = T.string.check((value) => {
if (value.length === 0 || value.length > 64) {
throw new T.ValidationError(`Expected an emoji of 1-64 characters, got ${value.length}`)
}
})
const commentAnchorValidator: T.Validator<TLCommentAnchor> = T.union('type', {
shape: T.object({
type: T.literal('shape'),
shapeId: idValidator<TLShapeId>('shape'),
x: T.number,
y: T.number,
isPrecise: T.boolean,
}),
point: T.object({
type: T.literal('point'),
x: T.number,
y: T.number,
}),
region: T.object({
type: T.literal('region'),
x: T.number,
y: T.number,
w: T.number,
h: T.number,
pinX: T.number.optional(),
pinY: T.number.optional(),
}),
page: T.object({
type: T.literal('page'),
}),
})
/**
* Guard migrations for the comment record types. Each sequence starts with an identity migration
* that has no `down`, so a session whose schema predates the comment types is rejected with
* CLIENT_TOO_OLD instead of being sent records its store can't represent.
*/
function createCommentGuardMigrations(
typeName: 'comment' | 'comment-thread' | 'comment-reaction',
extra: Parameters<typeof createRecordMigrationSequence>[0]['sequence'] = []
) {
return createRecordMigrationSequence({
sequenceId: `com.tldraw.${typeName}`,
recordType: typeName,
retroactive: true,
sequence: [
{
id: `com.tldraw.${typeName}/1`,
up: (record) => record,
},
...extra,
],
})
}
/**
* Config for registering the `comment-thread` record type in a tldraw schema. Pass via
* `commentSchemaRecords`; see `TLCommentThread`.
*
* @public
*/
export const commentThreadRecordConfig: CustomRecordInfo = {
scope: 'document',
migrations: createCommentGuardMigrations('comment-thread', [
{
// Shape anchors gained normalized x/y + isPrecise; existing ones were imprecise (top-right).
id: 'com.tldraw.comment-thread/2',
up: (record) => {
const anchor = (record as any).anchor
if (anchor?.type === 'shape' && anchor.x === undefined) {
;(record as any).anchor = { ...anchor, x: 1, y: 0, isPrecise: false }
}
return record
},
down: (record) => {
const anchor = (record as any).anchor
if (anchor?.type === 'shape') {
;(record as any).anchor = { type: 'shape', shapeId: anchor.shapeId }
}
return record
},
},
{
// Region anchors gained an optional pin corner (where the creating drag released).
id: 'com.tldraw.comment-thread/3',
up: (record) => record,
down: (record) => {
const anchor = (record as any).anchor
if (anchor?.type === 'region') {
const { pinX: _pinX, pinY: _pinY, ...rest } = anchor
;(record as any).anchor = rest
}
return record
},
},
]),
validator: T.object({
id: idValidator<TLCommentThreadId>('comment-thread'),
typeName: T.literal('comment-thread'),
pageId: idValidator<TLPageId>('page'),
anchor: commentAnchorValidator,
createdBy: T.string,
createdAt: T.number,
resolved: T.object({ at: T.number, by: T.string }).nullable(),
isDeleted: T.boolean,
meta: T.jsonValue,
}),
}
/**
* Config for registering the `comment` record type in a tldraw schema. Pass via
* `commentSchemaRecords`; see `TLComment`.
*
* @public
*/
export const commentRecordConfig: CustomRecordInfo = {
scope: 'document',
migrations: createCommentGuardMigrations('comment'),
validator: T.object({
id: idValidator<TLCommentId>('comment'),
typeName: T.literal('comment'),
threadId: idValidator<TLCommentThreadId>('comment-thread'),
pageId: idValidator<TLPageId>('page'),
authorId: T.string,
createdAt: T.number,
editedAt: T.number.nullable(),
body: richTextValidator,
isDeleted: T.boolean,
meta: T.jsonValue,
}),
}
/**
* Config for registering the `comment-reaction` record type in a tldraw schema. Pass via
* `commentSchemaRecords`; see `TLCommentReaction`.
*
* @public
*/
export const commentReactionRecordConfig: CustomRecordInfo = {
scope: 'document',
migrations: createCommentGuardMigrations('comment-reaction'),
validator: T.object({
id: idValidator<TLCommentReactionId>('comment-reaction'),
typeName: T.literal('comment-reaction'),
commentId: idValidator<TLCommentId>('comment'),
threadId: idValidator<TLCommentThreadId>('comment-thread'),
pageId: idValidator<TLPageId>('page'),
userId: T.string,
emoji: emojiValidator,
createdAt: T.number,
meta: T.jsonValue,
}),
}
/**
* The `records` map to pass to `createTLSchema` / the client `records` option so comment
* threads, comments, and reactions sync. Register the types together — one without the others
* will fail schema validation on one side of the connection.
*
* @public
*/
export const commentSchemaRecords = {
'comment-thread': commentThreadRecordConfig,
comment: commentRecordConfig,
'comment-reaction': commentReactionRecordConfig,
}
/** @public */
export function createCommentThreadId(id?: string): TLCommentThreadId {
return createCustomRecordId('comment-thread', id) as TLCommentThreadId
}
/** @public */
export function createCommentId(id?: string): TLCommentId {
return createCustomRecordId('comment', id) as TLCommentId
}
/**
* The id of one user's reaction to one comment, derived from the (comment, user, emoji) triple
* rather than random, so the same triple always addresses the same record and two tabs converge
* instead of racing to create duplicates. The sync authorizer leans on this, so it must be
* injective: all three parts are URI-encoded before joining, so a `:` in any of them can't shift
* the boundary and collapse two triples onto one id.
*
* @public
*/
export function createCommentReactionId(
commentId: TLCommentId,
userId: string,
emoji: string
): TLCommentReactionId {
return createCustomRecordId(
'comment-reaction',
`${encodeURIComponent(commentId)}:${encodeURIComponent(userId)}:${encodeURIComponent(emoji)}`
) as TLCommentReactionId
}
/**
* Type guard for `TLCommentThreadId`. `isCommentId` rejects these — `comment-thread:` and
* `comment:` differ in the character after `comment`, so the prefixes never overlap.
*
* @public
*/
export function isCommentThreadId(id: string): id is TLCommentThreadId {
return isCustomRecordId('comment-thread', id)
}
/**
* Type guard for `TLCommentId`. See `isCommentThreadId`.
*
* @public
*/
export function isCommentId(id: string): id is TLCommentId {
return isCustomRecordId('comment', id)
}
/**
* Type guard for `TLCommentReactionId`. See `isCommentThreadId`.
*
* @public
*/
export function isCommentReactionId(id: string): id is TLCommentReactionId {
return isCustomRecordId('comment-reaction', id)
}
/**
* Create a new comment thread record. Pair with `createComment` for the thread's first message.
*
* @public
*/
export function createCommentThread(props: {
pageId: TLPageId
anchor: TLCommentAnchor
createdBy: string
now?: number
meta?: JsonObject
}): TLCommentThread {
return {
id: createCommentThreadId(),
typeName: 'comment-thread',
pageId: props.pageId,
anchor: props.anchor,
createdBy: props.createdBy,
createdAt: props.now ?? Date.now(),
resolved: null,
isDeleted: false,
meta: props.meta ?? {},
}
}
/**
* Create a new comment record within a thread.
*
* @public
*/
export function createComment(props: {
threadId: TLCommentThreadId
pageId: TLPageId
authorId: string
body: TLRichText
now?: number
meta?: JsonObject
}): TLComment {
return {
id: createCommentId(),
typeName: 'comment',
threadId: props.threadId,
pageId: props.pageId,
authorId: props.authorId,
createdAt: props.now ?? Date.now(),
editedAt: null,
body: props.body,
isDeleted: false,
meta: props.meta ?? {},
}
}
/**
* Create a reaction record for one user's reaction to one comment. The id is derived from the
* comment and user (see `createCommentReactionId`), so re-reacting overwrites rather than adding.
*
* @public
*/
export function createCommentReaction(props: {
commentId: TLCommentId
threadId: TLCommentThreadId
pageId: TLPageId
userId: string
emoji: string
now?: number
meta?: JsonObject
}): TLCommentReaction {
return {
id: createCommentReactionId(props.commentId, props.userId, props.emoji),
typeName: 'comment-reaction',
commentId: props.commentId,
threadId: props.threadId,
pageId: props.pageId,
userId: props.userId,
emoji: props.emoji,
createdAt: props.now ?? Date.now(),
meta: props.meta ?? {},
}
}