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
99 lines
4.3 KiB
TypeScript
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' })
|
|
)
|
|
}
|