Files
upstream-tldraw/packages/commenting/src/canvas/comment-render.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

99 lines
4.3 KiB
TypeScript

import { type Extensions, generateText, JSONContent } from '@tiptap/core'
import { type CommentAuthor, createMentionExtension } from '@tldraw/mentions'
import { renderHtmlFromRichTextWithExtensions, TLRichText } from 'tldraw'
import { commentTipTapExtensions, isCommentEmpty } from '../ui/comment-extensions'
/**
* The author name shown in a byline when no source can name an id (e.g. a deleted account, or a
* member with no comment and no live presence). The toolkit's one generic default, applied where a
* byline needs a name; hosts pre-empt it by resolving the id from `resolveAuthor`. Not a
* translation key — a single English literal.
*/
export const UNKNOWN_AUTHOR = 'Someone'
/** The `CommentAuthor` fallback for an unresolvable id — just the name, no color or image.
* Frozen: the one shared instance is handed to host render slots. */
export const UNKNOWN_COMMENT_AUTHOR: CommentAuthor = Object.freeze({ name: UNKNOWN_AUTHOR })
/**
* The comment extension set has no heading node, so a body that nonetheless contains one (e.g. a
* record created programmatically or synced from a client with a fuller set) would make TipTap
* throw on an unknown node type. Demote any heading to a paragraph first, so headings can never
* render as headings and rendering never crashes.
*/
function demoteHeadings(node: JSONContent): JSONContent {
const content = Array.isArray(node.content) ? node.content.map(demoteHeadings) : node.content
if (node.type === 'heading') {
const { attrs: _attrs, ...rest } = node
return { ...rest, type: 'paragraph', content }
}
return content === node.content ? node : { ...node, content }
}
/**
* Whether a body contains a mention node. Mention text is resolved from a member id at render time,
* so a body with one can't be cached by identity alone (the same body renders differently as names
* change) — those bodies skip the cache and re-render each call.
*/
function hasMention(node: JSONContent): boolean {
if (node.type === 'mention') return true
return Array.isArray(node.content) ? node.content.some(hasMention) : false
}
const htmlCache = new WeakMap<TLRichText, string>()
const textCache = new WeakMap<TLRichText, string>()
/**
* Render a body through the comment extension set, memoized by body identity in `cache` unless the
* body carries a mention (see {@link hasMention}), in which case the mention extension resolving
* `resolveName` is added and nothing is cached.
*/
function renderWithCommentExtensions(
cache: WeakMap<TLRichText, string>,
richText: TLRichText,
resolveName: ((id: string) => string | undefined) | undefined,
render: (doc: JSONContent, extensions: Extensions) => string
): string {
const mentions = hasMention(richText as JSONContent)
if (!mentions) {
const cached = cache.get(richText)
if (cached !== undefined) return cached
}
const extensions = mentions
? [...commentTipTapExtensions, createMentionExtension({ resolveName })]
: commentTipTapExtensions
const result = render(demoteHeadings(richText as JSONContent), extensions)
if (!mentions) cache.set(richText, result)
return result
}
/**
* Render a comment body to HTML through the limited comment extension set (no headings), so a body
* always renders with comment formatting regardless of the host editor's rich-text config. Renders
* through tldraw's shared helper, so it gets the same empty-paragraph fix that keeps blank lines
* from collapsing. `resolveName` maps a member id to its current name for any @mentions.
*/
export function renderCommentHtml(
richText: TLRichText,
resolveName?: (id: string) => string | undefined
): string {
return renderWithCommentExtensions(htmlCache, richText, resolveName, (doc, extensions) =>
renderHtmlFromRichTextWithExtensions(doc as TLRichText, extensions)
)
}
/**
* Flatten a comment body to plaintext through the limited comment extension set — paragraphs and
* list items separated by newlines. Used for previews (e.g. the sidebar) where formatting is dropped.
* `resolveName` maps a member id to its current name for any @mentions.
*/
export function renderCommentPlaintext(
richText: TLRichText,
resolveName?: (id: string) => string | undefined
): string {
if (isCommentEmpty(richText)) return ''
return renderWithCommentExtensions(textCache, richText, resolveName, (doc, extensions) =>
generateText(doc, extensions, { blockSeparator: '\n' })
)
}