snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
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
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
This commit is contained in:
1
packages/assets/LICENSE.md
Normal file
1
packages/assets/LICENSE.md
Normal file
@@ -0,0 +1 @@
|
||||
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)
|
||||
33
packages/assets/README.md
Normal file
33
packages/assets/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @tldraw/assets
|
||||
|
||||
This package contains assets that are used by tldraw, including icons and fonts.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
|
||||
|
||||
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
|
||||
|
||||
## Distributions
|
||||
|
||||
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## License
|
||||
|
||||
This project is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
4
packages/assets/api-extractor.json
Normal file
4
packages/assets/api-extractor.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
"extends": "../../internal/config/api-extractor.json"
|
||||
}
|
||||
2
packages/assets/imports.d.ts
vendored
Normal file
2
packages/assets/imports.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { AssetUrlOptions, AssetUrls } from './types'
|
||||
export function getAssetUrlsByImport(opts?: AssetUrlOptions): AssetUrls
|
||||
364
packages/assets/imports.js
Normal file
364
packages/assets/imports.js
Normal file
@@ -0,0 +1,364 @@
|
||||
// This file is automatically generated by internal/scripts/refresh-assets.ts.
|
||||
// Do not edit manually. Or do, I'm a comment, not a cop.
|
||||
|
||||
import embedIconsCanvaPng from './embed-icons/canva.png'
|
||||
import embedIconsCodepenPng from './embed-icons/codepen.png'
|
||||
import embedIconsCodesandboxPng from './embed-icons/codesandbox.png'
|
||||
import embedIconsDesmosPng from './embed-icons/desmos.png'
|
||||
import embedIconsExcalidrawPng from './embed-icons/excalidraw.png'
|
||||
import embedIconsFeltPng from './embed-icons/felt.png'
|
||||
import embedIconsFigmaPng from './embed-icons/figma.png'
|
||||
import embedIconsGithubGistPng from './embed-icons/github_gist.png'
|
||||
import embedIconsGoogleCalendarPng from './embed-icons/google_calendar.png'
|
||||
import embedIconsGoogleMapsPng from './embed-icons/google_maps.png'
|
||||
import embedIconsGoogleSlidesPng from './embed-icons/google_slides.png'
|
||||
import embedIconsObservablePng from './embed-icons/observable.png'
|
||||
import embedIconsReplitPng from './embed-icons/replit.png'
|
||||
import embedIconsScratchPng from './embed-icons/scratch.png'
|
||||
import embedIconsSpotifyPng from './embed-icons/spotify.png'
|
||||
import embedIconsTldrawPng from './embed-icons/tldraw.png'
|
||||
import embedIconsValTownPng from './embed-icons/val_town.png'
|
||||
import embedIconsVimeoPng from './embed-icons/vimeo.png'
|
||||
import embedIconsYoutubePng from './embed-icons/youtube.png'
|
||||
import fontsIBMPlexMonoBoldWoff2 from './fonts/IBMPlexMono-Bold.woff2'
|
||||
import fontsIBMPlexMonoBoldItalicWoff2 from './fonts/IBMPlexMono-BoldItalic.woff2'
|
||||
import fontsIBMPlexMonoMediumWoff2 from './fonts/IBMPlexMono-Medium.woff2'
|
||||
import fontsIBMPlexMonoMediumItalicWoff2 from './fonts/IBMPlexMono-MediumItalic.woff2'
|
||||
import fontsIBMPlexSansBoldWoff2 from './fonts/IBMPlexSans-Bold.woff2'
|
||||
import fontsIBMPlexSansBoldItalicWoff2 from './fonts/IBMPlexSans-BoldItalic.woff2'
|
||||
import fontsIBMPlexSansMediumWoff2 from './fonts/IBMPlexSans-Medium.woff2'
|
||||
import fontsIBMPlexSansMediumItalicWoff2 from './fonts/IBMPlexSans-MediumItalic.woff2'
|
||||
import fontsIBMPlexSerifBoldWoff2 from './fonts/IBMPlexSerif-Bold.woff2'
|
||||
import fontsIBMPlexSerifBoldItalicWoff2 from './fonts/IBMPlexSerif-BoldItalic.woff2'
|
||||
import fontsIBMPlexSerifMediumWoff2 from './fonts/IBMPlexSerif-Medium.woff2'
|
||||
import fontsIBMPlexSerifMediumItalicWoff2 from './fonts/IBMPlexSerif-MediumItalic.woff2'
|
||||
import fontsShantellSansInformalBoldWoff2 from './fonts/Shantell_Sans-Informal_Bold.woff2'
|
||||
import fontsShantellSansInformalBoldItalicWoff2 from './fonts/Shantell_Sans-Informal_Bold_Italic.woff2'
|
||||
import fontsShantellSansInformalRegularWoff2 from './fonts/Shantell_Sans-Informal_Regular.woff2'
|
||||
import fontsShantellSansInformalRegularItalicWoff2 from './fonts/Shantell_Sans-Informal_Regular_Italic.woff2'
|
||||
import iconsIcon0MergedSvg from './icons/icon/0_merged.svg'
|
||||
import translationsArJson from './translations/ar.json'
|
||||
import translationsBnJson from './translations/bn.json'
|
||||
import translationsCaJson from './translations/ca.json'
|
||||
import translationsCsJson from './translations/cs.json'
|
||||
import translationsDaJson from './translations/da.json'
|
||||
import translationsDeJson from './translations/de.json'
|
||||
import translationsElJson from './translations/el.json'
|
||||
import translationsEnJson from './translations/en.json'
|
||||
import translationsEsJson from './translations/es.json'
|
||||
import translationsFaJson from './translations/fa.json'
|
||||
import translationsFiJson from './translations/fi.json'
|
||||
import translationsFrJson from './translations/fr.json'
|
||||
import translationsGlJson from './translations/gl.json'
|
||||
import translationsGuInJson from './translations/gu-in.json'
|
||||
import translationsHeJson from './translations/he.json'
|
||||
import translationsHiInJson from './translations/hi-in.json'
|
||||
import translationsHrJson from './translations/hr.json'
|
||||
import translationsHuJson from './translations/hu.json'
|
||||
import translationsIdJson from './translations/id.json'
|
||||
import translationsItJson from './translations/it.json'
|
||||
import translationsJaJson from './translations/ja.json'
|
||||
import translationsKmKhJson from './translations/km-kh.json'
|
||||
import translationsKnJson from './translations/kn.json'
|
||||
import translationsKoKrJson from './translations/ko-kr.json'
|
||||
import translationsKuJson from './translations/ku.json'
|
||||
import translationsLanguagesJson from './translations/languages.json'
|
||||
import translationsMainJson from './translations/main.json'
|
||||
import translationsMlJson from './translations/ml.json'
|
||||
import translationsMrJson from './translations/mr.json'
|
||||
import translationsMsJson from './translations/ms.json'
|
||||
import translationsMyJson from './translations/my.json'
|
||||
import translationsNeJson from './translations/ne.json'
|
||||
import translationsNlJson from './translations/nl.json'
|
||||
import translationsNoJson from './translations/no.json'
|
||||
import translationsPaJson from './translations/pa.json'
|
||||
import translationsPlJson from './translations/pl.json'
|
||||
import translationsPtBrJson from './translations/pt-br.json'
|
||||
import translationsPtPtJson from './translations/pt-pt.json'
|
||||
import translationsRoJson from './translations/ro.json'
|
||||
import translationsRuJson from './translations/ru.json'
|
||||
import translationsSlJson from './translations/sl.json'
|
||||
import translationsSoJson from './translations/so.json'
|
||||
import translationsSvJson from './translations/sv.json'
|
||||
import translationsTaJson from './translations/ta.json'
|
||||
import translationsTeJson from './translations/te.json'
|
||||
import translationsThJson from './translations/th.json'
|
||||
import translationsTlJson from './translations/tl.json'
|
||||
import translationsTrJson from './translations/tr.json'
|
||||
import translationsUkJson from './translations/uk.json'
|
||||
import translationsUrJson from './translations/ur.json'
|
||||
import translationsViJson from './translations/vi.json'
|
||||
import translationsZhCnJson from './translations/zh-cn.json'
|
||||
import translationsZhTwJson from './translations/zh-tw.json'
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path="./modules.d.ts" />
|
||||
import { formatAssetUrl } from './utils.js'
|
||||
|
||||
/**
|
||||
* @param {AssetUrlOptions} [opts]
|
||||
* @public
|
||||
*/
|
||||
export function getAssetUrlsByImport(opts) {
|
||||
const iconsIcon0MergedSvg2 = formatAssetUrl(iconsIcon0MergedSvg, opts)
|
||||
return {
|
||||
fonts: {
|
||||
tldraw_mono_bold: formatAssetUrl(fontsIBMPlexMonoBoldWoff2, opts),
|
||||
tldraw_mono_italic_bold: formatAssetUrl(fontsIBMPlexMonoBoldItalicWoff2, opts),
|
||||
tldraw_mono: formatAssetUrl(fontsIBMPlexMonoMediumWoff2, opts),
|
||||
tldraw_mono_italic: formatAssetUrl(fontsIBMPlexMonoMediumItalicWoff2, opts),
|
||||
tldraw_sans_bold: formatAssetUrl(fontsIBMPlexSansBoldWoff2, opts),
|
||||
tldraw_sans_italic_bold: formatAssetUrl(fontsIBMPlexSansBoldItalicWoff2, opts),
|
||||
tldraw_sans: formatAssetUrl(fontsIBMPlexSansMediumWoff2, opts),
|
||||
tldraw_sans_italic: formatAssetUrl(fontsIBMPlexSansMediumItalicWoff2, opts),
|
||||
tldraw_serif_bold: formatAssetUrl(fontsIBMPlexSerifBoldWoff2, opts),
|
||||
tldraw_serif_italic_bold: formatAssetUrl(fontsIBMPlexSerifBoldItalicWoff2, opts),
|
||||
tldraw_serif: formatAssetUrl(fontsIBMPlexSerifMediumWoff2, opts),
|
||||
tldraw_serif_italic: formatAssetUrl(fontsIBMPlexSerifMediumItalicWoff2, opts),
|
||||
tldraw_draw_bold: formatAssetUrl(fontsShantellSansInformalBoldWoff2, opts),
|
||||
tldraw_draw_italic_bold: formatAssetUrl(fontsShantellSansInformalBoldItalicWoff2, opts),
|
||||
tldraw_draw: formatAssetUrl(fontsShantellSansInformalRegularWoff2, opts),
|
||||
tldraw_draw_italic: formatAssetUrl(fontsShantellSansInformalRegularItalicWoff2, opts),
|
||||
},
|
||||
icons: {
|
||||
'align-bottom': iconsIcon0MergedSvg2 + '#align-bottom',
|
||||
'align-center-horizontal': iconsIcon0MergedSvg2 + '#align-center-horizontal',
|
||||
'align-center-vertical': iconsIcon0MergedSvg2 + '#align-center-vertical',
|
||||
'align-left': iconsIcon0MergedSvg2 + '#align-left',
|
||||
'align-right': iconsIcon0MergedSvg2 + '#align-right',
|
||||
'align-top': iconsIcon0MergedSvg2 + '#align-top',
|
||||
alt: iconsIcon0MergedSvg2 + '#alt',
|
||||
'arrow-arc': iconsIcon0MergedSvg2 + '#arrow-arc',
|
||||
'arrow-cycle': iconsIcon0MergedSvg2 + '#arrow-cycle',
|
||||
'arrow-elbow': iconsIcon0MergedSvg2 + '#arrow-elbow',
|
||||
'arrow-left': iconsIcon0MergedSvg2 + '#arrow-left',
|
||||
'arrowhead-arrow': iconsIcon0MergedSvg2 + '#arrowhead-arrow',
|
||||
'arrowhead-bar': iconsIcon0MergedSvg2 + '#arrowhead-bar',
|
||||
'arrowhead-diamond': iconsIcon0MergedSvg2 + '#arrowhead-diamond',
|
||||
'arrowhead-dot': iconsIcon0MergedSvg2 + '#arrowhead-dot',
|
||||
'arrowhead-none': iconsIcon0MergedSvg2 + '#arrowhead-none',
|
||||
'arrowhead-square': iconsIcon0MergedSvg2 + '#arrowhead-square',
|
||||
'arrowhead-triangle-inverted': iconsIcon0MergedSvg2 + '#arrowhead-triangle-inverted',
|
||||
'arrowhead-triangle': iconsIcon0MergedSvg2 + '#arrowhead-triangle',
|
||||
blob: iconsIcon0MergedSvg2 + '#blob',
|
||||
bold: iconsIcon0MergedSvg2 + '#bold',
|
||||
bookmark: iconsIcon0MergedSvg2 + '#bookmark',
|
||||
'bring-forward': iconsIcon0MergedSvg2 + '#bring-forward',
|
||||
'bring-to-front': iconsIcon0MergedSvg2 + '#bring-to-front',
|
||||
broken: iconsIcon0MergedSvg2 + '#broken',
|
||||
bulletList: iconsIcon0MergedSvg2 + '#bulletList',
|
||||
'check-circle': iconsIcon0MergedSvg2 + '#check-circle',
|
||||
check: iconsIcon0MergedSvg2 + '#check',
|
||||
'chevron-down': iconsIcon0MergedSvg2 + '#chevron-down',
|
||||
'chevron-left': iconsIcon0MergedSvg2 + '#chevron-left',
|
||||
'chevron-right': iconsIcon0MergedSvg2 + '#chevron-right',
|
||||
'chevron-up': iconsIcon0MergedSvg2 + '#chevron-up',
|
||||
'chevrons-ne': iconsIcon0MergedSvg2 + '#chevrons-ne',
|
||||
'chevrons-sw': iconsIcon0MergedSvg2 + '#chevrons-sw',
|
||||
'clipboard-copied': iconsIcon0MergedSvg2 + '#clipboard-copied',
|
||||
'clipboard-copy': iconsIcon0MergedSvg2 + '#clipboard-copy',
|
||||
closed: iconsIcon0MergedSvg2 + '#closed',
|
||||
code: iconsIcon0MergedSvg2 + '#code',
|
||||
color: iconsIcon0MergedSvg2 + '#color',
|
||||
comment: iconsIcon0MergedSvg2 + '#comment',
|
||||
corners: iconsIcon0MergedSvg2 + '#corners',
|
||||
crop: iconsIcon0MergedSvg2 + '#crop',
|
||||
'cross-2': iconsIcon0MergedSvg2 + '#cross-2',
|
||||
'cross-circle': iconsIcon0MergedSvg2 + '#cross-circle',
|
||||
'dash-dashed': iconsIcon0MergedSvg2 + '#dash-dashed',
|
||||
'dash-dotted': iconsIcon0MergedSvg2 + '#dash-dotted',
|
||||
'dash-draw': iconsIcon0MergedSvg2 + '#dash-draw',
|
||||
'dash-solid': iconsIcon0MergedSvg2 + '#dash-solid',
|
||||
disconnected: iconsIcon0MergedSvg2 + '#disconnected',
|
||||
discord: iconsIcon0MergedSvg2 + '#discord',
|
||||
'distribute-horizontal': iconsIcon0MergedSvg2 + '#distribute-horizontal',
|
||||
'distribute-vertical': iconsIcon0MergedSvg2 + '#distribute-vertical',
|
||||
dot: iconsIcon0MergedSvg2 + '#dot',
|
||||
'dots-horizontal': iconsIcon0MergedSvg2 + '#dots-horizontal',
|
||||
'dots-vertical': iconsIcon0MergedSvg2 + '#dots-vertical',
|
||||
download: iconsIcon0MergedSvg2 + '#download',
|
||||
'drag-handle-dots': iconsIcon0MergedSvg2 + '#drag-handle-dots',
|
||||
duplicate: iconsIcon0MergedSvg2 + '#duplicate',
|
||||
edit: iconsIcon0MergedSvg2 + '#edit',
|
||||
'external-link': iconsIcon0MergedSvg2 + '#external-link',
|
||||
'fill-fill': iconsIcon0MergedSvg2 + '#fill-fill',
|
||||
'fill-lined-fill': iconsIcon0MergedSvg2 + '#fill-lined-fill',
|
||||
'fill-none': iconsIcon0MergedSvg2 + '#fill-none',
|
||||
'fill-pattern': iconsIcon0MergedSvg2 + '#fill-pattern',
|
||||
'fill-semi': iconsIcon0MergedSvg2 + '#fill-semi',
|
||||
'fill-solid': iconsIcon0MergedSvg2 + '#fill-solid',
|
||||
follow: iconsIcon0MergedSvg2 + '#follow',
|
||||
following: iconsIcon0MergedSvg2 + '#following',
|
||||
'font-draw': iconsIcon0MergedSvg2 + '#font-draw',
|
||||
'font-mono': iconsIcon0MergedSvg2 + '#font-mono',
|
||||
'font-sans': iconsIcon0MergedSvg2 + '#font-sans',
|
||||
'font-serif': iconsIcon0MergedSvg2 + '#font-serif',
|
||||
'geo-arrow-down': iconsIcon0MergedSvg2 + '#geo-arrow-down',
|
||||
'geo-arrow-left': iconsIcon0MergedSvg2 + '#geo-arrow-left',
|
||||
'geo-arrow-right': iconsIcon0MergedSvg2 + '#geo-arrow-right',
|
||||
'geo-arrow-up': iconsIcon0MergedSvg2 + '#geo-arrow-up',
|
||||
'geo-check-box': iconsIcon0MergedSvg2 + '#geo-check-box',
|
||||
'geo-cloud': iconsIcon0MergedSvg2 + '#geo-cloud',
|
||||
'geo-diamond': iconsIcon0MergedSvg2 + '#geo-diamond',
|
||||
'geo-ellipse': iconsIcon0MergedSvg2 + '#geo-ellipse',
|
||||
'geo-heart': iconsIcon0MergedSvg2 + '#geo-heart',
|
||||
'geo-hexagon': iconsIcon0MergedSvg2 + '#geo-hexagon',
|
||||
'geo-octagon': iconsIcon0MergedSvg2 + '#geo-octagon',
|
||||
'geo-oval': iconsIcon0MergedSvg2 + '#geo-oval',
|
||||
'geo-pentagon': iconsIcon0MergedSvg2 + '#geo-pentagon',
|
||||
'geo-rectangle': iconsIcon0MergedSvg2 + '#geo-rectangle',
|
||||
'geo-rhombus-2': iconsIcon0MergedSvg2 + '#geo-rhombus-2',
|
||||
'geo-rhombus': iconsIcon0MergedSvg2 + '#geo-rhombus',
|
||||
'geo-star': iconsIcon0MergedSvg2 + '#geo-star',
|
||||
'geo-trapezoid': iconsIcon0MergedSvg2 + '#geo-trapezoid',
|
||||
'geo-triangle': iconsIcon0MergedSvg2 + '#geo-triangle',
|
||||
'geo-x-box': iconsIcon0MergedSvg2 + '#geo-x-box',
|
||||
github: iconsIcon0MergedSvg2 + '#github',
|
||||
group: iconsIcon0MergedSvg2 + '#group',
|
||||
heading: iconsIcon0MergedSvg2 + '#heading',
|
||||
'help-circle': iconsIcon0MergedSvg2 + '#help-circle',
|
||||
highlight: iconsIcon0MergedSvg2 + '#highlight',
|
||||
'horizontal-align-end': iconsIcon0MergedSvg2 + '#horizontal-align-end',
|
||||
'horizontal-align-middle': iconsIcon0MergedSvg2 + '#horizontal-align-middle',
|
||||
'horizontal-align-start': iconsIcon0MergedSvg2 + '#horizontal-align-start',
|
||||
'info-circle': iconsIcon0MergedSvg2 + '#info-circle',
|
||||
italic: iconsIcon0MergedSvg2 + '#italic',
|
||||
leading: iconsIcon0MergedSvg2 + '#leading',
|
||||
link: iconsIcon0MergedSvg2 + '#link',
|
||||
list: iconsIcon0MergedSvg2 + '#list',
|
||||
lock: iconsIcon0MergedSvg2 + '#lock',
|
||||
manual: iconsIcon0MergedSvg2 + '#manual',
|
||||
menu: iconsIcon0MergedSvg2 + '#menu',
|
||||
minus: iconsIcon0MergedSvg2 + '#minus',
|
||||
mixed: iconsIcon0MergedSvg2 + '#mixed',
|
||||
pack: iconsIcon0MergedSvg2 + '#pack',
|
||||
plus: iconsIcon0MergedSvg2 + '#plus',
|
||||
'question-mark-circle': iconsIcon0MergedSvg2 + '#question-mark-circle',
|
||||
'question-mark': iconsIcon0MergedSvg2 + '#question-mark',
|
||||
redo: iconsIcon0MergedSvg2 + '#redo',
|
||||
'reset-zoom': iconsIcon0MergedSvg2 + '#reset-zoom',
|
||||
'rotate-ccw': iconsIcon0MergedSvg2 + '#rotate-ccw',
|
||||
'rotate-cw': iconsIcon0MergedSvg2 + '#rotate-cw',
|
||||
'send-backward': iconsIcon0MergedSvg2 + '#send-backward',
|
||||
'send-to-back': iconsIcon0MergedSvg2 + '#send-to-back',
|
||||
'share-1': iconsIcon0MergedSvg2 + '#share-1',
|
||||
'size-extra-large': iconsIcon0MergedSvg2 + '#size-extra-large',
|
||||
'size-large': iconsIcon0MergedSvg2 + '#size-large',
|
||||
'size-medium': iconsIcon0MergedSvg2 + '#size-medium',
|
||||
'size-small': iconsIcon0MergedSvg2 + '#size-small',
|
||||
'spline-cubic': iconsIcon0MergedSvg2 + '#spline-cubic',
|
||||
'spline-line': iconsIcon0MergedSvg2 + '#spline-line',
|
||||
'stack-horizontal': iconsIcon0MergedSvg2 + '#stack-horizontal',
|
||||
'stack-vertical': iconsIcon0MergedSvg2 + '#stack-vertical',
|
||||
'status-offline': iconsIcon0MergedSvg2 + '#status-offline',
|
||||
'stretch-horizontal': iconsIcon0MergedSvg2 + '#stretch-horizontal',
|
||||
'stretch-vertical': iconsIcon0MergedSvg2 + '#stretch-vertical',
|
||||
strike: iconsIcon0MergedSvg2 + '#strike',
|
||||
'text-align-center': iconsIcon0MergedSvg2 + '#text-align-center',
|
||||
'text-align-left': iconsIcon0MergedSvg2 + '#text-align-left',
|
||||
'text-align-right': iconsIcon0MergedSvg2 + '#text-align-right',
|
||||
'toggle-off': iconsIcon0MergedSvg2 + '#toggle-off',
|
||||
'toggle-on': iconsIcon0MergedSvg2 + '#toggle-on',
|
||||
'tool-arrow': iconsIcon0MergedSvg2 + '#tool-arrow',
|
||||
'tool-eraser': iconsIcon0MergedSvg2 + '#tool-eraser',
|
||||
'tool-frame': iconsIcon0MergedSvg2 + '#tool-frame',
|
||||
'tool-hand': iconsIcon0MergedSvg2 + '#tool-hand',
|
||||
'tool-highlight': iconsIcon0MergedSvg2 + '#tool-highlight',
|
||||
'tool-laser': iconsIcon0MergedSvg2 + '#tool-laser',
|
||||
'tool-line': iconsIcon0MergedSvg2 + '#tool-line',
|
||||
'tool-media': iconsIcon0MergedSvg2 + '#tool-media',
|
||||
'tool-note': iconsIcon0MergedSvg2 + '#tool-note',
|
||||
'tool-pencil': iconsIcon0MergedSvg2 + '#tool-pencil',
|
||||
'tool-pointer': iconsIcon0MergedSvg2 + '#tool-pointer',
|
||||
'tool-screenshot': iconsIcon0MergedSvg2 + '#tool-screenshot',
|
||||
'tool-text': iconsIcon0MergedSvg2 + '#tool-text',
|
||||
trash: iconsIcon0MergedSvg2 + '#trash',
|
||||
twitter: iconsIcon0MergedSvg2 + '#twitter',
|
||||
underline: iconsIcon0MergedSvg2 + '#underline',
|
||||
undo: iconsIcon0MergedSvg2 + '#undo',
|
||||
ungroup: iconsIcon0MergedSvg2 + '#ungroup',
|
||||
unlock: iconsIcon0MergedSvg2 + '#unlock',
|
||||
'vertical-align-end': iconsIcon0MergedSvg2 + '#vertical-align-end',
|
||||
'vertical-align-middle': iconsIcon0MergedSvg2 + '#vertical-align-middle',
|
||||
'vertical-align-start': iconsIcon0MergedSvg2 + '#vertical-align-start',
|
||||
'warning-triangle': iconsIcon0MergedSvg2 + '#warning-triangle',
|
||||
'zoom-in': iconsIcon0MergedSvg2 + '#zoom-in',
|
||||
'zoom-out': iconsIcon0MergedSvg2 + '#zoom-out',
|
||||
},
|
||||
translations: {
|
||||
ar: formatAssetUrl(translationsArJson, opts),
|
||||
bn: formatAssetUrl(translationsBnJson, opts),
|
||||
ca: formatAssetUrl(translationsCaJson, opts),
|
||||
cs: formatAssetUrl(translationsCsJson, opts),
|
||||
da: formatAssetUrl(translationsDaJson, opts),
|
||||
de: formatAssetUrl(translationsDeJson, opts),
|
||||
el: formatAssetUrl(translationsElJson, opts),
|
||||
en: formatAssetUrl(translationsEnJson, opts),
|
||||
es: formatAssetUrl(translationsEsJson, opts),
|
||||
fa: formatAssetUrl(translationsFaJson, opts),
|
||||
fi: formatAssetUrl(translationsFiJson, opts),
|
||||
fr: formatAssetUrl(translationsFrJson, opts),
|
||||
gl: formatAssetUrl(translationsGlJson, opts),
|
||||
'gu-in': formatAssetUrl(translationsGuInJson, opts),
|
||||
he: formatAssetUrl(translationsHeJson, opts),
|
||||
'hi-in': formatAssetUrl(translationsHiInJson, opts),
|
||||
hr: formatAssetUrl(translationsHrJson, opts),
|
||||
hu: formatAssetUrl(translationsHuJson, opts),
|
||||
id: formatAssetUrl(translationsIdJson, opts),
|
||||
it: formatAssetUrl(translationsItJson, opts),
|
||||
ja: formatAssetUrl(translationsJaJson, opts),
|
||||
'km-kh': formatAssetUrl(translationsKmKhJson, opts),
|
||||
kn: formatAssetUrl(translationsKnJson, opts),
|
||||
'ko-kr': formatAssetUrl(translationsKoKrJson, opts),
|
||||
ku: formatAssetUrl(translationsKuJson, opts),
|
||||
languages: formatAssetUrl(translationsLanguagesJson, opts),
|
||||
main: formatAssetUrl(translationsMainJson, opts),
|
||||
ml: formatAssetUrl(translationsMlJson, opts),
|
||||
mr: formatAssetUrl(translationsMrJson, opts),
|
||||
ms: formatAssetUrl(translationsMsJson, opts),
|
||||
my: formatAssetUrl(translationsMyJson, opts),
|
||||
ne: formatAssetUrl(translationsNeJson, opts),
|
||||
nl: formatAssetUrl(translationsNlJson, opts),
|
||||
no: formatAssetUrl(translationsNoJson, opts),
|
||||
pa: formatAssetUrl(translationsPaJson, opts),
|
||||
pl: formatAssetUrl(translationsPlJson, opts),
|
||||
'pt-br': formatAssetUrl(translationsPtBrJson, opts),
|
||||
'pt-pt': formatAssetUrl(translationsPtPtJson, opts),
|
||||
ro: formatAssetUrl(translationsRoJson, opts),
|
||||
ru: formatAssetUrl(translationsRuJson, opts),
|
||||
sl: formatAssetUrl(translationsSlJson, opts),
|
||||
so: formatAssetUrl(translationsSoJson, opts),
|
||||
sv: formatAssetUrl(translationsSvJson, opts),
|
||||
ta: formatAssetUrl(translationsTaJson, opts),
|
||||
te: formatAssetUrl(translationsTeJson, opts),
|
||||
th: formatAssetUrl(translationsThJson, opts),
|
||||
tl: formatAssetUrl(translationsTlJson, opts),
|
||||
tr: formatAssetUrl(translationsTrJson, opts),
|
||||
uk: formatAssetUrl(translationsUkJson, opts),
|
||||
ur: formatAssetUrl(translationsUrJson, opts),
|
||||
vi: formatAssetUrl(translationsViJson, opts),
|
||||
'zh-cn': formatAssetUrl(translationsZhCnJson, opts),
|
||||
'zh-tw': formatAssetUrl(translationsZhTwJson, opts),
|
||||
},
|
||||
embedIcons: {
|
||||
canva: formatAssetUrl(embedIconsCanvaPng, opts),
|
||||
codepen: formatAssetUrl(embedIconsCodepenPng, opts),
|
||||
codesandbox: formatAssetUrl(embedIconsCodesandboxPng, opts),
|
||||
desmos: formatAssetUrl(embedIconsDesmosPng, opts),
|
||||
excalidraw: formatAssetUrl(embedIconsExcalidrawPng, opts),
|
||||
felt: formatAssetUrl(embedIconsFeltPng, opts),
|
||||
figma: formatAssetUrl(embedIconsFigmaPng, opts),
|
||||
github_gist: formatAssetUrl(embedIconsGithubGistPng, opts),
|
||||
google_calendar: formatAssetUrl(embedIconsGoogleCalendarPng, opts),
|
||||
google_maps: formatAssetUrl(embedIconsGoogleMapsPng, opts),
|
||||
google_slides: formatAssetUrl(embedIconsGoogleSlidesPng, opts),
|
||||
observable: formatAssetUrl(embedIconsObservablePng, opts),
|
||||
replit: formatAssetUrl(embedIconsReplitPng, opts),
|
||||
scratch: formatAssetUrl(embedIconsScratchPng, opts),
|
||||
spotify: formatAssetUrl(embedIconsSpotifyPng, opts),
|
||||
tldraw: formatAssetUrl(embedIconsTldrawPng, opts),
|
||||
val_town: formatAssetUrl(embedIconsValTownPng, opts),
|
||||
vimeo: formatAssetUrl(embedIconsVimeoPng, opts),
|
||||
youtube: formatAssetUrl(embedIconsYoutubePng, opts),
|
||||
},
|
||||
}
|
||||
}
|
||||
2
packages/assets/imports.vite.d.ts
vendored
Normal file
2
packages/assets/imports.vite.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { AssetUrlOptions, AssetUrls } from './types'
|
||||
export function getAssetUrlsByImport(opts?: AssetUrlOptions): AssetUrls
|
||||
364
packages/assets/imports.vite.js
Normal file
364
packages/assets/imports.vite.js
Normal file
@@ -0,0 +1,364 @@
|
||||
// This file is automatically generated by internal/scripts/refresh-assets.ts.
|
||||
// Do not edit manually. Or do, I'm a comment, not a cop.
|
||||
|
||||
import embedIconsCanvaPngUrl from './embed-icons/canva.png?url'
|
||||
import embedIconsCodepenPngUrl from './embed-icons/codepen.png?url'
|
||||
import embedIconsCodesandboxPngUrl from './embed-icons/codesandbox.png?url'
|
||||
import embedIconsDesmosPngUrl from './embed-icons/desmos.png?url'
|
||||
import embedIconsExcalidrawPngUrl from './embed-icons/excalidraw.png?url'
|
||||
import embedIconsFeltPngUrl from './embed-icons/felt.png?url'
|
||||
import embedIconsFigmaPngUrl from './embed-icons/figma.png?url'
|
||||
import embedIconsGithubGistPngUrl from './embed-icons/github_gist.png?url'
|
||||
import embedIconsGoogleCalendarPngUrl from './embed-icons/google_calendar.png?url'
|
||||
import embedIconsGoogleMapsPngUrl from './embed-icons/google_maps.png?url'
|
||||
import embedIconsGoogleSlidesPngUrl from './embed-icons/google_slides.png?url'
|
||||
import embedIconsObservablePngUrl from './embed-icons/observable.png?url'
|
||||
import embedIconsReplitPngUrl from './embed-icons/replit.png?url'
|
||||
import embedIconsScratchPngUrl from './embed-icons/scratch.png?url'
|
||||
import embedIconsSpotifyPngUrl from './embed-icons/spotify.png?url'
|
||||
import embedIconsTldrawPngUrl from './embed-icons/tldraw.png?url'
|
||||
import embedIconsValTownPngUrl from './embed-icons/val_town.png?url'
|
||||
import embedIconsVimeoPngUrl from './embed-icons/vimeo.png?url'
|
||||
import embedIconsYoutubePngUrl from './embed-icons/youtube.png?url'
|
||||
import fontsIBMPlexMonoBoldWoff2Url from './fonts/IBMPlexMono-Bold.woff2?url'
|
||||
import fontsIBMPlexMonoBoldItalicWoff2Url from './fonts/IBMPlexMono-BoldItalic.woff2?url'
|
||||
import fontsIBMPlexMonoMediumWoff2Url from './fonts/IBMPlexMono-Medium.woff2?url'
|
||||
import fontsIBMPlexMonoMediumItalicWoff2Url from './fonts/IBMPlexMono-MediumItalic.woff2?url'
|
||||
import fontsIBMPlexSansBoldWoff2Url from './fonts/IBMPlexSans-Bold.woff2?url'
|
||||
import fontsIBMPlexSansBoldItalicWoff2Url from './fonts/IBMPlexSans-BoldItalic.woff2?url'
|
||||
import fontsIBMPlexSansMediumWoff2Url from './fonts/IBMPlexSans-Medium.woff2?url'
|
||||
import fontsIBMPlexSansMediumItalicWoff2Url from './fonts/IBMPlexSans-MediumItalic.woff2?url'
|
||||
import fontsIBMPlexSerifBoldWoff2Url from './fonts/IBMPlexSerif-Bold.woff2?url'
|
||||
import fontsIBMPlexSerifBoldItalicWoff2Url from './fonts/IBMPlexSerif-BoldItalic.woff2?url'
|
||||
import fontsIBMPlexSerifMediumWoff2Url from './fonts/IBMPlexSerif-Medium.woff2?url'
|
||||
import fontsIBMPlexSerifMediumItalicWoff2Url from './fonts/IBMPlexSerif-MediumItalic.woff2?url'
|
||||
import fontsShantellSansInformalBoldWoff2Url from './fonts/Shantell_Sans-Informal_Bold.woff2?url'
|
||||
import fontsShantellSansInformalBoldItalicWoff2Url from './fonts/Shantell_Sans-Informal_Bold_Italic.woff2?url'
|
||||
import fontsShantellSansInformalRegularWoff2Url from './fonts/Shantell_Sans-Informal_Regular.woff2?url'
|
||||
import fontsShantellSansInformalRegularItalicWoff2Url from './fonts/Shantell_Sans-Informal_Regular_Italic.woff2?url'
|
||||
import iconsIcon0MergedSvgUrl from './icons/icon/0_merged.svg?url'
|
||||
import translationsArJsonUrl from './translations/ar.json?url'
|
||||
import translationsBnJsonUrl from './translations/bn.json?url'
|
||||
import translationsCaJsonUrl from './translations/ca.json?url'
|
||||
import translationsCsJsonUrl from './translations/cs.json?url'
|
||||
import translationsDaJsonUrl from './translations/da.json?url'
|
||||
import translationsDeJsonUrl from './translations/de.json?url'
|
||||
import translationsElJsonUrl from './translations/el.json?url'
|
||||
import translationsEnJsonUrl from './translations/en.json?url'
|
||||
import translationsEsJsonUrl from './translations/es.json?url'
|
||||
import translationsFaJsonUrl from './translations/fa.json?url'
|
||||
import translationsFiJsonUrl from './translations/fi.json?url'
|
||||
import translationsFrJsonUrl from './translations/fr.json?url'
|
||||
import translationsGlJsonUrl from './translations/gl.json?url'
|
||||
import translationsGuInJsonUrl from './translations/gu-in.json?url'
|
||||
import translationsHeJsonUrl from './translations/he.json?url'
|
||||
import translationsHiInJsonUrl from './translations/hi-in.json?url'
|
||||
import translationsHrJsonUrl from './translations/hr.json?url'
|
||||
import translationsHuJsonUrl from './translations/hu.json?url'
|
||||
import translationsIdJsonUrl from './translations/id.json?url'
|
||||
import translationsItJsonUrl from './translations/it.json?url'
|
||||
import translationsJaJsonUrl from './translations/ja.json?url'
|
||||
import translationsKmKhJsonUrl from './translations/km-kh.json?url'
|
||||
import translationsKnJsonUrl from './translations/kn.json?url'
|
||||
import translationsKoKrJsonUrl from './translations/ko-kr.json?url'
|
||||
import translationsKuJsonUrl from './translations/ku.json?url'
|
||||
import translationsLanguagesJsonUrl from './translations/languages.json?url'
|
||||
import translationsMainJsonUrl from './translations/main.json?url'
|
||||
import translationsMlJsonUrl from './translations/ml.json?url'
|
||||
import translationsMrJsonUrl from './translations/mr.json?url'
|
||||
import translationsMsJsonUrl from './translations/ms.json?url'
|
||||
import translationsMyJsonUrl from './translations/my.json?url'
|
||||
import translationsNeJsonUrl from './translations/ne.json?url'
|
||||
import translationsNlJsonUrl from './translations/nl.json?url'
|
||||
import translationsNoJsonUrl from './translations/no.json?url'
|
||||
import translationsPaJsonUrl from './translations/pa.json?url'
|
||||
import translationsPlJsonUrl from './translations/pl.json?url'
|
||||
import translationsPtBrJsonUrl from './translations/pt-br.json?url'
|
||||
import translationsPtPtJsonUrl from './translations/pt-pt.json?url'
|
||||
import translationsRoJsonUrl from './translations/ro.json?url'
|
||||
import translationsRuJsonUrl from './translations/ru.json?url'
|
||||
import translationsSlJsonUrl from './translations/sl.json?url'
|
||||
import translationsSoJsonUrl from './translations/so.json?url'
|
||||
import translationsSvJsonUrl from './translations/sv.json?url'
|
||||
import translationsTaJsonUrl from './translations/ta.json?url'
|
||||
import translationsTeJsonUrl from './translations/te.json?url'
|
||||
import translationsThJsonUrl from './translations/th.json?url'
|
||||
import translationsTlJsonUrl from './translations/tl.json?url'
|
||||
import translationsTrJsonUrl from './translations/tr.json?url'
|
||||
import translationsUkJsonUrl from './translations/uk.json?url'
|
||||
import translationsUrJsonUrl from './translations/ur.json?url'
|
||||
import translationsViJsonUrl from './translations/vi.json?url'
|
||||
import translationsZhCnJsonUrl from './translations/zh-cn.json?url'
|
||||
import translationsZhTwJsonUrl from './translations/zh-tw.json?url'
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path="./modules.d.ts" />
|
||||
import { formatAssetUrl } from './utils.js'
|
||||
|
||||
/**
|
||||
* @param {AssetUrlOptions} [opts]
|
||||
* @public
|
||||
*/
|
||||
export function getAssetUrlsByImport(opts) {
|
||||
const iconsIcon0MergedSvg2 = formatAssetUrl(iconsIcon0MergedSvgUrl, opts)
|
||||
return {
|
||||
fonts: {
|
||||
tldraw_mono_bold: formatAssetUrl(fontsIBMPlexMonoBoldWoff2Url, opts),
|
||||
tldraw_mono_italic_bold: formatAssetUrl(fontsIBMPlexMonoBoldItalicWoff2Url, opts),
|
||||
tldraw_mono: formatAssetUrl(fontsIBMPlexMonoMediumWoff2Url, opts),
|
||||
tldraw_mono_italic: formatAssetUrl(fontsIBMPlexMonoMediumItalicWoff2Url, opts),
|
||||
tldraw_sans_bold: formatAssetUrl(fontsIBMPlexSansBoldWoff2Url, opts),
|
||||
tldraw_sans_italic_bold: formatAssetUrl(fontsIBMPlexSansBoldItalicWoff2Url, opts),
|
||||
tldraw_sans: formatAssetUrl(fontsIBMPlexSansMediumWoff2Url, opts),
|
||||
tldraw_sans_italic: formatAssetUrl(fontsIBMPlexSansMediumItalicWoff2Url, opts),
|
||||
tldraw_serif_bold: formatAssetUrl(fontsIBMPlexSerifBoldWoff2Url, opts),
|
||||
tldraw_serif_italic_bold: formatAssetUrl(fontsIBMPlexSerifBoldItalicWoff2Url, opts),
|
||||
tldraw_serif: formatAssetUrl(fontsIBMPlexSerifMediumWoff2Url, opts),
|
||||
tldraw_serif_italic: formatAssetUrl(fontsIBMPlexSerifMediumItalicWoff2Url, opts),
|
||||
tldraw_draw_bold: formatAssetUrl(fontsShantellSansInformalBoldWoff2Url, opts),
|
||||
tldraw_draw_italic_bold: formatAssetUrl(fontsShantellSansInformalBoldItalicWoff2Url, opts),
|
||||
tldraw_draw: formatAssetUrl(fontsShantellSansInformalRegularWoff2Url, opts),
|
||||
tldraw_draw_italic: formatAssetUrl(fontsShantellSansInformalRegularItalicWoff2Url, opts),
|
||||
},
|
||||
icons: {
|
||||
'align-bottom': iconsIcon0MergedSvg2 + '#align-bottom',
|
||||
'align-center-horizontal': iconsIcon0MergedSvg2 + '#align-center-horizontal',
|
||||
'align-center-vertical': iconsIcon0MergedSvg2 + '#align-center-vertical',
|
||||
'align-left': iconsIcon0MergedSvg2 + '#align-left',
|
||||
'align-right': iconsIcon0MergedSvg2 + '#align-right',
|
||||
'align-top': iconsIcon0MergedSvg2 + '#align-top',
|
||||
alt: iconsIcon0MergedSvg2 + '#alt',
|
||||
'arrow-arc': iconsIcon0MergedSvg2 + '#arrow-arc',
|
||||
'arrow-cycle': iconsIcon0MergedSvg2 + '#arrow-cycle',
|
||||
'arrow-elbow': iconsIcon0MergedSvg2 + '#arrow-elbow',
|
||||
'arrow-left': iconsIcon0MergedSvg2 + '#arrow-left',
|
||||
'arrowhead-arrow': iconsIcon0MergedSvg2 + '#arrowhead-arrow',
|
||||
'arrowhead-bar': iconsIcon0MergedSvg2 + '#arrowhead-bar',
|
||||
'arrowhead-diamond': iconsIcon0MergedSvg2 + '#arrowhead-diamond',
|
||||
'arrowhead-dot': iconsIcon0MergedSvg2 + '#arrowhead-dot',
|
||||
'arrowhead-none': iconsIcon0MergedSvg2 + '#arrowhead-none',
|
||||
'arrowhead-square': iconsIcon0MergedSvg2 + '#arrowhead-square',
|
||||
'arrowhead-triangle-inverted': iconsIcon0MergedSvg2 + '#arrowhead-triangle-inverted',
|
||||
'arrowhead-triangle': iconsIcon0MergedSvg2 + '#arrowhead-triangle',
|
||||
blob: iconsIcon0MergedSvg2 + '#blob',
|
||||
bold: iconsIcon0MergedSvg2 + '#bold',
|
||||
bookmark: iconsIcon0MergedSvg2 + '#bookmark',
|
||||
'bring-forward': iconsIcon0MergedSvg2 + '#bring-forward',
|
||||
'bring-to-front': iconsIcon0MergedSvg2 + '#bring-to-front',
|
||||
broken: iconsIcon0MergedSvg2 + '#broken',
|
||||
bulletList: iconsIcon0MergedSvg2 + '#bulletList',
|
||||
'check-circle': iconsIcon0MergedSvg2 + '#check-circle',
|
||||
check: iconsIcon0MergedSvg2 + '#check',
|
||||
'chevron-down': iconsIcon0MergedSvg2 + '#chevron-down',
|
||||
'chevron-left': iconsIcon0MergedSvg2 + '#chevron-left',
|
||||
'chevron-right': iconsIcon0MergedSvg2 + '#chevron-right',
|
||||
'chevron-up': iconsIcon0MergedSvg2 + '#chevron-up',
|
||||
'chevrons-ne': iconsIcon0MergedSvg2 + '#chevrons-ne',
|
||||
'chevrons-sw': iconsIcon0MergedSvg2 + '#chevrons-sw',
|
||||
'clipboard-copied': iconsIcon0MergedSvg2 + '#clipboard-copied',
|
||||
'clipboard-copy': iconsIcon0MergedSvg2 + '#clipboard-copy',
|
||||
closed: iconsIcon0MergedSvg2 + '#closed',
|
||||
code: iconsIcon0MergedSvg2 + '#code',
|
||||
color: iconsIcon0MergedSvg2 + '#color',
|
||||
comment: iconsIcon0MergedSvg2 + '#comment',
|
||||
corners: iconsIcon0MergedSvg2 + '#corners',
|
||||
crop: iconsIcon0MergedSvg2 + '#crop',
|
||||
'cross-2': iconsIcon0MergedSvg2 + '#cross-2',
|
||||
'cross-circle': iconsIcon0MergedSvg2 + '#cross-circle',
|
||||
'dash-dashed': iconsIcon0MergedSvg2 + '#dash-dashed',
|
||||
'dash-dotted': iconsIcon0MergedSvg2 + '#dash-dotted',
|
||||
'dash-draw': iconsIcon0MergedSvg2 + '#dash-draw',
|
||||
'dash-solid': iconsIcon0MergedSvg2 + '#dash-solid',
|
||||
disconnected: iconsIcon0MergedSvg2 + '#disconnected',
|
||||
discord: iconsIcon0MergedSvg2 + '#discord',
|
||||
'distribute-horizontal': iconsIcon0MergedSvg2 + '#distribute-horizontal',
|
||||
'distribute-vertical': iconsIcon0MergedSvg2 + '#distribute-vertical',
|
||||
dot: iconsIcon0MergedSvg2 + '#dot',
|
||||
'dots-horizontal': iconsIcon0MergedSvg2 + '#dots-horizontal',
|
||||
'dots-vertical': iconsIcon0MergedSvg2 + '#dots-vertical',
|
||||
download: iconsIcon0MergedSvg2 + '#download',
|
||||
'drag-handle-dots': iconsIcon0MergedSvg2 + '#drag-handle-dots',
|
||||
duplicate: iconsIcon0MergedSvg2 + '#duplicate',
|
||||
edit: iconsIcon0MergedSvg2 + '#edit',
|
||||
'external-link': iconsIcon0MergedSvg2 + '#external-link',
|
||||
'fill-fill': iconsIcon0MergedSvg2 + '#fill-fill',
|
||||
'fill-lined-fill': iconsIcon0MergedSvg2 + '#fill-lined-fill',
|
||||
'fill-none': iconsIcon0MergedSvg2 + '#fill-none',
|
||||
'fill-pattern': iconsIcon0MergedSvg2 + '#fill-pattern',
|
||||
'fill-semi': iconsIcon0MergedSvg2 + '#fill-semi',
|
||||
'fill-solid': iconsIcon0MergedSvg2 + '#fill-solid',
|
||||
follow: iconsIcon0MergedSvg2 + '#follow',
|
||||
following: iconsIcon0MergedSvg2 + '#following',
|
||||
'font-draw': iconsIcon0MergedSvg2 + '#font-draw',
|
||||
'font-mono': iconsIcon0MergedSvg2 + '#font-mono',
|
||||
'font-sans': iconsIcon0MergedSvg2 + '#font-sans',
|
||||
'font-serif': iconsIcon0MergedSvg2 + '#font-serif',
|
||||
'geo-arrow-down': iconsIcon0MergedSvg2 + '#geo-arrow-down',
|
||||
'geo-arrow-left': iconsIcon0MergedSvg2 + '#geo-arrow-left',
|
||||
'geo-arrow-right': iconsIcon0MergedSvg2 + '#geo-arrow-right',
|
||||
'geo-arrow-up': iconsIcon0MergedSvg2 + '#geo-arrow-up',
|
||||
'geo-check-box': iconsIcon0MergedSvg2 + '#geo-check-box',
|
||||
'geo-cloud': iconsIcon0MergedSvg2 + '#geo-cloud',
|
||||
'geo-diamond': iconsIcon0MergedSvg2 + '#geo-diamond',
|
||||
'geo-ellipse': iconsIcon0MergedSvg2 + '#geo-ellipse',
|
||||
'geo-heart': iconsIcon0MergedSvg2 + '#geo-heart',
|
||||
'geo-hexagon': iconsIcon0MergedSvg2 + '#geo-hexagon',
|
||||
'geo-octagon': iconsIcon0MergedSvg2 + '#geo-octagon',
|
||||
'geo-oval': iconsIcon0MergedSvg2 + '#geo-oval',
|
||||
'geo-pentagon': iconsIcon0MergedSvg2 + '#geo-pentagon',
|
||||
'geo-rectangle': iconsIcon0MergedSvg2 + '#geo-rectangle',
|
||||
'geo-rhombus-2': iconsIcon0MergedSvg2 + '#geo-rhombus-2',
|
||||
'geo-rhombus': iconsIcon0MergedSvg2 + '#geo-rhombus',
|
||||
'geo-star': iconsIcon0MergedSvg2 + '#geo-star',
|
||||
'geo-trapezoid': iconsIcon0MergedSvg2 + '#geo-trapezoid',
|
||||
'geo-triangle': iconsIcon0MergedSvg2 + '#geo-triangle',
|
||||
'geo-x-box': iconsIcon0MergedSvg2 + '#geo-x-box',
|
||||
github: iconsIcon0MergedSvg2 + '#github',
|
||||
group: iconsIcon0MergedSvg2 + '#group',
|
||||
heading: iconsIcon0MergedSvg2 + '#heading',
|
||||
'help-circle': iconsIcon0MergedSvg2 + '#help-circle',
|
||||
highlight: iconsIcon0MergedSvg2 + '#highlight',
|
||||
'horizontal-align-end': iconsIcon0MergedSvg2 + '#horizontal-align-end',
|
||||
'horizontal-align-middle': iconsIcon0MergedSvg2 + '#horizontal-align-middle',
|
||||
'horizontal-align-start': iconsIcon0MergedSvg2 + '#horizontal-align-start',
|
||||
'info-circle': iconsIcon0MergedSvg2 + '#info-circle',
|
||||
italic: iconsIcon0MergedSvg2 + '#italic',
|
||||
leading: iconsIcon0MergedSvg2 + '#leading',
|
||||
link: iconsIcon0MergedSvg2 + '#link',
|
||||
list: iconsIcon0MergedSvg2 + '#list',
|
||||
lock: iconsIcon0MergedSvg2 + '#lock',
|
||||
manual: iconsIcon0MergedSvg2 + '#manual',
|
||||
menu: iconsIcon0MergedSvg2 + '#menu',
|
||||
minus: iconsIcon0MergedSvg2 + '#minus',
|
||||
mixed: iconsIcon0MergedSvg2 + '#mixed',
|
||||
pack: iconsIcon0MergedSvg2 + '#pack',
|
||||
plus: iconsIcon0MergedSvg2 + '#plus',
|
||||
'question-mark-circle': iconsIcon0MergedSvg2 + '#question-mark-circle',
|
||||
'question-mark': iconsIcon0MergedSvg2 + '#question-mark',
|
||||
redo: iconsIcon0MergedSvg2 + '#redo',
|
||||
'reset-zoom': iconsIcon0MergedSvg2 + '#reset-zoom',
|
||||
'rotate-ccw': iconsIcon0MergedSvg2 + '#rotate-ccw',
|
||||
'rotate-cw': iconsIcon0MergedSvg2 + '#rotate-cw',
|
||||
'send-backward': iconsIcon0MergedSvg2 + '#send-backward',
|
||||
'send-to-back': iconsIcon0MergedSvg2 + '#send-to-back',
|
||||
'share-1': iconsIcon0MergedSvg2 + '#share-1',
|
||||
'size-extra-large': iconsIcon0MergedSvg2 + '#size-extra-large',
|
||||
'size-large': iconsIcon0MergedSvg2 + '#size-large',
|
||||
'size-medium': iconsIcon0MergedSvg2 + '#size-medium',
|
||||
'size-small': iconsIcon0MergedSvg2 + '#size-small',
|
||||
'spline-cubic': iconsIcon0MergedSvg2 + '#spline-cubic',
|
||||
'spline-line': iconsIcon0MergedSvg2 + '#spline-line',
|
||||
'stack-horizontal': iconsIcon0MergedSvg2 + '#stack-horizontal',
|
||||
'stack-vertical': iconsIcon0MergedSvg2 + '#stack-vertical',
|
||||
'status-offline': iconsIcon0MergedSvg2 + '#status-offline',
|
||||
'stretch-horizontal': iconsIcon0MergedSvg2 + '#stretch-horizontal',
|
||||
'stretch-vertical': iconsIcon0MergedSvg2 + '#stretch-vertical',
|
||||
strike: iconsIcon0MergedSvg2 + '#strike',
|
||||
'text-align-center': iconsIcon0MergedSvg2 + '#text-align-center',
|
||||
'text-align-left': iconsIcon0MergedSvg2 + '#text-align-left',
|
||||
'text-align-right': iconsIcon0MergedSvg2 + '#text-align-right',
|
||||
'toggle-off': iconsIcon0MergedSvg2 + '#toggle-off',
|
||||
'toggle-on': iconsIcon0MergedSvg2 + '#toggle-on',
|
||||
'tool-arrow': iconsIcon0MergedSvg2 + '#tool-arrow',
|
||||
'tool-eraser': iconsIcon0MergedSvg2 + '#tool-eraser',
|
||||
'tool-frame': iconsIcon0MergedSvg2 + '#tool-frame',
|
||||
'tool-hand': iconsIcon0MergedSvg2 + '#tool-hand',
|
||||
'tool-highlight': iconsIcon0MergedSvg2 + '#tool-highlight',
|
||||
'tool-laser': iconsIcon0MergedSvg2 + '#tool-laser',
|
||||
'tool-line': iconsIcon0MergedSvg2 + '#tool-line',
|
||||
'tool-media': iconsIcon0MergedSvg2 + '#tool-media',
|
||||
'tool-note': iconsIcon0MergedSvg2 + '#tool-note',
|
||||
'tool-pencil': iconsIcon0MergedSvg2 + '#tool-pencil',
|
||||
'tool-pointer': iconsIcon0MergedSvg2 + '#tool-pointer',
|
||||
'tool-screenshot': iconsIcon0MergedSvg2 + '#tool-screenshot',
|
||||
'tool-text': iconsIcon0MergedSvg2 + '#tool-text',
|
||||
trash: iconsIcon0MergedSvg2 + '#trash',
|
||||
twitter: iconsIcon0MergedSvg2 + '#twitter',
|
||||
underline: iconsIcon0MergedSvg2 + '#underline',
|
||||
undo: iconsIcon0MergedSvg2 + '#undo',
|
||||
ungroup: iconsIcon0MergedSvg2 + '#ungroup',
|
||||
unlock: iconsIcon0MergedSvg2 + '#unlock',
|
||||
'vertical-align-end': iconsIcon0MergedSvg2 + '#vertical-align-end',
|
||||
'vertical-align-middle': iconsIcon0MergedSvg2 + '#vertical-align-middle',
|
||||
'vertical-align-start': iconsIcon0MergedSvg2 + '#vertical-align-start',
|
||||
'warning-triangle': iconsIcon0MergedSvg2 + '#warning-triangle',
|
||||
'zoom-in': iconsIcon0MergedSvg2 + '#zoom-in',
|
||||
'zoom-out': iconsIcon0MergedSvg2 + '#zoom-out',
|
||||
},
|
||||
translations: {
|
||||
ar: formatAssetUrl(translationsArJsonUrl, opts),
|
||||
bn: formatAssetUrl(translationsBnJsonUrl, opts),
|
||||
ca: formatAssetUrl(translationsCaJsonUrl, opts),
|
||||
cs: formatAssetUrl(translationsCsJsonUrl, opts),
|
||||
da: formatAssetUrl(translationsDaJsonUrl, opts),
|
||||
de: formatAssetUrl(translationsDeJsonUrl, opts),
|
||||
el: formatAssetUrl(translationsElJsonUrl, opts),
|
||||
en: formatAssetUrl(translationsEnJsonUrl, opts),
|
||||
es: formatAssetUrl(translationsEsJsonUrl, opts),
|
||||
fa: formatAssetUrl(translationsFaJsonUrl, opts),
|
||||
fi: formatAssetUrl(translationsFiJsonUrl, opts),
|
||||
fr: formatAssetUrl(translationsFrJsonUrl, opts),
|
||||
gl: formatAssetUrl(translationsGlJsonUrl, opts),
|
||||
'gu-in': formatAssetUrl(translationsGuInJsonUrl, opts),
|
||||
he: formatAssetUrl(translationsHeJsonUrl, opts),
|
||||
'hi-in': formatAssetUrl(translationsHiInJsonUrl, opts),
|
||||
hr: formatAssetUrl(translationsHrJsonUrl, opts),
|
||||
hu: formatAssetUrl(translationsHuJsonUrl, opts),
|
||||
id: formatAssetUrl(translationsIdJsonUrl, opts),
|
||||
it: formatAssetUrl(translationsItJsonUrl, opts),
|
||||
ja: formatAssetUrl(translationsJaJsonUrl, opts),
|
||||
'km-kh': formatAssetUrl(translationsKmKhJsonUrl, opts),
|
||||
kn: formatAssetUrl(translationsKnJsonUrl, opts),
|
||||
'ko-kr': formatAssetUrl(translationsKoKrJsonUrl, opts),
|
||||
ku: formatAssetUrl(translationsKuJsonUrl, opts),
|
||||
languages: formatAssetUrl(translationsLanguagesJsonUrl, opts),
|
||||
main: formatAssetUrl(translationsMainJsonUrl, opts),
|
||||
ml: formatAssetUrl(translationsMlJsonUrl, opts),
|
||||
mr: formatAssetUrl(translationsMrJsonUrl, opts),
|
||||
ms: formatAssetUrl(translationsMsJsonUrl, opts),
|
||||
my: formatAssetUrl(translationsMyJsonUrl, opts),
|
||||
ne: formatAssetUrl(translationsNeJsonUrl, opts),
|
||||
nl: formatAssetUrl(translationsNlJsonUrl, opts),
|
||||
no: formatAssetUrl(translationsNoJsonUrl, opts),
|
||||
pa: formatAssetUrl(translationsPaJsonUrl, opts),
|
||||
pl: formatAssetUrl(translationsPlJsonUrl, opts),
|
||||
'pt-br': formatAssetUrl(translationsPtBrJsonUrl, opts),
|
||||
'pt-pt': formatAssetUrl(translationsPtPtJsonUrl, opts),
|
||||
ro: formatAssetUrl(translationsRoJsonUrl, opts),
|
||||
ru: formatAssetUrl(translationsRuJsonUrl, opts),
|
||||
sl: formatAssetUrl(translationsSlJsonUrl, opts),
|
||||
so: formatAssetUrl(translationsSoJsonUrl, opts),
|
||||
sv: formatAssetUrl(translationsSvJsonUrl, opts),
|
||||
ta: formatAssetUrl(translationsTaJsonUrl, opts),
|
||||
te: formatAssetUrl(translationsTeJsonUrl, opts),
|
||||
th: formatAssetUrl(translationsThJsonUrl, opts),
|
||||
tl: formatAssetUrl(translationsTlJsonUrl, opts),
|
||||
tr: formatAssetUrl(translationsTrJsonUrl, opts),
|
||||
uk: formatAssetUrl(translationsUkJsonUrl, opts),
|
||||
ur: formatAssetUrl(translationsUrJsonUrl, opts),
|
||||
vi: formatAssetUrl(translationsViJsonUrl, opts),
|
||||
'zh-cn': formatAssetUrl(translationsZhCnJsonUrl, opts),
|
||||
'zh-tw': formatAssetUrl(translationsZhTwJsonUrl, opts),
|
||||
},
|
||||
embedIcons: {
|
||||
canva: formatAssetUrl(embedIconsCanvaPngUrl, opts),
|
||||
codepen: formatAssetUrl(embedIconsCodepenPngUrl, opts),
|
||||
codesandbox: formatAssetUrl(embedIconsCodesandboxPngUrl, opts),
|
||||
desmos: formatAssetUrl(embedIconsDesmosPngUrl, opts),
|
||||
excalidraw: formatAssetUrl(embedIconsExcalidrawPngUrl, opts),
|
||||
felt: formatAssetUrl(embedIconsFeltPngUrl, opts),
|
||||
figma: formatAssetUrl(embedIconsFigmaPngUrl, opts),
|
||||
github_gist: formatAssetUrl(embedIconsGithubGistPngUrl, opts),
|
||||
google_calendar: formatAssetUrl(embedIconsGoogleCalendarPngUrl, opts),
|
||||
google_maps: formatAssetUrl(embedIconsGoogleMapsPngUrl, opts),
|
||||
google_slides: formatAssetUrl(embedIconsGoogleSlidesPngUrl, opts),
|
||||
observable: formatAssetUrl(embedIconsObservablePngUrl, opts),
|
||||
replit: formatAssetUrl(embedIconsReplitPngUrl, opts),
|
||||
scratch: formatAssetUrl(embedIconsScratchPngUrl, opts),
|
||||
spotify: formatAssetUrl(embedIconsSpotifyPngUrl, opts),
|
||||
tldraw: formatAssetUrl(embedIconsTldrawPngUrl, opts),
|
||||
val_town: formatAssetUrl(embedIconsValTownPngUrl, opts),
|
||||
vimeo: formatAssetUrl(embedIconsVimeoPngUrl, opts),
|
||||
youtube: formatAssetUrl(embedIconsYoutubePngUrl, opts),
|
||||
},
|
||||
}
|
||||
}
|
||||
16
packages/assets/modules.d.ts
vendored
Normal file
16
packages/assets/modules.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
declare module '*.svg' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
declare module '*.png' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
declare module '*.json' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
declare module '*.woff2' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
67
packages/assets/package.json
Normal file
67
packages/assets/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@tldraw/assets",
|
||||
"description": "tldraw infinite canvas SDK (assets).",
|
||||
"version": "5.4.2",
|
||||
"author": {
|
||||
"name": "tldraw Inc.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"tldraw_product": {
|
||||
"stableId": "tldraw:sdk-core",
|
||||
"name": "SDK",
|
||||
"type": "product",
|
||||
"premium": false
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tldraw/tldraw"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tldraw/tldraw/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"tldraw",
|
||||
"sdk",
|
||||
"drawing",
|
||||
"app",
|
||||
"development",
|
||||
"whiteboard",
|
||||
"canvas",
|
||||
"infinite"
|
||||
],
|
||||
"files": [
|
||||
"embed-icons",
|
||||
"icons",
|
||||
"fonts",
|
||||
"translations",
|
||||
"imports.js",
|
||||
"imports.d.ts",
|
||||
"imports.vite.js",
|
||||
"imports.vite.d.ts",
|
||||
"selfHosted.js",
|
||||
"selfHosted.d.ts",
|
||||
"types.d.ts",
|
||||
"urls.js",
|
||||
"urls.d.ts",
|
||||
"utils.js",
|
||||
"utils.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"pack-tarball": "yarn pack",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tldraw/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"ts-node-dev": "^1.1.8",
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
2
packages/assets/selfHosted.d.ts
vendored
Normal file
2
packages/assets/selfHosted.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { AssetUrlOptions, AssetUrls } from './types'
|
||||
export function getAssetUrls(opts?: AssetUrlOptions): AssetUrls
|
||||
281
packages/assets/selfHosted.js
Normal file
281
packages/assets/selfHosted.js
Normal file
@@ -0,0 +1,281 @@
|
||||
// This file is automatically generated by internal/scripts/refresh-assets.ts.
|
||||
// Do not edit manually. Or do, I'm a comment, not a cop.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path="./modules.d.ts" />
|
||||
import { formatAssetUrl } from './utils.js'
|
||||
|
||||
/**
|
||||
* @param {AssetUrlOptions} [opts]
|
||||
* @public
|
||||
*/
|
||||
export function getAssetUrls(opts) {
|
||||
const iconsIcon0MergedSvg = formatAssetUrl('./icons/icon/0_merged.svg', opts)
|
||||
return {
|
||||
fonts: {
|
||||
tldraw_mono_bold: formatAssetUrl('./fonts/IBMPlexMono-Bold.woff2', opts),
|
||||
tldraw_mono_italic_bold: formatAssetUrl('./fonts/IBMPlexMono-BoldItalic.woff2', opts),
|
||||
tldraw_mono: formatAssetUrl('./fonts/IBMPlexMono-Medium.woff2', opts),
|
||||
tldraw_mono_italic: formatAssetUrl('./fonts/IBMPlexMono-MediumItalic.woff2', opts),
|
||||
tldraw_sans_bold: formatAssetUrl('./fonts/IBMPlexSans-Bold.woff2', opts),
|
||||
tldraw_sans_italic_bold: formatAssetUrl('./fonts/IBMPlexSans-BoldItalic.woff2', opts),
|
||||
tldraw_sans: formatAssetUrl('./fonts/IBMPlexSans-Medium.woff2', opts),
|
||||
tldraw_sans_italic: formatAssetUrl('./fonts/IBMPlexSans-MediumItalic.woff2', opts),
|
||||
tldraw_serif_bold: formatAssetUrl('./fonts/IBMPlexSerif-Bold.woff2', opts),
|
||||
tldraw_serif_italic_bold: formatAssetUrl('./fonts/IBMPlexSerif-BoldItalic.woff2', opts),
|
||||
tldraw_serif: formatAssetUrl('./fonts/IBMPlexSerif-Medium.woff2', opts),
|
||||
tldraw_serif_italic: formatAssetUrl('./fonts/IBMPlexSerif-MediumItalic.woff2', opts),
|
||||
tldraw_draw_bold: formatAssetUrl('./fonts/Shantell_Sans-Informal_Bold.woff2', opts),
|
||||
tldraw_draw_italic_bold: formatAssetUrl(
|
||||
'./fonts/Shantell_Sans-Informal_Bold_Italic.woff2',
|
||||
opts
|
||||
),
|
||||
tldraw_draw: formatAssetUrl('./fonts/Shantell_Sans-Informal_Regular.woff2', opts),
|
||||
tldraw_draw_italic: formatAssetUrl(
|
||||
'./fonts/Shantell_Sans-Informal_Regular_Italic.woff2',
|
||||
opts
|
||||
),
|
||||
},
|
||||
icons: {
|
||||
'align-bottom': iconsIcon0MergedSvg + '#align-bottom',
|
||||
'align-center-horizontal': iconsIcon0MergedSvg + '#align-center-horizontal',
|
||||
'align-center-vertical': iconsIcon0MergedSvg + '#align-center-vertical',
|
||||
'align-left': iconsIcon0MergedSvg + '#align-left',
|
||||
'align-right': iconsIcon0MergedSvg + '#align-right',
|
||||
'align-top': iconsIcon0MergedSvg + '#align-top',
|
||||
alt: iconsIcon0MergedSvg + '#alt',
|
||||
'arrow-arc': iconsIcon0MergedSvg + '#arrow-arc',
|
||||
'arrow-cycle': iconsIcon0MergedSvg + '#arrow-cycle',
|
||||
'arrow-elbow': iconsIcon0MergedSvg + '#arrow-elbow',
|
||||
'arrow-left': iconsIcon0MergedSvg + '#arrow-left',
|
||||
'arrowhead-arrow': iconsIcon0MergedSvg + '#arrowhead-arrow',
|
||||
'arrowhead-bar': iconsIcon0MergedSvg + '#arrowhead-bar',
|
||||
'arrowhead-diamond': iconsIcon0MergedSvg + '#arrowhead-diamond',
|
||||
'arrowhead-dot': iconsIcon0MergedSvg + '#arrowhead-dot',
|
||||
'arrowhead-none': iconsIcon0MergedSvg + '#arrowhead-none',
|
||||
'arrowhead-square': iconsIcon0MergedSvg + '#arrowhead-square',
|
||||
'arrowhead-triangle-inverted': iconsIcon0MergedSvg + '#arrowhead-triangle-inverted',
|
||||
'arrowhead-triangle': iconsIcon0MergedSvg + '#arrowhead-triangle',
|
||||
blob: iconsIcon0MergedSvg + '#blob',
|
||||
bold: iconsIcon0MergedSvg + '#bold',
|
||||
bookmark: iconsIcon0MergedSvg + '#bookmark',
|
||||
'bring-forward': iconsIcon0MergedSvg + '#bring-forward',
|
||||
'bring-to-front': iconsIcon0MergedSvg + '#bring-to-front',
|
||||
broken: iconsIcon0MergedSvg + '#broken',
|
||||
bulletList: iconsIcon0MergedSvg + '#bulletList',
|
||||
'check-circle': iconsIcon0MergedSvg + '#check-circle',
|
||||
check: iconsIcon0MergedSvg + '#check',
|
||||
'chevron-down': iconsIcon0MergedSvg + '#chevron-down',
|
||||
'chevron-left': iconsIcon0MergedSvg + '#chevron-left',
|
||||
'chevron-right': iconsIcon0MergedSvg + '#chevron-right',
|
||||
'chevron-up': iconsIcon0MergedSvg + '#chevron-up',
|
||||
'chevrons-ne': iconsIcon0MergedSvg + '#chevrons-ne',
|
||||
'chevrons-sw': iconsIcon0MergedSvg + '#chevrons-sw',
|
||||
'clipboard-copied': iconsIcon0MergedSvg + '#clipboard-copied',
|
||||
'clipboard-copy': iconsIcon0MergedSvg + '#clipboard-copy',
|
||||
closed: iconsIcon0MergedSvg + '#closed',
|
||||
code: iconsIcon0MergedSvg + '#code',
|
||||
color: iconsIcon0MergedSvg + '#color',
|
||||
comment: iconsIcon0MergedSvg + '#comment',
|
||||
corners: iconsIcon0MergedSvg + '#corners',
|
||||
crop: iconsIcon0MergedSvg + '#crop',
|
||||
'cross-2': iconsIcon0MergedSvg + '#cross-2',
|
||||
'cross-circle': iconsIcon0MergedSvg + '#cross-circle',
|
||||
'dash-dashed': iconsIcon0MergedSvg + '#dash-dashed',
|
||||
'dash-dotted': iconsIcon0MergedSvg + '#dash-dotted',
|
||||
'dash-draw': iconsIcon0MergedSvg + '#dash-draw',
|
||||
'dash-solid': iconsIcon0MergedSvg + '#dash-solid',
|
||||
disconnected: iconsIcon0MergedSvg + '#disconnected',
|
||||
discord: iconsIcon0MergedSvg + '#discord',
|
||||
'distribute-horizontal': iconsIcon0MergedSvg + '#distribute-horizontal',
|
||||
'distribute-vertical': iconsIcon0MergedSvg + '#distribute-vertical',
|
||||
dot: iconsIcon0MergedSvg + '#dot',
|
||||
'dots-horizontal': iconsIcon0MergedSvg + '#dots-horizontal',
|
||||
'dots-vertical': iconsIcon0MergedSvg + '#dots-vertical',
|
||||
download: iconsIcon0MergedSvg + '#download',
|
||||
'drag-handle-dots': iconsIcon0MergedSvg + '#drag-handle-dots',
|
||||
duplicate: iconsIcon0MergedSvg + '#duplicate',
|
||||
edit: iconsIcon0MergedSvg + '#edit',
|
||||
'external-link': iconsIcon0MergedSvg + '#external-link',
|
||||
'fill-fill': iconsIcon0MergedSvg + '#fill-fill',
|
||||
'fill-lined-fill': iconsIcon0MergedSvg + '#fill-lined-fill',
|
||||
'fill-none': iconsIcon0MergedSvg + '#fill-none',
|
||||
'fill-pattern': iconsIcon0MergedSvg + '#fill-pattern',
|
||||
'fill-semi': iconsIcon0MergedSvg + '#fill-semi',
|
||||
'fill-solid': iconsIcon0MergedSvg + '#fill-solid',
|
||||
follow: iconsIcon0MergedSvg + '#follow',
|
||||
following: iconsIcon0MergedSvg + '#following',
|
||||
'font-draw': iconsIcon0MergedSvg + '#font-draw',
|
||||
'font-mono': iconsIcon0MergedSvg + '#font-mono',
|
||||
'font-sans': iconsIcon0MergedSvg + '#font-sans',
|
||||
'font-serif': iconsIcon0MergedSvg + '#font-serif',
|
||||
'geo-arrow-down': iconsIcon0MergedSvg + '#geo-arrow-down',
|
||||
'geo-arrow-left': iconsIcon0MergedSvg + '#geo-arrow-left',
|
||||
'geo-arrow-right': iconsIcon0MergedSvg + '#geo-arrow-right',
|
||||
'geo-arrow-up': iconsIcon0MergedSvg + '#geo-arrow-up',
|
||||
'geo-check-box': iconsIcon0MergedSvg + '#geo-check-box',
|
||||
'geo-cloud': iconsIcon0MergedSvg + '#geo-cloud',
|
||||
'geo-diamond': iconsIcon0MergedSvg + '#geo-diamond',
|
||||
'geo-ellipse': iconsIcon0MergedSvg + '#geo-ellipse',
|
||||
'geo-heart': iconsIcon0MergedSvg + '#geo-heart',
|
||||
'geo-hexagon': iconsIcon0MergedSvg + '#geo-hexagon',
|
||||
'geo-octagon': iconsIcon0MergedSvg + '#geo-octagon',
|
||||
'geo-oval': iconsIcon0MergedSvg + '#geo-oval',
|
||||
'geo-pentagon': iconsIcon0MergedSvg + '#geo-pentagon',
|
||||
'geo-rectangle': iconsIcon0MergedSvg + '#geo-rectangle',
|
||||
'geo-rhombus-2': iconsIcon0MergedSvg + '#geo-rhombus-2',
|
||||
'geo-rhombus': iconsIcon0MergedSvg + '#geo-rhombus',
|
||||
'geo-star': iconsIcon0MergedSvg + '#geo-star',
|
||||
'geo-trapezoid': iconsIcon0MergedSvg + '#geo-trapezoid',
|
||||
'geo-triangle': iconsIcon0MergedSvg + '#geo-triangle',
|
||||
'geo-x-box': iconsIcon0MergedSvg + '#geo-x-box',
|
||||
github: iconsIcon0MergedSvg + '#github',
|
||||
group: iconsIcon0MergedSvg + '#group',
|
||||
heading: iconsIcon0MergedSvg + '#heading',
|
||||
'help-circle': iconsIcon0MergedSvg + '#help-circle',
|
||||
highlight: iconsIcon0MergedSvg + '#highlight',
|
||||
'horizontal-align-end': iconsIcon0MergedSvg + '#horizontal-align-end',
|
||||
'horizontal-align-middle': iconsIcon0MergedSvg + '#horizontal-align-middle',
|
||||
'horizontal-align-start': iconsIcon0MergedSvg + '#horizontal-align-start',
|
||||
'info-circle': iconsIcon0MergedSvg + '#info-circle',
|
||||
italic: iconsIcon0MergedSvg + '#italic',
|
||||
leading: iconsIcon0MergedSvg + '#leading',
|
||||
link: iconsIcon0MergedSvg + '#link',
|
||||
list: iconsIcon0MergedSvg + '#list',
|
||||
lock: iconsIcon0MergedSvg + '#lock',
|
||||
manual: iconsIcon0MergedSvg + '#manual',
|
||||
menu: iconsIcon0MergedSvg + '#menu',
|
||||
minus: iconsIcon0MergedSvg + '#minus',
|
||||
mixed: iconsIcon0MergedSvg + '#mixed',
|
||||
pack: iconsIcon0MergedSvg + '#pack',
|
||||
plus: iconsIcon0MergedSvg + '#plus',
|
||||
'question-mark-circle': iconsIcon0MergedSvg + '#question-mark-circle',
|
||||
'question-mark': iconsIcon0MergedSvg + '#question-mark',
|
||||
redo: iconsIcon0MergedSvg + '#redo',
|
||||
'reset-zoom': iconsIcon0MergedSvg + '#reset-zoom',
|
||||
'rotate-ccw': iconsIcon0MergedSvg + '#rotate-ccw',
|
||||
'rotate-cw': iconsIcon0MergedSvg + '#rotate-cw',
|
||||
'send-backward': iconsIcon0MergedSvg + '#send-backward',
|
||||
'send-to-back': iconsIcon0MergedSvg + '#send-to-back',
|
||||
'share-1': iconsIcon0MergedSvg + '#share-1',
|
||||
'size-extra-large': iconsIcon0MergedSvg + '#size-extra-large',
|
||||
'size-large': iconsIcon0MergedSvg + '#size-large',
|
||||
'size-medium': iconsIcon0MergedSvg + '#size-medium',
|
||||
'size-small': iconsIcon0MergedSvg + '#size-small',
|
||||
'spline-cubic': iconsIcon0MergedSvg + '#spline-cubic',
|
||||
'spline-line': iconsIcon0MergedSvg + '#spline-line',
|
||||
'stack-horizontal': iconsIcon0MergedSvg + '#stack-horizontal',
|
||||
'stack-vertical': iconsIcon0MergedSvg + '#stack-vertical',
|
||||
'status-offline': iconsIcon0MergedSvg + '#status-offline',
|
||||
'stretch-horizontal': iconsIcon0MergedSvg + '#stretch-horizontal',
|
||||
'stretch-vertical': iconsIcon0MergedSvg + '#stretch-vertical',
|
||||
strike: iconsIcon0MergedSvg + '#strike',
|
||||
'text-align-center': iconsIcon0MergedSvg + '#text-align-center',
|
||||
'text-align-left': iconsIcon0MergedSvg + '#text-align-left',
|
||||
'text-align-right': iconsIcon0MergedSvg + '#text-align-right',
|
||||
'toggle-off': iconsIcon0MergedSvg + '#toggle-off',
|
||||
'toggle-on': iconsIcon0MergedSvg + '#toggle-on',
|
||||
'tool-arrow': iconsIcon0MergedSvg + '#tool-arrow',
|
||||
'tool-eraser': iconsIcon0MergedSvg + '#tool-eraser',
|
||||
'tool-frame': iconsIcon0MergedSvg + '#tool-frame',
|
||||
'tool-hand': iconsIcon0MergedSvg + '#tool-hand',
|
||||
'tool-highlight': iconsIcon0MergedSvg + '#tool-highlight',
|
||||
'tool-laser': iconsIcon0MergedSvg + '#tool-laser',
|
||||
'tool-line': iconsIcon0MergedSvg + '#tool-line',
|
||||
'tool-media': iconsIcon0MergedSvg + '#tool-media',
|
||||
'tool-note': iconsIcon0MergedSvg + '#tool-note',
|
||||
'tool-pencil': iconsIcon0MergedSvg + '#tool-pencil',
|
||||
'tool-pointer': iconsIcon0MergedSvg + '#tool-pointer',
|
||||
'tool-screenshot': iconsIcon0MergedSvg + '#tool-screenshot',
|
||||
'tool-text': iconsIcon0MergedSvg + '#tool-text',
|
||||
trash: iconsIcon0MergedSvg + '#trash',
|
||||
twitter: iconsIcon0MergedSvg + '#twitter',
|
||||
underline: iconsIcon0MergedSvg + '#underline',
|
||||
undo: iconsIcon0MergedSvg + '#undo',
|
||||
ungroup: iconsIcon0MergedSvg + '#ungroup',
|
||||
unlock: iconsIcon0MergedSvg + '#unlock',
|
||||
'vertical-align-end': iconsIcon0MergedSvg + '#vertical-align-end',
|
||||
'vertical-align-middle': iconsIcon0MergedSvg + '#vertical-align-middle',
|
||||
'vertical-align-start': iconsIcon0MergedSvg + '#vertical-align-start',
|
||||
'warning-triangle': iconsIcon0MergedSvg + '#warning-triangle',
|
||||
'zoom-in': iconsIcon0MergedSvg + '#zoom-in',
|
||||
'zoom-out': iconsIcon0MergedSvg + '#zoom-out',
|
||||
},
|
||||
translations: {
|
||||
ar: formatAssetUrl('./translations/ar.json', opts),
|
||||
bn: formatAssetUrl('./translations/bn.json', opts),
|
||||
ca: formatAssetUrl('./translations/ca.json', opts),
|
||||
cs: formatAssetUrl('./translations/cs.json', opts),
|
||||
da: formatAssetUrl('./translations/da.json', opts),
|
||||
de: formatAssetUrl('./translations/de.json', opts),
|
||||
el: formatAssetUrl('./translations/el.json', opts),
|
||||
en: formatAssetUrl('./translations/en.json', opts),
|
||||
es: formatAssetUrl('./translations/es.json', opts),
|
||||
fa: formatAssetUrl('./translations/fa.json', opts),
|
||||
fi: formatAssetUrl('./translations/fi.json', opts),
|
||||
fr: formatAssetUrl('./translations/fr.json', opts),
|
||||
gl: formatAssetUrl('./translations/gl.json', opts),
|
||||
'gu-in': formatAssetUrl('./translations/gu-in.json', opts),
|
||||
he: formatAssetUrl('./translations/he.json', opts),
|
||||
'hi-in': formatAssetUrl('./translations/hi-in.json', opts),
|
||||
hr: formatAssetUrl('./translations/hr.json', opts),
|
||||
hu: formatAssetUrl('./translations/hu.json', opts),
|
||||
id: formatAssetUrl('./translations/id.json', opts),
|
||||
it: formatAssetUrl('./translations/it.json', opts),
|
||||
ja: formatAssetUrl('./translations/ja.json', opts),
|
||||
'km-kh': formatAssetUrl('./translations/km-kh.json', opts),
|
||||
kn: formatAssetUrl('./translations/kn.json', opts),
|
||||
'ko-kr': formatAssetUrl('./translations/ko-kr.json', opts),
|
||||
ku: formatAssetUrl('./translations/ku.json', opts),
|
||||
languages: formatAssetUrl('./translations/languages.json', opts),
|
||||
main: formatAssetUrl('./translations/main.json', opts),
|
||||
ml: formatAssetUrl('./translations/ml.json', opts),
|
||||
mr: formatAssetUrl('./translations/mr.json', opts),
|
||||
ms: formatAssetUrl('./translations/ms.json', opts),
|
||||
my: formatAssetUrl('./translations/my.json', opts),
|
||||
ne: formatAssetUrl('./translations/ne.json', opts),
|
||||
nl: formatAssetUrl('./translations/nl.json', opts),
|
||||
no: formatAssetUrl('./translations/no.json', opts),
|
||||
pa: formatAssetUrl('./translations/pa.json', opts),
|
||||
pl: formatAssetUrl('./translations/pl.json', opts),
|
||||
'pt-br': formatAssetUrl('./translations/pt-br.json', opts),
|
||||
'pt-pt': formatAssetUrl('./translations/pt-pt.json', opts),
|
||||
ro: formatAssetUrl('./translations/ro.json', opts),
|
||||
ru: formatAssetUrl('./translations/ru.json', opts),
|
||||
sl: formatAssetUrl('./translations/sl.json', opts),
|
||||
so: formatAssetUrl('./translations/so.json', opts),
|
||||
sv: formatAssetUrl('./translations/sv.json', opts),
|
||||
ta: formatAssetUrl('./translations/ta.json', opts),
|
||||
te: formatAssetUrl('./translations/te.json', opts),
|
||||
th: formatAssetUrl('./translations/th.json', opts),
|
||||
tl: formatAssetUrl('./translations/tl.json', opts),
|
||||
tr: formatAssetUrl('./translations/tr.json', opts),
|
||||
uk: formatAssetUrl('./translations/uk.json', opts),
|
||||
ur: formatAssetUrl('./translations/ur.json', opts),
|
||||
vi: formatAssetUrl('./translations/vi.json', opts),
|
||||
'zh-cn': formatAssetUrl('./translations/zh-cn.json', opts),
|
||||
'zh-tw': formatAssetUrl('./translations/zh-tw.json', opts),
|
||||
},
|
||||
embedIcons: {
|
||||
canva: formatAssetUrl('./embed-icons/canva.png', opts),
|
||||
codepen: formatAssetUrl('./embed-icons/codepen.png', opts),
|
||||
codesandbox: formatAssetUrl('./embed-icons/codesandbox.png', opts),
|
||||
desmos: formatAssetUrl('./embed-icons/desmos.png', opts),
|
||||
excalidraw: formatAssetUrl('./embed-icons/excalidraw.png', opts),
|
||||
felt: formatAssetUrl('./embed-icons/felt.png', opts),
|
||||
figma: formatAssetUrl('./embed-icons/figma.png', opts),
|
||||
github_gist: formatAssetUrl('./embed-icons/github_gist.png', opts),
|
||||
google_calendar: formatAssetUrl('./embed-icons/google_calendar.png', opts),
|
||||
google_maps: formatAssetUrl('./embed-icons/google_maps.png', opts),
|
||||
google_slides: formatAssetUrl('./embed-icons/google_slides.png', opts),
|
||||
observable: formatAssetUrl('./embed-icons/observable.png', opts),
|
||||
replit: formatAssetUrl('./embed-icons/replit.png', opts),
|
||||
scratch: formatAssetUrl('./embed-icons/scratch.png', opts),
|
||||
spotify: formatAssetUrl('./embed-icons/spotify.png', opts),
|
||||
tldraw: formatAssetUrl('./embed-icons/tldraw.png', opts),
|
||||
val_town: formatAssetUrl('./embed-icons/val_town.png', opts),
|
||||
vimeo: formatAssetUrl('./embed-icons/vimeo.png', opts),
|
||||
youtube: formatAssetUrl('./embed-icons/youtube.png', opts),
|
||||
},
|
||||
}
|
||||
}
|
||||
14
packages/assets/tsconfig.json
Normal file
14
packages/assets/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../internal/config/tsconfig.base.json",
|
||||
"include": [
|
||||
"urls.js",
|
||||
"imports.js",
|
||||
"imports.vite.js",
|
||||
"selfHosted.js",
|
||||
"utils.js",
|
||||
"modules.d.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src", "resolveJsonModule": false },
|
||||
"references": [{ "path": "../utils" }]
|
||||
}
|
||||
266
packages/assets/types.d.ts
vendored
Normal file
266
packages/assets/types.d.ts
vendored
Normal file
@@ -0,0 +1,266 @@
|
||||
// This file is automatically generated by internal/scripts/refresh-assets.ts.
|
||||
// Do not edit manually. Or do, I'm a comment, not a cop.
|
||||
|
||||
export type AssetUrl = string | { src: string }
|
||||
export type AssetUrlOptions = { baseUrl?: string } | ((assetUrl: string) => string)
|
||||
export type AssetUrls = {
|
||||
fonts: {
|
||||
tldraw_mono_bold: string
|
||||
tldraw_mono_italic_bold: string
|
||||
tldraw_mono: string
|
||||
tldraw_mono_italic: string
|
||||
tldraw_sans_bold: string
|
||||
tldraw_sans_italic_bold: string
|
||||
tldraw_sans: string
|
||||
tldraw_sans_italic: string
|
||||
tldraw_serif_bold: string
|
||||
tldraw_serif_italic_bold: string
|
||||
tldraw_serif: string
|
||||
tldraw_serif_italic: string
|
||||
tldraw_draw_bold: string
|
||||
tldraw_draw_italic_bold: string
|
||||
tldraw_draw: string
|
||||
tldraw_draw_italic: string
|
||||
}
|
||||
icons: {
|
||||
'align-bottom': string
|
||||
'align-center-horizontal': string
|
||||
'align-center-vertical': string
|
||||
'align-left': string
|
||||
'align-right': string
|
||||
'align-top': string
|
||||
alt: string
|
||||
'arrow-arc': string
|
||||
'arrow-cycle': string
|
||||
'arrow-elbow': string
|
||||
'arrow-left': string
|
||||
'arrowhead-arrow': string
|
||||
'arrowhead-bar': string
|
||||
'arrowhead-diamond': string
|
||||
'arrowhead-dot': string
|
||||
'arrowhead-none': string
|
||||
'arrowhead-square': string
|
||||
'arrowhead-triangle-inverted': string
|
||||
'arrowhead-triangle': string
|
||||
blob: string
|
||||
bold: string
|
||||
bookmark: string
|
||||
'bring-forward': string
|
||||
'bring-to-front': string
|
||||
broken: string
|
||||
bulletList: string
|
||||
'check-circle': string
|
||||
check: string
|
||||
'chevron-down': string
|
||||
'chevron-left': string
|
||||
'chevron-right': string
|
||||
'chevron-up': string
|
||||
'chevrons-ne': string
|
||||
'chevrons-sw': string
|
||||
'clipboard-copied': string
|
||||
'clipboard-copy': string
|
||||
closed: string
|
||||
code: string
|
||||
color: string
|
||||
comment: string
|
||||
corners: string
|
||||
crop: string
|
||||
'cross-2': string
|
||||
'cross-circle': string
|
||||
'dash-dashed': string
|
||||
'dash-dotted': string
|
||||
'dash-draw': string
|
||||
'dash-solid': string
|
||||
disconnected: string
|
||||
discord: string
|
||||
'distribute-horizontal': string
|
||||
'distribute-vertical': string
|
||||
dot: string
|
||||
'dots-horizontal': string
|
||||
'dots-vertical': string
|
||||
download: string
|
||||
'drag-handle-dots': string
|
||||
duplicate: string
|
||||
edit: string
|
||||
'external-link': string
|
||||
'fill-fill': string
|
||||
'fill-lined-fill': string
|
||||
'fill-none': string
|
||||
'fill-pattern': string
|
||||
'fill-semi': string
|
||||
'fill-solid': string
|
||||
follow: string
|
||||
following: string
|
||||
'font-draw': string
|
||||
'font-mono': string
|
||||
'font-sans': string
|
||||
'font-serif': string
|
||||
'geo-arrow-down': string
|
||||
'geo-arrow-left': string
|
||||
'geo-arrow-right': string
|
||||
'geo-arrow-up': string
|
||||
'geo-check-box': string
|
||||
'geo-cloud': string
|
||||
'geo-diamond': string
|
||||
'geo-ellipse': string
|
||||
'geo-heart': string
|
||||
'geo-hexagon': string
|
||||
'geo-octagon': string
|
||||
'geo-oval': string
|
||||
'geo-pentagon': string
|
||||
'geo-rectangle': string
|
||||
'geo-rhombus-2': string
|
||||
'geo-rhombus': string
|
||||
'geo-star': string
|
||||
'geo-trapezoid': string
|
||||
'geo-triangle': string
|
||||
'geo-x-box': string
|
||||
github: string
|
||||
group: string
|
||||
heading: string
|
||||
'help-circle': string
|
||||
highlight: string
|
||||
'horizontal-align-end': string
|
||||
'horizontal-align-middle': string
|
||||
'horizontal-align-start': string
|
||||
'info-circle': string
|
||||
italic: string
|
||||
leading: string
|
||||
link: string
|
||||
list: string
|
||||
lock: string
|
||||
manual: string
|
||||
menu: string
|
||||
minus: string
|
||||
mixed: string
|
||||
pack: string
|
||||
plus: string
|
||||
'question-mark-circle': string
|
||||
'question-mark': string
|
||||
redo: string
|
||||
'reset-zoom': string
|
||||
'rotate-ccw': string
|
||||
'rotate-cw': string
|
||||
'send-backward': string
|
||||
'send-to-back': string
|
||||
'share-1': string
|
||||
'size-extra-large': string
|
||||
'size-large': string
|
||||
'size-medium': string
|
||||
'size-small': string
|
||||
'spline-cubic': string
|
||||
'spline-line': string
|
||||
'stack-horizontal': string
|
||||
'stack-vertical': string
|
||||
'status-offline': string
|
||||
'stretch-horizontal': string
|
||||
'stretch-vertical': string
|
||||
strike: string
|
||||
'text-align-center': string
|
||||
'text-align-left': string
|
||||
'text-align-right': string
|
||||
'toggle-off': string
|
||||
'toggle-on': string
|
||||
'tool-arrow': string
|
||||
'tool-eraser': string
|
||||
'tool-frame': string
|
||||
'tool-hand': string
|
||||
'tool-highlight': string
|
||||
'tool-laser': string
|
||||
'tool-line': string
|
||||
'tool-media': string
|
||||
'tool-note': string
|
||||
'tool-pencil': string
|
||||
'tool-pointer': string
|
||||
'tool-screenshot': string
|
||||
'tool-text': string
|
||||
trash: string
|
||||
twitter: string
|
||||
underline: string
|
||||
undo: string
|
||||
ungroup: string
|
||||
unlock: string
|
||||
'vertical-align-end': string
|
||||
'vertical-align-middle': string
|
||||
'vertical-align-start': string
|
||||
'warning-triangle': string
|
||||
'zoom-in': string
|
||||
'zoom-out': string
|
||||
}
|
||||
translations: {
|
||||
ar: string
|
||||
bn: string
|
||||
ca: string
|
||||
cs: string
|
||||
da: string
|
||||
de: string
|
||||
el: string
|
||||
en: string
|
||||
es: string
|
||||
fa: string
|
||||
fi: string
|
||||
fr: string
|
||||
gl: string
|
||||
'gu-in': string
|
||||
he: string
|
||||
'hi-in': string
|
||||
hr: string
|
||||
hu: string
|
||||
id: string
|
||||
it: string
|
||||
ja: string
|
||||
'km-kh': string
|
||||
kn: string
|
||||
'ko-kr': string
|
||||
ku: string
|
||||
languages: string
|
||||
main: string
|
||||
ml: string
|
||||
mr: string
|
||||
ms: string
|
||||
my: string
|
||||
ne: string
|
||||
nl: string
|
||||
no: string
|
||||
pa: string
|
||||
pl: string
|
||||
'pt-br': string
|
||||
'pt-pt': string
|
||||
ro: string
|
||||
ru: string
|
||||
sl: string
|
||||
so: string
|
||||
sv: string
|
||||
ta: string
|
||||
te: string
|
||||
th: string
|
||||
tl: string
|
||||
tr: string
|
||||
uk: string
|
||||
ur: string
|
||||
vi: string
|
||||
'zh-cn': string
|
||||
'zh-tw': string
|
||||
}
|
||||
embedIcons: {
|
||||
canva: string
|
||||
codepen: string
|
||||
codesandbox: string
|
||||
desmos: string
|
||||
excalidraw: string
|
||||
felt: string
|
||||
figma: string
|
||||
github_gist: string
|
||||
google_calendar: string
|
||||
google_maps: string
|
||||
google_slides: string
|
||||
observable: string
|
||||
replit: string
|
||||
scratch: string
|
||||
spotify: string
|
||||
tldraw: string
|
||||
val_town: string
|
||||
vimeo: string
|
||||
youtube: string
|
||||
}
|
||||
}
|
||||
2
packages/assets/urls.d.ts
vendored
Normal file
2
packages/assets/urls.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { AssetUrlOptions, AssetUrls } from './types'
|
||||
export function getAssetUrlsByMetaUrl(opts?: AssetUrlOptions): AssetUrls
|
||||
350
packages/assets/urls.js
Normal file
350
packages/assets/urls.js
Normal file
@@ -0,0 +1,350 @@
|
||||
// This file is automatically generated by internal/scripts/refresh-assets.ts.
|
||||
// Do not edit manually. Or do, I'm a comment, not a cop.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path="./modules.d.ts" />
|
||||
import { formatAssetUrl } from './utils.js'
|
||||
|
||||
/**
|
||||
* @param {AssetUrlOptions} [opts]
|
||||
* @public
|
||||
*/
|
||||
export function getAssetUrlsByMetaUrl(opts) {
|
||||
const iconsIcon0MergedSvg = formatAssetUrl(
|
||||
new URL('./icons/icon/0_merged.svg', import.meta.url).href,
|
||||
opts
|
||||
)
|
||||
return {
|
||||
fonts: {
|
||||
tldraw_mono_bold: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexMono-Bold.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_mono_italic_bold: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexMono-BoldItalic.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_mono: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexMono-Medium.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_mono_italic: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexMono-MediumItalic.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_sans_bold: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexSans-Bold.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_sans_italic_bold: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexSans-BoldItalic.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_sans: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexSans-Medium.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_sans_italic: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexSans-MediumItalic.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_serif_bold: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexSerif-Bold.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_serif_italic_bold: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexSerif-BoldItalic.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_serif: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexSerif-Medium.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_serif_italic: formatAssetUrl(
|
||||
new URL('./fonts/IBMPlexSerif-MediumItalic.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_draw_bold: formatAssetUrl(
|
||||
new URL('./fonts/Shantell_Sans-Informal_Bold.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_draw_italic_bold: formatAssetUrl(
|
||||
new URL('./fonts/Shantell_Sans-Informal_Bold_Italic.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_draw: formatAssetUrl(
|
||||
new URL('./fonts/Shantell_Sans-Informal_Regular.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
tldraw_draw_italic: formatAssetUrl(
|
||||
new URL('./fonts/Shantell_Sans-Informal_Regular_Italic.woff2', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
},
|
||||
icons: {
|
||||
'align-bottom': iconsIcon0MergedSvg + '#align-bottom',
|
||||
'align-center-horizontal': iconsIcon0MergedSvg + '#align-center-horizontal',
|
||||
'align-center-vertical': iconsIcon0MergedSvg + '#align-center-vertical',
|
||||
'align-left': iconsIcon0MergedSvg + '#align-left',
|
||||
'align-right': iconsIcon0MergedSvg + '#align-right',
|
||||
'align-top': iconsIcon0MergedSvg + '#align-top',
|
||||
alt: iconsIcon0MergedSvg + '#alt',
|
||||
'arrow-arc': iconsIcon0MergedSvg + '#arrow-arc',
|
||||
'arrow-cycle': iconsIcon0MergedSvg + '#arrow-cycle',
|
||||
'arrow-elbow': iconsIcon0MergedSvg + '#arrow-elbow',
|
||||
'arrow-left': iconsIcon0MergedSvg + '#arrow-left',
|
||||
'arrowhead-arrow': iconsIcon0MergedSvg + '#arrowhead-arrow',
|
||||
'arrowhead-bar': iconsIcon0MergedSvg + '#arrowhead-bar',
|
||||
'arrowhead-diamond': iconsIcon0MergedSvg + '#arrowhead-diamond',
|
||||
'arrowhead-dot': iconsIcon0MergedSvg + '#arrowhead-dot',
|
||||
'arrowhead-none': iconsIcon0MergedSvg + '#arrowhead-none',
|
||||
'arrowhead-square': iconsIcon0MergedSvg + '#arrowhead-square',
|
||||
'arrowhead-triangle-inverted': iconsIcon0MergedSvg + '#arrowhead-triangle-inverted',
|
||||
'arrowhead-triangle': iconsIcon0MergedSvg + '#arrowhead-triangle',
|
||||
blob: iconsIcon0MergedSvg + '#blob',
|
||||
bold: iconsIcon0MergedSvg + '#bold',
|
||||
bookmark: iconsIcon0MergedSvg + '#bookmark',
|
||||
'bring-forward': iconsIcon0MergedSvg + '#bring-forward',
|
||||
'bring-to-front': iconsIcon0MergedSvg + '#bring-to-front',
|
||||
broken: iconsIcon0MergedSvg + '#broken',
|
||||
bulletList: iconsIcon0MergedSvg + '#bulletList',
|
||||
'check-circle': iconsIcon0MergedSvg + '#check-circle',
|
||||
check: iconsIcon0MergedSvg + '#check',
|
||||
'chevron-down': iconsIcon0MergedSvg + '#chevron-down',
|
||||
'chevron-left': iconsIcon0MergedSvg + '#chevron-left',
|
||||
'chevron-right': iconsIcon0MergedSvg + '#chevron-right',
|
||||
'chevron-up': iconsIcon0MergedSvg + '#chevron-up',
|
||||
'chevrons-ne': iconsIcon0MergedSvg + '#chevrons-ne',
|
||||
'chevrons-sw': iconsIcon0MergedSvg + '#chevrons-sw',
|
||||
'clipboard-copied': iconsIcon0MergedSvg + '#clipboard-copied',
|
||||
'clipboard-copy': iconsIcon0MergedSvg + '#clipboard-copy',
|
||||
closed: iconsIcon0MergedSvg + '#closed',
|
||||
code: iconsIcon0MergedSvg + '#code',
|
||||
color: iconsIcon0MergedSvg + '#color',
|
||||
comment: iconsIcon0MergedSvg + '#comment',
|
||||
corners: iconsIcon0MergedSvg + '#corners',
|
||||
crop: iconsIcon0MergedSvg + '#crop',
|
||||
'cross-2': iconsIcon0MergedSvg + '#cross-2',
|
||||
'cross-circle': iconsIcon0MergedSvg + '#cross-circle',
|
||||
'dash-dashed': iconsIcon0MergedSvg + '#dash-dashed',
|
||||
'dash-dotted': iconsIcon0MergedSvg + '#dash-dotted',
|
||||
'dash-draw': iconsIcon0MergedSvg + '#dash-draw',
|
||||
'dash-solid': iconsIcon0MergedSvg + '#dash-solid',
|
||||
disconnected: iconsIcon0MergedSvg + '#disconnected',
|
||||
discord: iconsIcon0MergedSvg + '#discord',
|
||||
'distribute-horizontal': iconsIcon0MergedSvg + '#distribute-horizontal',
|
||||
'distribute-vertical': iconsIcon0MergedSvg + '#distribute-vertical',
|
||||
dot: iconsIcon0MergedSvg + '#dot',
|
||||
'dots-horizontal': iconsIcon0MergedSvg + '#dots-horizontal',
|
||||
'dots-vertical': iconsIcon0MergedSvg + '#dots-vertical',
|
||||
download: iconsIcon0MergedSvg + '#download',
|
||||
'drag-handle-dots': iconsIcon0MergedSvg + '#drag-handle-dots',
|
||||
duplicate: iconsIcon0MergedSvg + '#duplicate',
|
||||
edit: iconsIcon0MergedSvg + '#edit',
|
||||
'external-link': iconsIcon0MergedSvg + '#external-link',
|
||||
'fill-fill': iconsIcon0MergedSvg + '#fill-fill',
|
||||
'fill-lined-fill': iconsIcon0MergedSvg + '#fill-lined-fill',
|
||||
'fill-none': iconsIcon0MergedSvg + '#fill-none',
|
||||
'fill-pattern': iconsIcon0MergedSvg + '#fill-pattern',
|
||||
'fill-semi': iconsIcon0MergedSvg + '#fill-semi',
|
||||
'fill-solid': iconsIcon0MergedSvg + '#fill-solid',
|
||||
follow: iconsIcon0MergedSvg + '#follow',
|
||||
following: iconsIcon0MergedSvg + '#following',
|
||||
'font-draw': iconsIcon0MergedSvg + '#font-draw',
|
||||
'font-mono': iconsIcon0MergedSvg + '#font-mono',
|
||||
'font-sans': iconsIcon0MergedSvg + '#font-sans',
|
||||
'font-serif': iconsIcon0MergedSvg + '#font-serif',
|
||||
'geo-arrow-down': iconsIcon0MergedSvg + '#geo-arrow-down',
|
||||
'geo-arrow-left': iconsIcon0MergedSvg + '#geo-arrow-left',
|
||||
'geo-arrow-right': iconsIcon0MergedSvg + '#geo-arrow-right',
|
||||
'geo-arrow-up': iconsIcon0MergedSvg + '#geo-arrow-up',
|
||||
'geo-check-box': iconsIcon0MergedSvg + '#geo-check-box',
|
||||
'geo-cloud': iconsIcon0MergedSvg + '#geo-cloud',
|
||||
'geo-diamond': iconsIcon0MergedSvg + '#geo-diamond',
|
||||
'geo-ellipse': iconsIcon0MergedSvg + '#geo-ellipse',
|
||||
'geo-heart': iconsIcon0MergedSvg + '#geo-heart',
|
||||
'geo-hexagon': iconsIcon0MergedSvg + '#geo-hexagon',
|
||||
'geo-octagon': iconsIcon0MergedSvg + '#geo-octagon',
|
||||
'geo-oval': iconsIcon0MergedSvg + '#geo-oval',
|
||||
'geo-pentagon': iconsIcon0MergedSvg + '#geo-pentagon',
|
||||
'geo-rectangle': iconsIcon0MergedSvg + '#geo-rectangle',
|
||||
'geo-rhombus-2': iconsIcon0MergedSvg + '#geo-rhombus-2',
|
||||
'geo-rhombus': iconsIcon0MergedSvg + '#geo-rhombus',
|
||||
'geo-star': iconsIcon0MergedSvg + '#geo-star',
|
||||
'geo-trapezoid': iconsIcon0MergedSvg + '#geo-trapezoid',
|
||||
'geo-triangle': iconsIcon0MergedSvg + '#geo-triangle',
|
||||
'geo-x-box': iconsIcon0MergedSvg + '#geo-x-box',
|
||||
github: iconsIcon0MergedSvg + '#github',
|
||||
group: iconsIcon0MergedSvg + '#group',
|
||||
heading: iconsIcon0MergedSvg + '#heading',
|
||||
'help-circle': iconsIcon0MergedSvg + '#help-circle',
|
||||
highlight: iconsIcon0MergedSvg + '#highlight',
|
||||
'horizontal-align-end': iconsIcon0MergedSvg + '#horizontal-align-end',
|
||||
'horizontal-align-middle': iconsIcon0MergedSvg + '#horizontal-align-middle',
|
||||
'horizontal-align-start': iconsIcon0MergedSvg + '#horizontal-align-start',
|
||||
'info-circle': iconsIcon0MergedSvg + '#info-circle',
|
||||
italic: iconsIcon0MergedSvg + '#italic',
|
||||
leading: iconsIcon0MergedSvg + '#leading',
|
||||
link: iconsIcon0MergedSvg + '#link',
|
||||
list: iconsIcon0MergedSvg + '#list',
|
||||
lock: iconsIcon0MergedSvg + '#lock',
|
||||
manual: iconsIcon0MergedSvg + '#manual',
|
||||
menu: iconsIcon0MergedSvg + '#menu',
|
||||
minus: iconsIcon0MergedSvg + '#minus',
|
||||
mixed: iconsIcon0MergedSvg + '#mixed',
|
||||
pack: iconsIcon0MergedSvg + '#pack',
|
||||
plus: iconsIcon0MergedSvg + '#plus',
|
||||
'question-mark-circle': iconsIcon0MergedSvg + '#question-mark-circle',
|
||||
'question-mark': iconsIcon0MergedSvg + '#question-mark',
|
||||
redo: iconsIcon0MergedSvg + '#redo',
|
||||
'reset-zoom': iconsIcon0MergedSvg + '#reset-zoom',
|
||||
'rotate-ccw': iconsIcon0MergedSvg + '#rotate-ccw',
|
||||
'rotate-cw': iconsIcon0MergedSvg + '#rotate-cw',
|
||||
'send-backward': iconsIcon0MergedSvg + '#send-backward',
|
||||
'send-to-back': iconsIcon0MergedSvg + '#send-to-back',
|
||||
'share-1': iconsIcon0MergedSvg + '#share-1',
|
||||
'size-extra-large': iconsIcon0MergedSvg + '#size-extra-large',
|
||||
'size-large': iconsIcon0MergedSvg + '#size-large',
|
||||
'size-medium': iconsIcon0MergedSvg + '#size-medium',
|
||||
'size-small': iconsIcon0MergedSvg + '#size-small',
|
||||
'spline-cubic': iconsIcon0MergedSvg + '#spline-cubic',
|
||||
'spline-line': iconsIcon0MergedSvg + '#spline-line',
|
||||
'stack-horizontal': iconsIcon0MergedSvg + '#stack-horizontal',
|
||||
'stack-vertical': iconsIcon0MergedSvg + '#stack-vertical',
|
||||
'status-offline': iconsIcon0MergedSvg + '#status-offline',
|
||||
'stretch-horizontal': iconsIcon0MergedSvg + '#stretch-horizontal',
|
||||
'stretch-vertical': iconsIcon0MergedSvg + '#stretch-vertical',
|
||||
strike: iconsIcon0MergedSvg + '#strike',
|
||||
'text-align-center': iconsIcon0MergedSvg + '#text-align-center',
|
||||
'text-align-left': iconsIcon0MergedSvg + '#text-align-left',
|
||||
'text-align-right': iconsIcon0MergedSvg + '#text-align-right',
|
||||
'toggle-off': iconsIcon0MergedSvg + '#toggle-off',
|
||||
'toggle-on': iconsIcon0MergedSvg + '#toggle-on',
|
||||
'tool-arrow': iconsIcon0MergedSvg + '#tool-arrow',
|
||||
'tool-eraser': iconsIcon0MergedSvg + '#tool-eraser',
|
||||
'tool-frame': iconsIcon0MergedSvg + '#tool-frame',
|
||||
'tool-hand': iconsIcon0MergedSvg + '#tool-hand',
|
||||
'tool-highlight': iconsIcon0MergedSvg + '#tool-highlight',
|
||||
'tool-laser': iconsIcon0MergedSvg + '#tool-laser',
|
||||
'tool-line': iconsIcon0MergedSvg + '#tool-line',
|
||||
'tool-media': iconsIcon0MergedSvg + '#tool-media',
|
||||
'tool-note': iconsIcon0MergedSvg + '#tool-note',
|
||||
'tool-pencil': iconsIcon0MergedSvg + '#tool-pencil',
|
||||
'tool-pointer': iconsIcon0MergedSvg + '#tool-pointer',
|
||||
'tool-screenshot': iconsIcon0MergedSvg + '#tool-screenshot',
|
||||
'tool-text': iconsIcon0MergedSvg + '#tool-text',
|
||||
trash: iconsIcon0MergedSvg + '#trash',
|
||||
twitter: iconsIcon0MergedSvg + '#twitter',
|
||||
underline: iconsIcon0MergedSvg + '#underline',
|
||||
undo: iconsIcon0MergedSvg + '#undo',
|
||||
ungroup: iconsIcon0MergedSvg + '#ungroup',
|
||||
unlock: iconsIcon0MergedSvg + '#unlock',
|
||||
'vertical-align-end': iconsIcon0MergedSvg + '#vertical-align-end',
|
||||
'vertical-align-middle': iconsIcon0MergedSvg + '#vertical-align-middle',
|
||||
'vertical-align-start': iconsIcon0MergedSvg + '#vertical-align-start',
|
||||
'warning-triangle': iconsIcon0MergedSvg + '#warning-triangle',
|
||||
'zoom-in': iconsIcon0MergedSvg + '#zoom-in',
|
||||
'zoom-out': iconsIcon0MergedSvg + '#zoom-out',
|
||||
},
|
||||
translations: {
|
||||
ar: formatAssetUrl(new URL('./translations/ar.json', import.meta.url).href, opts),
|
||||
bn: formatAssetUrl(new URL('./translations/bn.json', import.meta.url).href, opts),
|
||||
ca: formatAssetUrl(new URL('./translations/ca.json', import.meta.url).href, opts),
|
||||
cs: formatAssetUrl(new URL('./translations/cs.json', import.meta.url).href, opts),
|
||||
da: formatAssetUrl(new URL('./translations/da.json', import.meta.url).href, opts),
|
||||
de: formatAssetUrl(new URL('./translations/de.json', import.meta.url).href, opts),
|
||||
el: formatAssetUrl(new URL('./translations/el.json', import.meta.url).href, opts),
|
||||
en: formatAssetUrl(new URL('./translations/en.json', import.meta.url).href, opts),
|
||||
es: formatAssetUrl(new URL('./translations/es.json', import.meta.url).href, opts),
|
||||
fa: formatAssetUrl(new URL('./translations/fa.json', import.meta.url).href, opts),
|
||||
fi: formatAssetUrl(new URL('./translations/fi.json', import.meta.url).href, opts),
|
||||
fr: formatAssetUrl(new URL('./translations/fr.json', import.meta.url).href, opts),
|
||||
gl: formatAssetUrl(new URL('./translations/gl.json', import.meta.url).href, opts),
|
||||
'gu-in': formatAssetUrl(new URL('./translations/gu-in.json', import.meta.url).href, opts),
|
||||
he: formatAssetUrl(new URL('./translations/he.json', import.meta.url).href, opts),
|
||||
'hi-in': formatAssetUrl(new URL('./translations/hi-in.json', import.meta.url).href, opts),
|
||||
hr: formatAssetUrl(new URL('./translations/hr.json', import.meta.url).href, opts),
|
||||
hu: formatAssetUrl(new URL('./translations/hu.json', import.meta.url).href, opts),
|
||||
id: formatAssetUrl(new URL('./translations/id.json', import.meta.url).href, opts),
|
||||
it: formatAssetUrl(new URL('./translations/it.json', import.meta.url).href, opts),
|
||||
ja: formatAssetUrl(new URL('./translations/ja.json', import.meta.url).href, opts),
|
||||
'km-kh': formatAssetUrl(new URL('./translations/km-kh.json', import.meta.url).href, opts),
|
||||
kn: formatAssetUrl(new URL('./translations/kn.json', import.meta.url).href, opts),
|
||||
'ko-kr': formatAssetUrl(new URL('./translations/ko-kr.json', import.meta.url).href, opts),
|
||||
ku: formatAssetUrl(new URL('./translations/ku.json', import.meta.url).href, opts),
|
||||
languages: formatAssetUrl(
|
||||
new URL('./translations/languages.json', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
main: formatAssetUrl(new URL('./translations/main.json', import.meta.url).href, opts),
|
||||
ml: formatAssetUrl(new URL('./translations/ml.json', import.meta.url).href, opts),
|
||||
mr: formatAssetUrl(new URL('./translations/mr.json', import.meta.url).href, opts),
|
||||
ms: formatAssetUrl(new URL('./translations/ms.json', import.meta.url).href, opts),
|
||||
my: formatAssetUrl(new URL('./translations/my.json', import.meta.url).href, opts),
|
||||
ne: formatAssetUrl(new URL('./translations/ne.json', import.meta.url).href, opts),
|
||||
nl: formatAssetUrl(new URL('./translations/nl.json', import.meta.url).href, opts),
|
||||
no: formatAssetUrl(new URL('./translations/no.json', import.meta.url).href, opts),
|
||||
pa: formatAssetUrl(new URL('./translations/pa.json', import.meta.url).href, opts),
|
||||
pl: formatAssetUrl(new URL('./translations/pl.json', import.meta.url).href, opts),
|
||||
'pt-br': formatAssetUrl(new URL('./translations/pt-br.json', import.meta.url).href, opts),
|
||||
'pt-pt': formatAssetUrl(new URL('./translations/pt-pt.json', import.meta.url).href, opts),
|
||||
ro: formatAssetUrl(new URL('./translations/ro.json', import.meta.url).href, opts),
|
||||
ru: formatAssetUrl(new URL('./translations/ru.json', import.meta.url).href, opts),
|
||||
sl: formatAssetUrl(new URL('./translations/sl.json', import.meta.url).href, opts),
|
||||
so: formatAssetUrl(new URL('./translations/so.json', import.meta.url).href, opts),
|
||||
sv: formatAssetUrl(new URL('./translations/sv.json', import.meta.url).href, opts),
|
||||
ta: formatAssetUrl(new URL('./translations/ta.json', import.meta.url).href, opts),
|
||||
te: formatAssetUrl(new URL('./translations/te.json', import.meta.url).href, opts),
|
||||
th: formatAssetUrl(new URL('./translations/th.json', import.meta.url).href, opts),
|
||||
tl: formatAssetUrl(new URL('./translations/tl.json', import.meta.url).href, opts),
|
||||
tr: formatAssetUrl(new URL('./translations/tr.json', import.meta.url).href, opts),
|
||||
uk: formatAssetUrl(new URL('./translations/uk.json', import.meta.url).href, opts),
|
||||
ur: formatAssetUrl(new URL('./translations/ur.json', import.meta.url).href, opts),
|
||||
vi: formatAssetUrl(new URL('./translations/vi.json', import.meta.url).href, opts),
|
||||
'zh-cn': formatAssetUrl(new URL('./translations/zh-cn.json', import.meta.url).href, opts),
|
||||
'zh-tw': formatAssetUrl(new URL('./translations/zh-tw.json', import.meta.url).href, opts),
|
||||
},
|
||||
embedIcons: {
|
||||
canva: formatAssetUrl(new URL('./embed-icons/canva.png', import.meta.url).href, opts),
|
||||
codepen: formatAssetUrl(new URL('./embed-icons/codepen.png', import.meta.url).href, opts),
|
||||
codesandbox: formatAssetUrl(
|
||||
new URL('./embed-icons/codesandbox.png', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
desmos: formatAssetUrl(new URL('./embed-icons/desmos.png', import.meta.url).href, opts),
|
||||
excalidraw: formatAssetUrl(
|
||||
new URL('./embed-icons/excalidraw.png', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
felt: formatAssetUrl(new URL('./embed-icons/felt.png', import.meta.url).href, opts),
|
||||
figma: formatAssetUrl(new URL('./embed-icons/figma.png', import.meta.url).href, opts),
|
||||
github_gist: formatAssetUrl(
|
||||
new URL('./embed-icons/github_gist.png', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
google_calendar: formatAssetUrl(
|
||||
new URL('./embed-icons/google_calendar.png', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
google_maps: formatAssetUrl(
|
||||
new URL('./embed-icons/google_maps.png', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
google_slides: formatAssetUrl(
|
||||
new URL('./embed-icons/google_slides.png', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
observable: formatAssetUrl(
|
||||
new URL('./embed-icons/observable.png', import.meta.url).href,
|
||||
opts
|
||||
),
|
||||
replit: formatAssetUrl(new URL('./embed-icons/replit.png', import.meta.url).href, opts),
|
||||
scratch: formatAssetUrl(new URL('./embed-icons/scratch.png', import.meta.url).href, opts),
|
||||
spotify: formatAssetUrl(new URL('./embed-icons/spotify.png', import.meta.url).href, opts),
|
||||
tldraw: formatAssetUrl(new URL('./embed-icons/tldraw.png', import.meta.url).href, opts),
|
||||
val_town: formatAssetUrl(new URL('./embed-icons/val_town.png', import.meta.url).href, opts),
|
||||
vimeo: formatAssetUrl(new URL('./embed-icons/vimeo.png', import.meta.url).href, opts),
|
||||
youtube: formatAssetUrl(new URL('./embed-icons/youtube.png', import.meta.url).href, opts),
|
||||
},
|
||||
}
|
||||
}
|
||||
2
packages/assets/utils.d.ts
vendored
Normal file
2
packages/assets/utils.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { AssetUrl, AssetUrlOptions } from './types'
|
||||
export function formatAssetUrl(assetUrl: AssetUrl, format: AssetUrlOptions): string
|
||||
20
packages/assets/utils.js
Normal file
20
packages/assets/utils.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path="./modules.d.ts" />
|
||||
|
||||
/**
|
||||
* @param {AssetUrl} assetUrl
|
||||
* @param {AssetUrlOptions} [format]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatAssetUrl(assetUrl, format = {}) {
|
||||
const assetUrlString = typeof assetUrl === 'string' ? assetUrl : assetUrl.src
|
||||
|
||||
if (typeof format === 'function') return format(assetUrlString)
|
||||
|
||||
const { baseUrl = '' } = format
|
||||
|
||||
if (assetUrlString.startsWith('data:')) return assetUrlString
|
||||
if (assetUrlString.match(/^https?:\/\//)) return assetUrlString
|
||||
|
||||
return `${baseUrl.replace(/\/$/, '')}/${assetUrlString.replace(/^\.?\//, '')}`
|
||||
}
|
||||
1
packages/collaboration/LICENSE.md
Normal file
1
packages/collaboration/LICENSE.md
Normal file
@@ -0,0 +1 @@
|
||||
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)
|
||||
7
packages/collaboration/README.md
Normal file
7
packages/collaboration/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# @tldraw/collaboration
|
||||
|
||||
tldraw's collaboration umbrella package. It re-exports the individual collaboration
|
||||
packages so consumers can depend on a single entry point.
|
||||
|
||||
For now it re-exports [`@tldraw/commenting`](../commenting). Over time it will grow to
|
||||
re-export the other collaboration packages as well.
|
||||
4
packages/collaboration/api-extractor.json
Normal file
4
packages/collaboration/api-extractor.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
"extends": "../../internal/config/api-extractor.json"
|
||||
}
|
||||
12
packages/collaboration/api-report.api.md
Normal file
12
packages/collaboration/api-report.api.md
Normal file
@@ -0,0 +1,12 @@
|
||||
## API Report File for "@tldraw/collaboration"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
|
||||
|
||||
export * from "@tldraw/commenting";
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
```
|
||||
63
packages/collaboration/package.json
Normal file
63
packages/collaboration/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@tldraw/collaboration",
|
||||
"description": "tldraw collaboration umbrella package.",
|
||||
"version": "5.4.2",
|
||||
"author": {
|
||||
"name": "tldraw Inc.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"tldraw_product": {
|
||||
"stableId": "tldraw:collaboration",
|
||||
"name": "Collaboration module",
|
||||
"type": "product",
|
||||
"premium": true,
|
||||
"licenseFlag": "FEAT_COLLABORATION"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tldraw/tldraw"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tldraw/tldraw/issues"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./.tsbuild/index.d.ts",
|
||||
"style": "./collaboration.css",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"collaboration.css"
|
||||
],
|
||||
"scripts": {
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"predev": "node ./scripts/copy-css-files.mjs",
|
||||
"dev": "chokidar '../commenting/src/**/*.css' -c 'node ./scripts/copy-css-files.mjs'",
|
||||
"prebuild": "node ./scripts/copy-css-files.mjs",
|
||||
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
|
||||
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
|
||||
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
|
||||
"postpack": "../../internal/scripts/postpack.sh",
|
||||
"pack-tarball": "yarn pack",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0 || ^19.2.1",
|
||||
"react-dom": "^18.2.0 || ^19.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tldraw/commenting": "workspace:*",
|
||||
"@tldraw/utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
25
packages/collaboration/scripts/copy-css-files.mjs
Normal file
25
packages/collaboration/scripts/copy-css-files.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const packageDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const commentingDir = join(packageDir, '..', 'commenting')
|
||||
|
||||
// Generate commenting.css first, so the list of source stylesheets lives in one place (the
|
||||
// commenting package's own script) and this one can't silently drift out of sync with it.
|
||||
await import('../../commenting/scripts/copy-css-files.mjs')
|
||||
|
||||
// Republish it under the umbrella's name, swapping the provenance header for our own.
|
||||
const commentingCss = readFileSync(join(commentingDir, 'commenting.css'), 'utf8').replace(
|
||||
/^(\/\*[^\n]*\*\/\n)+\n?/,
|
||||
''
|
||||
)
|
||||
|
||||
const combinedContent = `/* THIS CSS FILE IS GENERATED! DO NOT EDIT. OR EDIT. I'M A COMMENT NOT A COP */
|
||||
/* This file is created by the copy-css-files.mjs script in packages/collaboration. */
|
||||
/* It combines the stylesheets of the collaboration umbrella's sub-packages. */
|
||||
|
||||
${commentingCss}`
|
||||
|
||||
const destination = join(packageDir, 'collaboration.css')
|
||||
writeFileSync(destination, combinedContent)
|
||||
13
packages/collaboration/src/index.ts
Normal file
13
packages/collaboration/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { registerTldrawLibraryVersion } from '@tldraw/utils'
|
||||
|
||||
// The collaboration umbrella. For now it re-exports the commenting package; over time it
|
||||
// will re-export the other collaboration packages as well.
|
||||
|
||||
// eslint-disable-next-line tldraw/no-export-star
|
||||
export * from '@tldraw/commenting'
|
||||
|
||||
registerTldrawLibraryVersion(
|
||||
(globalThis as any).TLDRAW_LIBRARY_NAME,
|
||||
(globalThis as any).TLDRAW_LIBRARY_VERSION,
|
||||
(globalThis as any).TLDRAW_LIBRARY_MODULES
|
||||
)
|
||||
24
packages/collaboration/tsconfig.json
Normal file
24
packages/collaboration/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../internal/config/tsconfig.base.json",
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.css",
|
||||
".tsbuild*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"outDir": "./.tsbuild",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../commenting"
|
||||
},
|
||||
{
|
||||
"path": "../utils"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/collaboration/vitest.config.ts
Normal file
6
packages/collaboration/vitest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../internal/config/vitest/node-preset'
|
||||
|
||||
// Collaboration package uses the standard node preset with no overrides
|
||||
export default mergeConfig(baseConfig, {})
|
||||
1
packages/commenting/LICENSE.md
Normal file
1
packages/commenting/LICENSE.md
Normal file
@@ -0,0 +1 @@
|
||||
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)
|
||||
219
packages/commenting/REACTIONS.md
Normal file
219
packages/commenting/REACTIONS.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Reaction palettes
|
||||
|
||||
Comment reactions store a **token** — an opaque string the commenting layer only ever stores,
|
||||
syncs, and hands back to a renderer. It never assumes the token is an emoji glyph. Out of the box
|
||||
the token _is_ the glyph (drawn by the OS emoji font), but you can swap the palette to make a
|
||||
reaction be anything: custom emoji images, SVGs, drawings, a fixed brand set, etc.
|
||||
|
||||
## Overrides are opt-in
|
||||
|
||||
Every extension point below — the palette, the renderer, the validator, and the reactor tooltip —
|
||||
is **opt-in**. Each is a slot on the comment tool that defaults to a built-in when you leave it
|
||||
unset. So an unconfigured tool gives you the full standard experience: OS-font emoji reactions, the
|
||||
default emoji picker, and the default hover tooltip.
|
||||
|
||||
```tsx
|
||||
// All of these are equivalent — the built-in emoji reactions + default tooltip:
|
||||
const commentTools = [CommentTool]
|
||||
const commentTools = [CommentTool.configure({})]
|
||||
const commentTools = [CommentTool.configure({ components: {} })]
|
||||
```
|
||||
|
||||
You only pass an override to change that specific slot; everything you don't pass stays the built-in.
|
||||
`configure({})` is not "turn reactions off" — it's "use every default." Reverting any customisation
|
||||
is just deleting the override, never adding config back.
|
||||
|
||||
## The model
|
||||
|
||||
A reaction is one record per `(comment, user, token)` (see `TLCommentReaction` in `@tldraw/tlschema`).
|
||||
The `token` is stored verbatim. Three things decide how a palette behaves, and **they must agree** —
|
||||
they're facets of one palette:
|
||||
|
||||
| Concern | Extension point | Default |
|
||||
| ------------------------------------------ | ---------------------------- | ----------------------------------------------------------------- |
|
||||
| **What tokens exist / how they're picked** | `components.ReactionPalette` | `EmojiPicker` (a grid of `DEFAULT_REACTION_EMOJI`) |
|
||||
| **How a token is drawn** | `components.ReactionContent` | the token string (OS emoji font) |
|
||||
| **Which tokens may be written** | `isAllowedReaction(token)` | `isAllowedReactionEmoji` (membership in `DEFAULT_REACTION_EMOJI`) |
|
||||
|
||||
All three are set on the comment tool:
|
||||
|
||||
```tsx
|
||||
CommentTool.configure({
|
||||
components: {
|
||||
ReactionPalette: MyPalette,
|
||||
ReactionContent: MyReactionContent,
|
||||
},
|
||||
isAllowedReaction: (token) => /* is this one of mine? */,
|
||||
})
|
||||
```
|
||||
|
||||
If you set none of them, you get the default emoji palette. **Reverting a custom palette is just
|
||||
deleting these three overrides** — the defaults are the emoji system.
|
||||
|
||||
> **Why three, and why they must agree:** the picker emits tokens, the renderer draws them, and the
|
||||
> validator gates writes. If the picker emits a token the renderer doesn't understand, the pill shows
|
||||
> the raw token string; if the validator rejects it, the write is silently dropped. Keep the three in
|
||||
> lockstep. (This is a known rough edge — the intent is to collapse them into a single
|
||||
> `reactionPalette` object; until then, set all three together.)
|
||||
|
||||
## The three pieces in detail
|
||||
|
||||
### 1. The palette — `components.ReactionPalette`
|
||||
|
||||
A component with the same props as the built-in `EmojiPicker` (`EmojiPickerProps`):
|
||||
|
||||
```tsx
|
||||
interface EmojiPickerProps {
|
||||
emoji?: string[] // tokens to offer (your palette)
|
||||
selected?: string[] // tokens the user has already reacted with (show as pressed)
|
||||
onSelect?(token: string): void // call this with the chosen token
|
||||
renderReaction?: RenderReaction // draw a token (pass through to your renderer)
|
||||
}
|
||||
```
|
||||
|
||||
It renders inside the add-reaction dropdown. When the user picks something, call
|
||||
`onSelect(token)` — that token is what gets stored. It can be anything: an emoji glyph, a
|
||||
shortcode, a `data:` URL, an id into your own store.
|
||||
|
||||
### 2. The renderer — `components.ReactionContent`
|
||||
|
||||
A component `({ token }) => ReactNode` used to draw a token wherever a reaction appears (the count
|
||||
pills, and — via `renderReaction` — inside the palette). Return an `<img>`, an SVG, or plain text:
|
||||
|
||||
```tsx
|
||||
function MyReactionContent({ token }: { token: string }) {
|
||||
return isMyToken(token) ? <img src={resolveImage(token)} alt="" /> : <>{token}</>
|
||||
}
|
||||
```
|
||||
|
||||
Fall through to `<>{token}</>` for tokens you don't own, so a custom palette and the stock emoji
|
||||
palette can coexist on the same comment.
|
||||
|
||||
The presentational components (`Reaction`, `Reactions`, `EmojiPicker`, `ReactionPicker`) also take a
|
||||
`renderReaction(token): ReactNode` **function** prop directly, if you're composing them yourself
|
||||
rather than through `CanvasComments`. `defaultRenderReaction` is the token-string default.
|
||||
|
||||
### 3. Validation — `isAllowedReaction(token)`
|
||||
|
||||
Gates which tokens may be **added** (removals always go through, so a reaction carrying an
|
||||
off-palette token can still be cleared). This is the client-side guard that stops a token the picker
|
||||
would never offer. It should accept exactly the tokens your palette emits:
|
||||
|
||||
```ts
|
||||
isAllowedReaction: (token) => isMyToken(token) || isAllowedReactionEmoji(token)
|
||||
```
|
||||
|
||||
> Server-side, a reaction is still verified for identity/ownership by the sync authorizer, but the
|
||||
> _palette membership_ check is client-side. If you need a hard server bound, enforce your token set
|
||||
> in the sync server too.
|
||||
|
||||
## Worked example: draw-your-own reactions
|
||||
|
||||
The [drawn reactions example][drawn] implements a complete custom palette — a small tldraw canvas
|
||||
you draw in, stored as a `data:` image token. It lives in the examples app rather than in this
|
||||
package, since it's one shape a custom palette can take rather than something the SDK has an
|
||||
opinion about. Wiring it is the three overrides:
|
||||
|
||||
[drawn]: ../../apps/examples/src/examples/collaboration/comment-drawing-reactions
|
||||
|
||||
```tsx
|
||||
import { CommentTool, isAllowedReactionEmoji, type EmojiPickerProps } from '@tldraw/commenting'
|
||||
import {
|
||||
DrawingReactionContent,
|
||||
DrawingReactionPalette,
|
||||
isDrawingReactionToken,
|
||||
} from './drawing-reactions'
|
||||
|
||||
// DrawingReactionPalette is a nested tldraw editor, so bind its license key here.
|
||||
function ReactionPalette(props: EmojiPickerProps) {
|
||||
return <DrawingReactionPalette {...props} licenseKey={getLicenseKey()} />
|
||||
}
|
||||
|
||||
const commentTools = [
|
||||
CommentTool.configure({
|
||||
components: {
|
||||
ReactionPalette,
|
||||
ReactionContent: DrawingReactionContent, // draws data: tokens as <img>, emoji fall through
|
||||
},
|
||||
// drawings OR emoji are valid, so old emoji reactions still work after the switch
|
||||
isAllowedReaction: (token) => isDrawingReactionToken(token) || isAllowedReactionEmoji(token),
|
||||
}),
|
||||
]
|
||||
```
|
||||
|
||||
To go back to plain emoji, delete those overrides:
|
||||
|
||||
```tsx
|
||||
const commentTools = [CommentTool.configure({})]
|
||||
```
|
||||
|
||||
That's the entire revert — no package changes, because the defaults _are_ the emoji palette.
|
||||
|
||||
## The reactor tooltip
|
||||
|
||||
Separate from the palette (which is about the tokens themselves), the **hover tooltip that names who
|
||||
reacted** is its own override. By default, hovering a reaction pill shows an inline sentence —
|
||||
`You reacted`, `You and Bo reacted`, `You, Bo and Ada reacted`, then `You, Bo, Ada and N others
|
||||
reacted` past three names.
|
||||
|
||||
There are two levels of customisation, smallest first:
|
||||
|
||||
1. **Reword it — translation, no code.** The sentence is built from the `comments.reacted-1`,
|
||||
`comments.reacted-2`, `comments.reacted-3`, `comments.reacted-more`, and `comments.reacted-more-one`
|
||||
strings (plus `comments.mention-you` for "You"). Override them through the standard tldraw
|
||||
translation `overrides` to change the words or reorder the `{a}` / `{b}` / `{c}` / `{count}`
|
||||
placeholders per locale. This is also how the grammar gets localised.
|
||||
|
||||
2. **Replace it — `components.ReactionTooltip`.** To change more than the wording, replace the whole
|
||||
affordance. The component receives the reactors **and the pill itself** (as `children`) and
|
||||
returns the entire thing — so it owns the tooltip, its box, size, shape, and position, not just
|
||||
the text inside. That's what lets it be anything: a different box, avatars, or a banner painted
|
||||
anywhere on screen.
|
||||
|
||||
The default just hangs the built-in sentence in a standard tooltip. To keep that box but change
|
||||
the contents, wrap `children` in `TldrawUiTooltip` yourself:
|
||||
|
||||
```tsx
|
||||
import { CommentTool, type ReactionTooltipProps } from '@tldraw/commenting'
|
||||
import { TldrawUiTooltip } from 'tldraw'
|
||||
|
||||
function AvatarReactionTooltip({ reactors, children }: ReactionTooltipProps) {
|
||||
return (
|
||||
<TldrawUiTooltip
|
||||
side="bottom"
|
||||
content={reactors.map((r, i) => (
|
||||
<Avatar key={i} name={r.you ? 'You' : r.name} />
|
||||
))}
|
||||
>
|
||||
{children}
|
||||
</TldrawUiTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const commentTools = [
|
||||
CommentTool.configure({ components: { ReactionTooltip: AvatarReactionTooltip } }),
|
||||
]
|
||||
```
|
||||
|
||||
To break out of the tooltip entirely — your own popover, a fixed banner, no box at all — don't
|
||||
render `TldrawUiTooltip`; wrap `children` however you like and own the hover state yourself.
|
||||
|
||||
`ReactionTooltipProps` gives you `reactors: { name: string; you: boolean }[]` in reaction order,
|
||||
plus `children` (the pill). Unset, it falls back to `DefaultReactionTooltip` (the default wrapper).
|
||||
Two exports help you compose: `DefaultReactionTooltip` (the wrapper) and
|
||||
`DefaultReactionTooltipContent` (just the inline sentence, to drop inside your own box). This is
|
||||
the same override mechanism as `ReactionContent` / `ReactionPalette`, just for the reactor list.
|
||||
|
||||
> `enableHoverList` still applies upstream: when another menu on the comment is open the pill
|
||||
> renders bare and your `ReactionTooltip` isn't mounted, so you never have to handle that case.
|
||||
|
||||
## Token gotchas
|
||||
|
||||
- **The token goes into the record id** (`id = f(comment, user, token)`), so keep tokens small.
|
||||
Large tokens (e.g. an inlined drawing) bloat the id and the Postgres primary key. The drawn
|
||||
reactions example caps token length for this reason.
|
||||
- **Tokens arrive from other users over sync.** If a token renders as HTML (an SVG, an image),
|
||||
render it in a way that can't execute script or reach the network — the drawing renderer uses
|
||||
`<img src>` (never inlined SVG) and gates on an explicit `data:` prefix allowlist.
|
||||
- **Tokens are effectively immutable.** A stored reaction keeps its token forever; don't repurpose a
|
||||
token to mean something else, or old reactions change meaning.
|
||||
9
packages/commenting/README.md
Normal file
9
packages/commenting/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# @tldraw/commenting
|
||||
|
||||
tldraw's commenting UI components — the presentational pieces of the commenting feature
|
||||
(avatars, comment cards, composer, threads, sidebar, pins, and so on).
|
||||
|
||||
The components are pure React and take presentational props; a consumer maps its data
|
||||
model (the `TLComment` / `TLCommentThread` records from `@tldraw/tlschema`) onto them
|
||||
through its own adapter. Their CSS uses tldraw's `--tl-color-*` theme tokens, so render
|
||||
them inside a tldraw theme.
|
||||
4
packages/commenting/api-extractor.json
Normal file
4
packages/commenting/api-extractor.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
"extends": "../../internal/config/api-extractor.json"
|
||||
}
|
||||
693
packages/commenting/api-report.api.md
Normal file
693
packages/commenting/api-report.api.md
Normal file
@@ -0,0 +1,693 @@
|
||||
## API Report File for "@tldraw/commenting"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
|
||||
import { Avatar } from '@tldraw/mentions';
|
||||
import { AvatarProps } from '@tldraw/mentions';
|
||||
import { CommentAuthor } from '@tldraw/mentions';
|
||||
import { ComponentType } from 'react';
|
||||
import { createMentionSuggestion } from '@tldraw/mentions';
|
||||
import { Editor } from 'tldraw';
|
||||
import { EditorAtom } from 'tldraw';
|
||||
import { filterMentionMembers } from '@tldraw/mentions';
|
||||
import { JSX } from 'react/jsx-runtime';
|
||||
import { KeyboardEvent as KeyboardEvent_2 } from 'react';
|
||||
import { Mention } from '@tldraw/mentions';
|
||||
import { MentionList } from '@tldraw/mentions';
|
||||
import { MentionListProps } from '@tldraw/mentions';
|
||||
import { MentionMember } from '@tldraw/mentions';
|
||||
import { MentionProps } from '@tldraw/mentions';
|
||||
import { MentionSuggestionOptions } from '@tldraw/mentions';
|
||||
import { MouseEvent as MouseEvent_2 } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { StateNode } from 'tldraw';
|
||||
import { TLComment } from 'tldraw';
|
||||
import { TLCommentAnchor } from 'tldraw';
|
||||
import { TLCommentId } from 'tldraw';
|
||||
import { TLCommentReaction } from 'tldraw';
|
||||
import { TLCommentReactionId } from 'tldraw';
|
||||
import { TLCommentThread } from 'tldraw';
|
||||
import { TLCommentThreadId } from 'tldraw';
|
||||
import { TLHistoryBatchOptions } from 'tldraw';
|
||||
import { TLRichText } from 'tldraw';
|
||||
import { TLShapeId } from 'tldraw';
|
||||
import { TLStateNodeConstructor } from 'tldraw';
|
||||
import { TLUiOverrides } from 'tldraw';
|
||||
import { VecLike } from 'tldraw';
|
||||
|
||||
// @public
|
||||
export function anchorPagePoint(editor: Editor, anchor: TLCommentAnchor): {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
export { Avatar }
|
||||
|
||||
export { AvatarProps }
|
||||
|
||||
// @public
|
||||
export function Byline({ author, date, edited }: BylineProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface BylineProps {
|
||||
// (undocumented)
|
||||
author: CommentAuthor;
|
||||
date: string;
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function CanvasComments(props: CanvasCommentsProps): JSX.Element | null;
|
||||
|
||||
// @public
|
||||
export type CanvasCommentsProps = CommentingContext;
|
||||
|
||||
// @public
|
||||
export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps): JSX.Element | null;
|
||||
|
||||
// @public
|
||||
export interface CanvasCommentsSidebarProps extends Pick<CommentingContext, 'currentUserId' | 'getThreadHref' | 'isCommentUnread' | 'resolveAuthor'> {
|
||||
empty?: ReactNode;
|
||||
header?: ReactNode;
|
||||
}
|
||||
|
||||
export { CommentAuthor }
|
||||
|
||||
// @public
|
||||
export function CommentBody({ richText, resolveName }: CommentBodyProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CommentBodyProps {
|
||||
resolveName?(id: string): string | undefined;
|
||||
// (undocumented)
|
||||
richText: TLRichText;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function CommentCard({ author, body, date, you, edited, actions, footer, }: CommentCardProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CommentCardProps {
|
||||
actions?: ReactNode;
|
||||
// (undocumented)
|
||||
author: CommentAuthor;
|
||||
body: ReactNode;
|
||||
date: string;
|
||||
edited?: boolean;
|
||||
footer?: ReactNode;
|
||||
// (undocumented)
|
||||
you: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function CommentComposer({ author, placeholder, value, onChange, onSubmit, sendLabel, onArrowUpWhenEmpty, disabled, autoFocus, leading, getMentionSuggestions, renderMentionSuggestion, }: CommentComposerProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CommentComposerProps {
|
||||
// (undocumented)
|
||||
author: CommentAuthor;
|
||||
// (undocumented)
|
||||
autoFocus?: boolean;
|
||||
// (undocumented)
|
||||
disabled?: boolean;
|
||||
getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>;
|
||||
leading?: ReactNode;
|
||||
onArrowUpWhenEmpty?(): void;
|
||||
// (undocumented)
|
||||
onChange?(value: TLRichText): void;
|
||||
onSubmit?(): void;
|
||||
// (undocumented)
|
||||
placeholder: string;
|
||||
renderMentionSuggestion?(member: MentionMember): ReactNode;
|
||||
// (undocumented)
|
||||
sendLabel?: string;
|
||||
value?: TLRichText;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface CommentingComponents {
|
||||
CommentBody?: ComponentType<{
|
||||
comment: TLComment;
|
||||
}>;
|
||||
ComposerFallback?: ComponentType<{
|
||||
context: 'pending' | 'thread';
|
||||
}>;
|
||||
PinContent?: ComponentType<{
|
||||
comments: TLComment[];
|
||||
thread: TLCommentThread;
|
||||
}>;
|
||||
ReactionContent?: ComponentType<{
|
||||
token: string;
|
||||
}>;
|
||||
ReactionPalette?: ComponentType<EmojiPickerProps>;
|
||||
ReactionTooltip?: ComponentType<ReactionTooltipProps>;
|
||||
ThreadActions?: ComponentType<{
|
||||
comments: TLComment[];
|
||||
thread: TLCommentThread;
|
||||
}>;
|
||||
ThreadPreview?: ComponentType<{
|
||||
comment: TLComment;
|
||||
}>;
|
||||
ThreadRow?: ComponentType<CommentListItemRenderProps & {
|
||||
thread: TLCommentThread;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface CommentingContext {
|
||||
currentUserId: null | string;
|
||||
getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>;
|
||||
getThreadHref?(threadId: TLCommentThreadId): string | undefined;
|
||||
isCommentUnread?(commentId: TLCommentId): boolean;
|
||||
onCommentsRead?(commentIds: TLCommentId[]): void;
|
||||
onPostComment?(comment: TLComment): void;
|
||||
renderMentionSuggestion?(member: MentionMember): ReactNode;
|
||||
resolveAuthor(id: string): CommentAuthor | undefined;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface CommentingOptions {
|
||||
readonly allowMultipleReactions: boolean;
|
||||
readonly canComment: ((ctx: {
|
||||
currentUserId: null | string;
|
||||
editor: Editor;
|
||||
}) => boolean) | undefined;
|
||||
readonly canModifyComment: ((ctx: CommentModificationContext) => boolean) | undefined;
|
||||
readonly components: CommentingComponents;
|
||||
readonly dragHistory: TLHistoryBatchOptions['history'] | undefined;
|
||||
readonly enableClustering: boolean;
|
||||
readonly enableRegions: boolean;
|
||||
readonly history: TLHistoryBatchOptions['history'];
|
||||
readonly impreciseShapeAnchor: {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
};
|
||||
isAllowedReaction(token: string): boolean;
|
||||
shouldBePrecise(editor: Editor, context: ShapeCommentPrecisionContext): boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function CommentListItem({ id, author, preview, date, resolved, page, count, selected, reactions, href, resolvedLabel, onSelect, }: CommentListItemRenderProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CommentListItemProps {
|
||||
// (undocumented)
|
||||
author: CommentAuthor;
|
||||
count?: number;
|
||||
date: string;
|
||||
href?: string;
|
||||
// (undocumented)
|
||||
id: string;
|
||||
page?: string;
|
||||
preview: ReactNode;
|
||||
reactions?: ReactionSummary[];
|
||||
// (undocumented)
|
||||
resolved?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface CommentListItemRenderProps extends CommentListItemProps {
|
||||
onSelect?(id: string): void;
|
||||
resolvedLabel?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type CommentModification = {
|
||||
readonly action: 'delete-comment';
|
||||
readonly comment: TLComment;
|
||||
} | {
|
||||
readonly action: 'delete-thread';
|
||||
readonly thread: TLCommentThread;
|
||||
} | {
|
||||
readonly action: 'edit-comment';
|
||||
readonly comment: TLComment;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CommentModificationContext = {
|
||||
readonly currentUserId: null | string;
|
||||
readonly editor: Editor;
|
||||
} & CommentModification;
|
||||
|
||||
// @public
|
||||
export function CommentPin({ children, resolved, open }: CommentPinProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CommentPinProps {
|
||||
children?: ReactNode;
|
||||
open?: boolean;
|
||||
// (undocumented)
|
||||
resolved?: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function CommentReactionPicker({ comment, currentUserId, emoji, }: CommentReactionPickerProps): JSX.Element | null;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CommentReactionPickerProps {
|
||||
// (undocumented)
|
||||
comment: TLComment;
|
||||
currentUserId?: null | string;
|
||||
emoji?: string[];
|
||||
}
|
||||
|
||||
// @public
|
||||
export function CommentReactions({ comment, currentUserId, resolveName }: CommentReactionsProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CommentReactionsProps {
|
||||
// (undocumented)
|
||||
comment: TLComment;
|
||||
currentUserId?: null | string;
|
||||
resolveName?(userId: string): string | undefined;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function CommentsFilterMenu({ canFilterByAuthor, canFilterByUnread, }: CommentsFilterMenuProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CommentsFilterMenuProps {
|
||||
canFilterByAuthor?: boolean;
|
||||
canFilterByUnread?: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const commentsHidden: EditorAtom<boolean>;
|
||||
|
||||
// @public
|
||||
export function CommentsList({ items, onSelect, header, headerAction, empty, resolvedLabel, renderItem, }: CommentsListProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CommentsListProps {
|
||||
empty?: ReactNode;
|
||||
header?: ReactNode;
|
||||
headerAction?: ReactNode;
|
||||
// (undocumented)
|
||||
items: CommentListItemProps[];
|
||||
onSelect?(id: string): void;
|
||||
renderItem?(props: CommentListItemRenderProps): ReactNode;
|
||||
resolvedLabel?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function CommentsMenuItem(): JSX.Element;
|
||||
|
||||
// @public
|
||||
export function CommentsOverflowMenu(): JSX.Element;
|
||||
|
||||
// @public
|
||||
export const commentsSidebarOpen: EditorAtom<boolean>;
|
||||
|
||||
// @public
|
||||
export function CommentsVisibilityToggle(): JSX.Element;
|
||||
|
||||
// @public
|
||||
export function CommentThread({ comments, header, headerActions, resolvedBanner, composer, footer, renderComment, }: CommentThreadProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CommentThreadProps {
|
||||
comments: CommentCardProps[];
|
||||
composer?: CommentComposerProps;
|
||||
footer?: ReactNode;
|
||||
header?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
renderComment?(comment: CommentCardProps, index: number): ReactNode;
|
||||
resolvedBanner?: ReactNode;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class CommentTool extends StateNode {
|
||||
// (undocumented)
|
||||
static children(): TLStateNodeConstructor[];
|
||||
static configure<T extends TLStateNodeConstructor>(this: T, options: T extends new (...args: any[]) => {
|
||||
options: infer Options;
|
||||
} ? Partial<Options> : never): T;
|
||||
// (undocumented)
|
||||
static id: string;
|
||||
// (undocumented)
|
||||
static initial: string;
|
||||
// (undocumented)
|
||||
onCancel(): void;
|
||||
// (undocumented)
|
||||
onEnter(): void;
|
||||
// (undocumented)
|
||||
onExit(): void;
|
||||
options: CommentingOptions;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const commentToolOverrides: TLUiOverrides;
|
||||
|
||||
// @public (undocumented)
|
||||
export const commentTools: (typeof CommentTool)[];
|
||||
|
||||
// @public (undocumented)
|
||||
export function CountBadge({ count, open }: CountBadgeProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface CountBadgeProps {
|
||||
// (undocumented)
|
||||
count: number;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export { createMentionSuggestion }
|
||||
|
||||
// @public
|
||||
export const DEFAULT_REACTION_EMOJI: string[];
|
||||
|
||||
// @public
|
||||
export const DEFAULT_SIDEBAR_FILTERS: SidebarFilters;
|
||||
|
||||
// @public
|
||||
export function defaultCanModifyComment(ctx: CommentModificationContext): boolean;
|
||||
|
||||
// @public
|
||||
export const defaultCommentingOptions: {
|
||||
readonly allowMultipleReactions: true;
|
||||
readonly canComment: undefined;
|
||||
readonly canModifyComment: undefined;
|
||||
readonly components: {};
|
||||
readonly dragHistory: undefined;
|
||||
readonly enableClustering: true;
|
||||
readonly enableRegions: false;
|
||||
readonly history: 'ignore';
|
||||
readonly impreciseShapeAnchor: {
|
||||
readonly x: 1;
|
||||
readonly y: 0;
|
||||
};
|
||||
readonly isAllowedReaction: typeof isAllowedReactionEmoji;
|
||||
readonly shouldBePrecise: () => true;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function DefaultReactionTooltip({ reactors, children }: ReactionTooltipProps): JSX.Element;
|
||||
|
||||
// @public
|
||||
export function DefaultReactionTooltipContent({ reactors }: {
|
||||
reactors: ReactionReactor[];
|
||||
}): JSX.Element | null;
|
||||
|
||||
// @public
|
||||
export function defaultRenderReaction(token: string): ReactNode;
|
||||
|
||||
// @public
|
||||
export function deleteComment(editor: Editor, comment: TLComment): void;
|
||||
|
||||
// @public
|
||||
export function deleteThread(editor: Editor, thread: TLCommentThread): void;
|
||||
|
||||
// @public
|
||||
export function editComment(editor: Editor, comment: TLComment, body: TLRichText): void;
|
||||
|
||||
// @public
|
||||
export function EmojiPicker({ emoji, selected, onSelect, renderReaction, }: EmojiPickerProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface EmojiPickerProps {
|
||||
emoji?: string[];
|
||||
onSelect?(emoji: string, event?: KeyboardEvent_2 | MouseEvent_2): void;
|
||||
renderReaction?: RenderReaction;
|
||||
selected?: string[];
|
||||
}
|
||||
|
||||
// @public
|
||||
export function EmptyState({ message }: EmptyStateProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface EmptyStateProps {
|
||||
// (undocumented)
|
||||
message: string;
|
||||
}
|
||||
|
||||
export { filterMentionMembers }
|
||||
|
||||
// @public
|
||||
export function focusThread(editor: Editor, thread: TLCommentThread): void;
|
||||
|
||||
// @public
|
||||
export function formatFullDateTime(iso: string, locale?: string): string;
|
||||
|
||||
// @public
|
||||
export function formatRelativeTime(iso: string, locale?: string): string;
|
||||
|
||||
// @public
|
||||
export function getCanComment(editor: Editor, currentUserId: null | string | undefined): boolean;
|
||||
|
||||
// @public
|
||||
export function getCanModifyComment(editor: Editor, currentUserId: null | string | undefined, modification: CommentModification): boolean;
|
||||
|
||||
// @public
|
||||
export function getCommentingOptions(editor: Editor): CommentingOptions;
|
||||
|
||||
// @public
|
||||
export function getCommentReactions(editor: Editor): TLCommentReaction[];
|
||||
|
||||
// @public
|
||||
export function getCommentRecord(editor: Editor, id: string): TLCommentRecord | undefined;
|
||||
|
||||
// @public
|
||||
export function getComments(editor: Editor): TLComment[];
|
||||
|
||||
// @public
|
||||
export function getCommentThreads(editor: Editor): TLCommentThread[];
|
||||
|
||||
// @public
|
||||
export function getLiveComments(editor: Editor): TLComment[];
|
||||
|
||||
// @public
|
||||
export function getLiveCommentThreads(editor: Editor): TLCommentThread[];
|
||||
|
||||
// @public
|
||||
export function getRevealThreadPending(editor: Editor): null | string;
|
||||
|
||||
// @public
|
||||
export function isAllowedReactionEmoji(emoji: string, palette?: readonly string[]): boolean;
|
||||
|
||||
// @public
|
||||
export function isOpenInNewTabClick(e: MouseEvent_2): boolean;
|
||||
|
||||
export { Mention }
|
||||
|
||||
export { MentionList }
|
||||
|
||||
export { MentionListProps }
|
||||
|
||||
export { MentionMember }
|
||||
|
||||
export { MentionProps }
|
||||
|
||||
export { MentionSuggestionOptions }
|
||||
|
||||
// @public
|
||||
export const openThreadId: EditorAtom<null | string>;
|
||||
|
||||
// @public
|
||||
export function putCommentRecords(editor: Editor, records: TLCommentRecord[]): void;
|
||||
|
||||
// @public
|
||||
export function Reaction({ emoji, count, active, reactors, enableHoverList, renderReaction, ReactionTooltip, onClick, }: ReactionProps): JSX.Element;
|
||||
|
||||
// @public
|
||||
export function ReactionPicker({ emoji, selected, onSelect, renderReaction, palette: Palette, menuId, className, }: ReactionPickerProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ReactionPickerProps {
|
||||
className?: string;
|
||||
emoji?: string[];
|
||||
menuId?: string;
|
||||
onSelect?(emoji: string): void;
|
||||
palette?: ComponentType<EmojiPickerProps>;
|
||||
renderReaction?: RenderReaction;
|
||||
selected?: string[];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ReactionProps {
|
||||
// (undocumented)
|
||||
active: boolean;
|
||||
// (undocumented)
|
||||
count: number;
|
||||
// (undocumented)
|
||||
emoji: string;
|
||||
enableHoverList?: boolean;
|
||||
onClick?(): void;
|
||||
ReactionTooltip?: ComponentType<ReactionTooltipProps>;
|
||||
reactors: ReactionReactor[];
|
||||
renderReaction?: RenderReaction;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ReactionReactor {
|
||||
name: string;
|
||||
you: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function Reactions({ reactions, onToggle, canReact, enableHoverList, renderReaction, ReactionTooltip, }: ReactionsProps): JSX.Element | null;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ReactionsProps {
|
||||
canReact?: boolean;
|
||||
enableHoverList?: boolean;
|
||||
onToggle?(emoji: string): void;
|
||||
reactions: ReactionSummary[];
|
||||
ReactionTooltip?: ComponentType<ReactionTooltipProps>;
|
||||
renderReaction?: RenderReaction;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ReactionSummary {
|
||||
active: boolean;
|
||||
count: number;
|
||||
// (undocumented)
|
||||
emoji: string;
|
||||
reactors: ReactionReactor[];
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ReactionSummaryInput {
|
||||
// (undocumented)
|
||||
createdAt: number;
|
||||
// (undocumented)
|
||||
emoji: string;
|
||||
// (undocumented)
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ReactionTooltipProps {
|
||||
children: ReactNode;
|
||||
reactors: ReactionReactor[];
|
||||
}
|
||||
|
||||
// @public
|
||||
export function registerCommentAnchorLifecycle(editor: Editor): () => void;
|
||||
|
||||
// @public
|
||||
export function removeCommentRecords(editor: Editor, ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]): void;
|
||||
|
||||
// @public
|
||||
export type RenderReaction = (token: string) => ReactNode;
|
||||
|
||||
// @public
|
||||
export function reopenThread(editor: Editor, thread: TLCommentThread): void;
|
||||
|
||||
// @public
|
||||
export function resolveThread(editor: Editor, thread: TLCommentThread, userId: string): void;
|
||||
|
||||
// @public
|
||||
export function revealThread(editor: Editor, threadOrCommentId: string): void;
|
||||
|
||||
// @public
|
||||
export function richTextToPlaintext(body: TLRichText, resolveName?: (id: string) => string | undefined): string;
|
||||
|
||||
// @public
|
||||
export function SendButton({ label, disabled, onClick }: SendButtonProps): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SendButtonProps {
|
||||
// (undocumented)
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
// (undocumented)
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function shapeAnchorAt(editor: Editor, shapeId: TLShapeId, page: {
|
||||
x: number;
|
||||
y: number;
|
||||
}, precise: boolean): TLCommentAnchor;
|
||||
|
||||
// @public
|
||||
export interface ShapeCommentPrecisionContext {
|
||||
// (undocumented)
|
||||
readonly altKey: boolean;
|
||||
// (undocumented)
|
||||
readonly point: VecLike;
|
||||
// (undocumented)
|
||||
readonly shapeId: TLShapeId;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface SidebarFilters {
|
||||
onlyCurrentPage: boolean;
|
||||
onlyMine: boolean;
|
||||
onlyUnread: boolean;
|
||||
showResolved: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const sidebarFilters: EditorAtom<SidebarFilters>;
|
||||
|
||||
// @public
|
||||
export interface SidebarRow {
|
||||
// (undocumented)
|
||||
item: CommentListItemProps;
|
||||
lastActivity: number;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function sortSidebarRows(rows: readonly SidebarRow[]): readonly SidebarRow[];
|
||||
|
||||
// @public
|
||||
export function summarizeReactions(reactions: readonly ReactionSummaryInput[], currentUserId?: null | string, resolveName?: (userId: string) => string | undefined): ReactionSummary[];
|
||||
|
||||
// @public
|
||||
export type TLCommentRecord = TLComment | TLCommentReaction | TLCommentThread;
|
||||
|
||||
// @public
|
||||
export function toggleCommentReaction(editor: Editor, comment: TLComment, userId: string, emoji: string, now?: number): void;
|
||||
|
||||
// @public
|
||||
export function toggleCommentsHidden(editor: Editor): void;
|
||||
|
||||
// @public
|
||||
export function toggleCommentsSidebar(editor: Editor): void;
|
||||
|
||||
// @public
|
||||
export function useCanComment(currentUserId: null | string | undefined): boolean;
|
||||
|
||||
// @public
|
||||
export function useCanModifyComment(currentUserId: null | string | undefined, modification: CommentModification): boolean;
|
||||
|
||||
// @public
|
||||
export function useCommentingEnabled(): boolean;
|
||||
|
||||
// @public
|
||||
export function useCommentingOptions(): CommentingOptions;
|
||||
|
||||
// @public
|
||||
export function useCommentReactions(editor: Editor, commentId: TLComment['id']): TLCommentReaction[];
|
||||
|
||||
// @public
|
||||
export function useComments(editor: Editor): TLComment[];
|
||||
|
||||
// @public
|
||||
export function useCommentsHidden(): boolean;
|
||||
|
||||
// @public
|
||||
export function useCommentsSidebarOpen(): boolean;
|
||||
|
||||
// @public
|
||||
export function useCommentThreads(editor: Editor): TLCommentThread[];
|
||||
|
||||
// @public
|
||||
export function useOpenThreadId(): null | string;
|
||||
|
||||
// @public
|
||||
export function useRevealThreadPending(): null | string;
|
||||
|
||||
// @public
|
||||
export function useSidebarFilters(): SidebarFilters;
|
||||
|
||||
// @public
|
||||
export function useThreadComments(editor: Editor, threadId: TLCommentThreadId): TLComment[];
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
```
|
||||
70
packages/commenting/package.json
Normal file
70
packages/commenting/package.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@tldraw/commenting",
|
||||
"description": "tldraw commenting UI components.",
|
||||
"version": "5.4.2",
|
||||
"author": {
|
||||
"name": "tldraw Inc.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"tldraw_product": {
|
||||
"stableId": "tldraw:commenting",
|
||||
"name": "Commenting",
|
||||
"type": "feature",
|
||||
"parent": "tldraw:collaboration",
|
||||
"premium": true,
|
||||
"licenseFlag": "FEAT_COMMENTING"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tldraw/tldraw"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tldraw/tldraw/issues"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./.tsbuild/index.d.ts",
|
||||
"style": "./commenting.css",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"commenting.css"
|
||||
],
|
||||
"scripts": {
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"predev": "node ./scripts/copy-css-files.mjs",
|
||||
"dev": "chokidar '../mentions/mentions.css' 'src/ui/comments.css' 'src/canvas/canvas.css' -c 'node ./scripts/copy-css-files.mjs'",
|
||||
"prebuild": "node ./scripts/copy-css-files.mjs",
|
||||
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
|
||||
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
|
||||
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
|
||||
"postpack": "../../internal/scripts/postpack.sh",
|
||||
"pack-tarball": "yarn pack",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tldraw/driver": "workspace:*",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0 || ^19.2.1",
|
||||
"react-dom": "^18.2.0 || ^19.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.12.1",
|
||||
"@tiptap/pm": "^3.12.1",
|
||||
"@tiptap/react": "^3.12.1",
|
||||
"@tldraw/mentions": "workspace:*",
|
||||
"@tldraw/utils": "workspace:*",
|
||||
"tldraw": "workspace:*"
|
||||
}
|
||||
}
|
||||
28
packages/commenting/scripts/copy-css-files.mjs
Normal file
28
packages/commenting/scripts/copy-css-files.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const packageDir = join(__dirname, '..')
|
||||
|
||||
const combinedContent = [
|
||||
join(packageDir, '..', 'mentions', 'mentions.css'),
|
||||
join(packageDir, 'src', 'ui', 'comments.css'),
|
||||
join(packageDir, 'src', 'canvas', 'canvas.css'),
|
||||
].reduce(
|
||||
(acc, path) => {
|
||||
const content = readFileSync(path, 'utf8')
|
||||
acc += content + '\n'
|
||||
return acc
|
||||
},
|
||||
`/* THIS CSS FILE IS GENERATED! DO NOT EDIT. OR EDIT. I'M A COMMENT NOT A COP */
|
||||
/* This file is created by the copy-css-files.mjs script in packages/commenting. */
|
||||
/* It combines the commenting package's ui and canvas stylesheets. */
|
||||
|
||||
`
|
||||
)
|
||||
|
||||
const destination = join(packageDir, 'commenting.css')
|
||||
writeFileSync(destination, combinedContent)
|
||||
290
packages/commenting/src/canvas/anchor-lifecycle.test.ts
Normal file
290
packages/commenting/src/canvas/anchor-lifecycle.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
commentSchemaRecords,
|
||||
createCommentThread,
|
||||
createShapeId,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
toRichText,
|
||||
createComment as tlCreateComment,
|
||||
TLPageId,
|
||||
TLShapeId,
|
||||
} from 'tldraw'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { registerCommentAnchorLifecycle } from './anchor-lifecycle'
|
||||
import { commitCommentMutation, putCommentRecords } from './comment-mutations'
|
||||
import { getCommentRecord, getComments } from './comment-store'
|
||||
import { CommentTool } from './comment-tool'
|
||||
|
||||
/**
|
||||
* These tests need a real editor: the behavior under test is the interplay between store
|
||||
* side effects (the beforeDelete snapshot, operation-complete settlement) and real editor
|
||||
* operations like `deleteShapes` and `moveShapesToPage` — exactly what a stub can't model.
|
||||
*/
|
||||
|
||||
let editor: Editor
|
||||
let dispose: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: defaultTools,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
dispose = registerCommentAnchorLifecycle(editor)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dispose()
|
||||
editor.dispose()
|
||||
})
|
||||
|
||||
function makeShape(x = 100, y = 100, w = 100, h = 50): TLShapeId {
|
||||
const id = createShapeId()
|
||||
editor.createShape({ id, type: 'geo', x, y, props: { w, h } })
|
||||
return id
|
||||
}
|
||||
|
||||
function makeThread(shapeId: TLShapeId, anchorX = 0.5, anchorY = 0.5, isPrecise = true) {
|
||||
const thread = createCommentThread({
|
||||
pageId: editor.getCurrentPageId(),
|
||||
anchor: { type: 'shape', shapeId, x: anchorX, y: anchorY, isPrecise },
|
||||
createdBy: 'me',
|
||||
})
|
||||
const comment = tlCreateComment({
|
||||
threadId: thread.id,
|
||||
pageId: thread.pageId,
|
||||
authorId: 'me',
|
||||
body: toRichText('hello'),
|
||||
})
|
||||
putCommentRecords(editor, [thread, comment])
|
||||
return { thread, comment }
|
||||
}
|
||||
|
||||
function makeSecondPage(): TLPageId {
|
||||
editor.createPage({ name: 'page 2' })
|
||||
return editor.getPages()[1].id
|
||||
}
|
||||
|
||||
describe('shape deletion', () => {
|
||||
it('converts a precise shape anchor to a point anchor where the pin sat', () => {
|
||||
const shapeId = makeShape(100, 100, 100, 50)
|
||||
const { thread } = makeThread(shapeId, 0.5, 0.5, true)
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id)!
|
||||
expect(updated.typeName).toBe('comment-thread')
|
||||
expect((updated as typeof thread).anchor).toEqual({ type: 'point', x: 150, y: 125 })
|
||||
})
|
||||
|
||||
it('converts an imprecise shape anchor where the inset pin is drawn, not at the raw corner', () => {
|
||||
const shapeId = makeShape(100, 100, 100, 50)
|
||||
const { thread } = makeThread(shapeId, 0.2, 0.9, false)
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
|
||||
// Top-right corner (200, 100) plus the 20px screen inset toward the shape's centre.
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 180, y: 120 })
|
||||
})
|
||||
|
||||
it('resolves the imprecise spot from the commenting options when no override is passed', () => {
|
||||
const configured = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: [...defaultTools, CommentTool.configure({ impreciseShapeAnchor: { x: 0, y: 1 } })],
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
const disposeConfigured = registerCommentAnchorLifecycle(configured)
|
||||
try {
|
||||
const shapeId = createShapeId()
|
||||
configured.createShape({ id: shapeId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 50 } })
|
||||
const thread = createCommentThread({
|
||||
pageId: configured.getCurrentPageId(),
|
||||
anchor: { type: 'shape', shapeId, x: 0.5, y: 0.5, isPrecise: false },
|
||||
createdBy: 'me',
|
||||
})
|
||||
putCommentRecords(configured, [thread])
|
||||
|
||||
configured.deleteShape(shapeId)
|
||||
|
||||
// Bottom-left corner (100, 150) plus the inset toward the shape's centre.
|
||||
const updated = getCommentRecord(configured, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 120, y: 130 })
|
||||
} finally {
|
||||
disposeConfigured()
|
||||
configured.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('converts anchors on children when their frame is deleted with them', () => {
|
||||
const frameId = createShapeId()
|
||||
editor.createShape({ id: frameId, type: 'frame', x: 0, y: 0, props: { w: 400, h: 400 } })
|
||||
const childId = createShapeId()
|
||||
editor.createShape({
|
||||
id: childId,
|
||||
type: 'geo',
|
||||
parentId: frameId,
|
||||
x: 50,
|
||||
y: 50,
|
||||
props: { w: 100, h: 100 },
|
||||
})
|
||||
const { thread } = makeThread(childId, 0.5, 0.5, true)
|
||||
|
||||
editor.deleteShape(frameId)
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 100, y: 100 })
|
||||
})
|
||||
|
||||
it('re-attaches the thread when the delete is undone', () => {
|
||||
const shapeId = makeShape(100, 100, 100, 50)
|
||||
const { thread } = makeThread(shapeId, 0.5, 0.5, true)
|
||||
editor.markHistoryStoppingPoint()
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
expect((getCommentRecord(editor, thread.id) as typeof thread).anchor.type).toBe('point')
|
||||
|
||||
editor.undo()
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual(thread.anchor)
|
||||
})
|
||||
|
||||
it('keeps the restore when a returning shape cannot resolve its page yet', () => {
|
||||
const frameId = createShapeId()
|
||||
editor.createShape({ id: frameId, type: 'frame', x: 0, y: 0, props: { w: 400, h: 400 } })
|
||||
const childId = createShapeId()
|
||||
editor.createShape({
|
||||
id: childId,
|
||||
type: 'geo',
|
||||
parentId: frameId,
|
||||
x: 50,
|
||||
y: 50,
|
||||
props: { w: 100, h: 100 },
|
||||
})
|
||||
const { thread } = makeThread(childId, 0.5, 0.5, true)
|
||||
const childRecord = editor.getShape(childId)!
|
||||
const frameRecord = editor.getShape(frameId)!
|
||||
|
||||
editor.deleteShape(frameId)
|
||||
expect((getCommentRecord(editor, thread.id) as typeof thread).anchor.type).toBe('point')
|
||||
|
||||
// The child returns in an earlier operation than its parent, so its ancestor page can't
|
||||
// resolve when it first reappears.
|
||||
editor.store.put([childRecord])
|
||||
editor.store.put([frameRecord])
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual(thread.anchor)
|
||||
})
|
||||
|
||||
it('re-attaches on undo even when the lifecycle was re-registered in between', () => {
|
||||
const shapeId = makeShape(100, 100, 100, 50)
|
||||
const { thread } = makeThread(shapeId, 0.5, 0.5, true)
|
||||
editor.markHistoryStoppingPoint()
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
expect((getCommentRecord(editor, thread.id) as typeof thread).anchor.type).toBe('point')
|
||||
|
||||
// The registering effect re-runs whenever its inputs change identity; conversion memory
|
||||
// must not live and die with any one registration.
|
||||
dispose()
|
||||
dispose = registerCommentAnchorLifecycle(editor)
|
||||
|
||||
editor.undo()
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual(thread.anchor)
|
||||
})
|
||||
|
||||
it('does not re-attach on undo when the pin was moved after the conversion', () => {
|
||||
const shapeId = makeShape(100, 100, 100, 50)
|
||||
const { thread } = makeThread(shapeId, 0.5, 0.5, true)
|
||||
editor.markHistoryStoppingPoint()
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
const converted = getCommentRecord(editor, thread.id) as typeof thread
|
||||
commitCommentMutation(editor, ({ put }) =>
|
||||
put([{ ...converted, anchor: { type: 'point', x: 900, y: 900 } }])
|
||||
)
|
||||
|
||||
editor.undo()
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 900, y: 900 })
|
||||
})
|
||||
|
||||
it('leaves threads on other shapes untouched', () => {
|
||||
const shapeId = makeShape()
|
||||
const otherId = makeShape(300, 300)
|
||||
const { thread } = makeThread(otherId)
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor.type).toBe('shape')
|
||||
})
|
||||
})
|
||||
|
||||
describe('moving a shape to another page', () => {
|
||||
it('re-homes the thread and its comments to the new page, keeping the shape anchor', () => {
|
||||
const shapeId = makeShape()
|
||||
const { thread, comment } = makeThread(shapeId)
|
||||
const page2 = makeSecondPage()
|
||||
|
||||
editor.moveShapesToPage([shapeId], page2)
|
||||
|
||||
const updatedThread = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updatedThread.pageId).toBe(page2)
|
||||
expect(updatedThread.anchor).toEqual(thread.anchor)
|
||||
const updatedComment = getComments(editor).find((c) => c.id === comment.id)!
|
||||
expect(updatedComment.pageId).toBe(page2)
|
||||
})
|
||||
|
||||
it('re-homes threads anchored to children of a moved frame', () => {
|
||||
const frameId = createShapeId()
|
||||
editor.createShape({ id: frameId, type: 'frame', x: 0, y: 0, props: { w: 400, h: 400 } })
|
||||
const childId = createShapeId()
|
||||
editor.createShape({
|
||||
id: childId,
|
||||
type: 'geo',
|
||||
parentId: frameId,
|
||||
x: 50,
|
||||
y: 50,
|
||||
props: { w: 100, h: 100 },
|
||||
})
|
||||
const { thread } = makeThread(childId)
|
||||
const page2 = makeSecondPage()
|
||||
|
||||
editor.moveShapesToPage([frameId], page2)
|
||||
|
||||
const updatedThread = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updatedThread.pageId).toBe(page2)
|
||||
expect(updatedThread.anchor.type).toBe('shape')
|
||||
})
|
||||
|
||||
it('re-homes the thread back when the move is undone', () => {
|
||||
const shapeId = makeShape()
|
||||
const { thread } = makeThread(shapeId)
|
||||
const page1 = editor.getCurrentPageId()
|
||||
const page2 = makeSecondPage()
|
||||
editor.markHistoryStoppingPoint()
|
||||
|
||||
editor.moveShapesToPage([shapeId], page2)
|
||||
expect((getCommentRecord(editor, thread.id) as typeof thread).pageId).toBe(page2)
|
||||
|
||||
editor.undo()
|
||||
|
||||
const updatedThread = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updatedThread.pageId).toBe(page1)
|
||||
expect(updatedThread.anchor.type).toBe('shape')
|
||||
})
|
||||
})
|
||||
190
packages/commenting/src/canvas/anchor-lifecycle.ts
Normal file
190
packages/commenting/src/canvas/anchor-lifecycle.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { WeakCache } from '@tldraw/utils'
|
||||
import { Editor, TLCommentAnchor, TLCommentThread, TLPageId, TLShapeId, VecLike } from 'tldraw'
|
||||
import { commitCommentMutation } from './comment-mutations'
|
||||
import { getComments, getCommentThread, getCommentThreads, TLCommentRecord } from './comment-store'
|
||||
import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
|
||||
|
||||
type ShapeAnchor = Extract<TLCommentAnchor, { type: 'shape' }>
|
||||
|
||||
/**
|
||||
* Threads converted to point anchors because their shape was deleted, kept so the anchor can be
|
||||
* restored if the shape comes back. Owned by the editor, not the registration closure: the
|
||||
* registering effect can re-run, and an undo can arrive long after the delete.
|
||||
*/
|
||||
const convertedByShapeCache = new WeakCache<
|
||||
Editor,
|
||||
Map<TLShapeId, { threadId: string; anchor: ShapeAnchor; point: VecLike }[]>
|
||||
>()
|
||||
|
||||
function isAnchoredToShape(
|
||||
thread: TLCommentThread,
|
||||
shapeId: TLShapeId
|
||||
): thread is TLCommentThread & { anchor: ShapeAnchor } {
|
||||
const anchor = thread.anchor
|
||||
return anchor.type === 'shape' && anchor.shapeId === shapeId
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep shape-anchored threads alive across their shape's lifecycle:
|
||||
*
|
||||
* - When the shape is deleted, the thread converts to a `point` anchor where its pin last sat, so
|
||||
* the conversation outlives the shape instead of becoming invisible.
|
||||
* - When the shape moves to another page, the thread follows: its `pageId` and each comment's
|
||||
* denormalized `pageId` update, and the anchor keeps riding the shape.
|
||||
* - When a deleted shape comes back, the thread re-attaches — unless its pin was manually moved in
|
||||
* the meantime, in which case the manual placement wins.
|
||||
*
|
||||
* A page move is `deleteShapes` + re-create with preserved ids inside one `editor.run`, but each
|
||||
* store write is its own operation, so "this shape is being moved" is never observable as a single
|
||||
* event. The handlers therefore cooperate across operations: `beforeDelete` snapshots where each
|
||||
* affected pin sits, the operation-complete pass converts threads whose shape is really gone, and
|
||||
* `afterCreate` plus that same pass restore the anchor once the store has settled. Undo/redo of a
|
||||
* move replays as a `parentId` update, so `afterChange` re-homes threads on cross-page reparents —
|
||||
* including threads anchored to descendants, which move without change events of their own.
|
||||
*
|
||||
* Remote changes are ignored: the client that performed the operation runs this same maintenance
|
||||
* and syncs the result. Writes honour the {@link CommentingOptions.history} option.
|
||||
*
|
||||
* Registered by `CanvasComments` on mount; parts-built consumers can call this directly. Returns a
|
||||
* cleanup function that unregisters all handlers.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function registerCommentAnchorLifecycle(editor: Editor): () => void {
|
||||
// Pin positions snapshotted during the current operation: shape id -> (thread id -> pin page
|
||||
// point).
|
||||
const pendingByShape = new Map<TLShapeId, Map<string, VecLike | null>>()
|
||||
const convertedByShape = convertedByShapeCache.get(editor, () => new Map())
|
||||
|
||||
function rehomeThread(thread: TLCommentThread, pageId: TLPageId, updates: TLCommentRecord[]) {
|
||||
updates.push({ ...thread, pageId })
|
||||
for (const comment of getComments(editor)) {
|
||||
if (comment.threadId === thread.id) updates.push({ ...comment, pageId })
|
||||
}
|
||||
}
|
||||
|
||||
const disposeBeforeDelete = editor.sideEffects.registerBeforeDeleteHandler(
|
||||
'shape',
|
||||
(shape, source) => {
|
||||
if (source === 'remote') return
|
||||
for (const thread of getCommentThreads(editor)) {
|
||||
if (!isAnchoredToShape(thread, shape.id)) continue
|
||||
let snapshot = pendingByShape.get(shape.id)
|
||||
if (!snapshot) {
|
||||
snapshot = new Map()
|
||||
pendingByShape.set(shape.id, snapshot)
|
||||
}
|
||||
// Snapshot where the pin is drawn, not just the anchor point: imprecise shape pins render inset
|
||||
// toward the shape's centre, so without baking the inset in at the current zoom the pin would jump.
|
||||
let point = anchorPagePoint(editor, thread.anchor)
|
||||
const inset = impreciseShapePinInset(editor, thread.anchor)
|
||||
if (point && inset) {
|
||||
const zoom = editor.getZoomLevel()
|
||||
point = { x: point.x + inset.x / zoom, y: point.y + inset.y / zoom }
|
||||
}
|
||||
snapshot.set(thread.id, point)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Shape ids from `convertedByShape` re-created during the current operation. Settled at operation
|
||||
// complete rather than in `afterCreate`: creation order within an operation is arbitrary, so a shape
|
||||
// can appear before its parent and not resolve an ancestor page yet.
|
||||
const returnedShapeIds = new Set<TLShapeId>()
|
||||
|
||||
const disposeOperationComplete = editor.sideEffects.registerOperationCompleteHandler((source) => {
|
||||
// Only user-sourced handlers populate the maps and only local operations should settle
|
||||
// them; a remote operation completing leaves any (impossible-in-practice) leftovers for
|
||||
// the next local settle rather than judging them against remote state.
|
||||
if (source === 'remote') return
|
||||
if (pendingByShape.size === 0 && returnedShapeIds.size === 0) return
|
||||
const settled = [...pendingByShape.entries()]
|
||||
pendingByShape.clear()
|
||||
const returned = [...returnedShapeIds]
|
||||
returnedShapeIds.clear()
|
||||
|
||||
const updates: TLCommentRecord[] = []
|
||||
for (const [shapeId, threadPoints] of settled) {
|
||||
if (editor.getShape(shapeId)) continue
|
||||
for (const [threadId, point] of threadPoints) {
|
||||
if (!point) continue
|
||||
const thread = getCommentThread(editor, threadId)
|
||||
if (!thread || !isAnchoredToShape(thread, shapeId)) continue
|
||||
let converted = convertedByShape.get(shapeId)
|
||||
if (!converted) {
|
||||
converted = []
|
||||
convertedByShape.set(shapeId, converted)
|
||||
}
|
||||
converted.push({ threadId, anchor: thread.anchor, point })
|
||||
updates.push({ ...thread, anchor: { type: 'point', x: point.x, y: point.y } })
|
||||
}
|
||||
}
|
||||
|
||||
for (const shapeId of returned) {
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) continue
|
||||
const converted = convertedByShape.get(shapeId)
|
||||
if (!converted) continue
|
||||
convertedByShape.delete(shapeId)
|
||||
|
||||
const pageId = editor.getAncestorPageId(shape)
|
||||
for (const { threadId, anchor, point } of converted) {
|
||||
const thread = getCommentThread(editor, threadId)
|
||||
if (!thread) continue
|
||||
// Re-attach only threads still sitting exactly where the conversion left them — a
|
||||
// pin moved since then was placed deliberately, and that placement wins.
|
||||
const current = thread.anchor
|
||||
if (current.type !== 'point' || current.x !== point.x || current.y !== point.y) {
|
||||
continue
|
||||
}
|
||||
if (!pageId || thread.pageId === pageId) {
|
||||
updates.push({ ...thread, anchor })
|
||||
} else {
|
||||
rehomeThread({ ...thread, anchor }, pageId, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
commitCommentMutation(editor, ({ put }) => put(updates))
|
||||
}
|
||||
})
|
||||
|
||||
const disposeAfterCreate = editor.sideEffects.registerAfterCreateHandler(
|
||||
'shape',
|
||||
(shape, source) => {
|
||||
if (source === 'remote') return
|
||||
if (convertedByShape.has(shape.id)) returnedShapeIds.add(shape.id)
|
||||
}
|
||||
)
|
||||
|
||||
const disposeAfterChange = editor.sideEffects.registerAfterChangeHandler(
|
||||
'shape',
|
||||
(prev, next, source) => {
|
||||
if (source === 'remote') return
|
||||
if (prev.parentId === next.parentId) return
|
||||
const pageId = editor.getAncestorPageId(next)
|
||||
if (!pageId) return
|
||||
// Descendants move with their parent without change events of their own.
|
||||
const movedIds = editor.getShapeAndDescendantIds([next.id])
|
||||
const updates: TLCommentRecord[] = []
|
||||
for (const thread of getCommentThreads(editor)) {
|
||||
const anchor = thread.anchor
|
||||
if (anchor.type !== 'shape') continue
|
||||
if (!movedIds.has(anchor.shapeId)) continue
|
||||
if (thread.pageId === pageId) continue
|
||||
rehomeThread(thread, pageId, updates)
|
||||
}
|
||||
if (updates.length > 0) {
|
||||
commitCommentMutation(editor, ({ put }) => put(updates))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
disposeBeforeDelete()
|
||||
disposeOperationComplete()
|
||||
disposeAfterCreate()
|
||||
disposeAfterChange()
|
||||
}
|
||||
}
|
||||
18
packages/commenting/src/canvas/canvas-events.ts
Normal file
18
packages/commenting/src/canvas/canvas-events.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { Editor } from 'tldraw'
|
||||
|
||||
/** A pointer-down that belongs to the camera, not the comment UI: any non-primary button
|
||||
* (middle/right-button pans), or a primary press with the spacebar pan key held. */
|
||||
export function isCanvasPanGesture(editor: Editor, e: ReactPointerEvent) {
|
||||
return e.button !== 0 || editor.inputs.keys.has('Space')
|
||||
}
|
||||
|
||||
/** Hand a pointer event to the canvas beneath the comments layer, marked the same way the
|
||||
* pass-through wheel/hover hooks mark their re-dispatched events. */
|
||||
export function forwardPointerEventToCanvas(container: HTMLElement, e: ReactPointerEvent) {
|
||||
const cvs = container.querySelector('.tl-canvas')
|
||||
if (!cvs) return
|
||||
const newEvent = new PointerEvent(e.type, e.nativeEvent as any)
|
||||
;(newEvent as any).isSpecialRedispatchedEvent = true
|
||||
cvs.dispatchEvent(newEvent)
|
||||
}
|
||||
557
packages/commenting/src/canvas/canvas.css
Normal file
557
packages/commenting/src/canvas/canvas.css
Normal file
@@ -0,0 +1,557 @@
|
||||
/* The comments layer renders through `EditorPortal`, so its pieces can pick their own layer rather
|
||||
than the one the component is mounted in. Pins live in the canvas-in-front layer (beneath the UI
|
||||
panels at z-index 300), so the toolbar and style panel draw over them; the open thread's popover
|
||||
goes to the menus layer instead (`.tlui-cmt-canvas-popover`) so it isn't clipped. Click-through
|
||||
except on its own children. */
|
||||
.tlui-cmt-canvas-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* Just below the collaborator cursor layer, so cursors ride over the pins — and below the
|
||||
in-front content (250), so active tool UI covers both. Derived, not a literal, so the
|
||||
ordering can't silently drift if the cursor slot moves. */
|
||||
z-index: calc(var(--tl-layer-collaborators) - 5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tlui-cmt-canvas-pin,
|
||||
.tlui-cmt-canvas-cluster,
|
||||
.tlui-cmt-canvas-composer {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.tlui-cmt-canvas-composer {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The pin's only interactive surface is the square hit layer below — the wrapper itself is
|
||||
inert. Its box sits at the anchor point, not over the (translated) visual, so letting it take
|
||||
events would put a phantom hit zone beside the pin. */
|
||||
.tlui-cmt-canvas-pin {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tlui-cmt-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Anchored by its bottom-left corner like a pin, so the badge sits up-and-right of the cluster's
|
||||
centroid — the same visual-to-anchor relationship a single comment pin has. */
|
||||
.tlui-cmt-canvas-cluster {
|
||||
transform: translate(0, -100%);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Keyboard focus has to be visible on the canvas, where there's no surrounding chrome to imply
|
||||
it. The ring goes on the marker visual (the rounded pin/badge), not the button box, so it
|
||||
traces the shape rather than a rectangle around it; the button's own outline is suppressed
|
||||
because its box is offset from what you see. */
|
||||
.tlui-cmt-canvas-pin__marker:focus-visible,
|
||||
.tlui-cmt-canvas-cluster:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.tlui-cmt-canvas-pin__marker:focus-visible .tlui-cmt-marker,
|
||||
.tlui-cmt-canvas-cluster:focus-visible .tlui-cmt-marker {
|
||||
outline: 2px solid var(--tl-color-selected);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Focus should read like hover, so the pin lifts the same way when tabbed to. */
|
||||
.tlui-cmt-canvas-pin__marker:focus-visible .tlui-cmt-marker {
|
||||
transform: scale(var(--tlui-cmt-marker-hover-scale));
|
||||
}
|
||||
|
||||
.tlui-cmt-cluster-fade {
|
||||
opacity: 1;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.tlui-cmt-cluster-fade--entering {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tlui-cmt-cluster-fade--present {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tlui-cmt-cluster-fade--exiting {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tlui-cmt-cluster-fade--entering *,
|
||||
.tlui-cmt-cluster-fade--exiting * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tlui-cmt-cluster-fade {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The open thread (its pin + popover) sits above the other pins, so nearby markers don't
|
||||
overlap its popover — they tuck behind it. */
|
||||
.tlui-cmt-canvas-pin--open {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* The count badge standing in for a stack of coincident pins — same face as a cluster badge,
|
||||
but clicking opens the stack's thread list rather than zooming (no zoom can separate them).
|
||||
Re-enables pointer events: the badge lives inside `.tlui-cmt-canvas-pin`, which is inert
|
||||
(`pointer-events: none`) so its box doesn't shadow the canvas — without this the badge renders
|
||||
but can't be hovered or clicked, like the cluster badge's own `pointer-events: auto`. */
|
||||
.tlui-cmt-canvas-stack-badge {
|
||||
/* Bottom-left anchored like the pins it stands in for, and like the cluster badge. */
|
||||
transform: translate(0, -100%);
|
||||
width: max-content;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tlui-cmt-canvas-stack-badge:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.tlui-cmt-canvas-stack-badge:focus-visible .tlui-cmt-marker {
|
||||
outline: 2px solid var(--tl-color-selected);
|
||||
outline-offset: 2px;
|
||||
transform: scale(var(--tlui-cmt-marker-hover-scale));
|
||||
}
|
||||
|
||||
/* The stack's popover content: one card per thread, the expanded thread inline among them. */
|
||||
.tlui-cmt-stack-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* The expanded thread is the only entry with pills floating above its panel — its header actions,
|
||||
and its first comment's hover actions. Those reach ~15px up, past the list's 10px gap, so give
|
||||
this one entry extra room: without it the pills land on the card above, and against the scroll
|
||||
box's clip edge. Collapsed cards keep their own actions inside, so they need none of this. */
|
||||
.tlui-cmt-stack-list__thread {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Collapsed entries match the expanded thread's surface (same width, panel, elevation) so the
|
||||
list reads as one column of cards with one of them opened up. */
|
||||
.tlui-cmt-stack-list__card {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 300px;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
padding: 2px 12px 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The card's keyboard affordance, covering the whole surface so it's one target rather than a
|
||||
control tucked in a corner. It's laid under the card's content instead of wrapping it: a comment
|
||||
body renders its links as anchors, which can't be nested inside a button (see the note in
|
||||
`StackThreadCard`). Pointer clicks anywhere still reach the card's own handler by bubbling. */
|
||||
.tlui-cmt-stack-list__card-action {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tlui-cmt-stack-list__card-action:focus-visible {
|
||||
outline: 2px solid var(--tl-color-selected);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The wrapper is the card's surface — inside it the comment is plain content, mirroring how the
|
||||
thread panel flattens its own cards. Stacked above the action overlay so the body's links stay
|
||||
clickable. */
|
||||
.tlui-cmt-stack-list__card .tlui-cmt-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* The hover preview, shown for any marker — a pin, a coincident stack, or a cluster badge. Live,
|
||||
not a tooltip: the pointer travels into it to hover and click the cards.
|
||||
|
||||
Two elements, deliberately: this root is the hover *region* (it reaches back over the marker via
|
||||
the bridge below), and `__panel` inside it is the visible surface. A pseudo-element's hover
|
||||
counts as its host's, so a surface on this root would light up whenever the pointer was on the
|
||||
marker — the pin would appear pressed just for being hovered. */
|
||||
.tlui-cmt-canvas-preview {
|
||||
position: absolute;
|
||||
z-index: var(--tl-layer-menus);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The bridge over the gap between the marker and the panel. Moving right off the pin would
|
||||
otherwise cross bare canvas, and that leave would retract the panel before the pointer arrived.
|
||||
Invisible, part of the panel's hover region, and only present while the panel is.
|
||||
|
||||
It spans the gap and stops there. Reaching any further would put it over the marker itself, and
|
||||
since it sits in the menus layer — above the pins — it would take the marker's hover and clicks:
|
||||
the pin would go dead exactly where it looks most alive. The marker needs no covering anyway, as
|
||||
it carries the same hover handlers.
|
||||
|
||||
The gap is the panel's own offset (`--tlui-cmt-preview-offset`, set from `POPOVER_OFFSET` so
|
||||
there's one source) less however far the marker reaches toward it, per variant below. */
|
||||
.tlui-cmt-canvas-preview::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 100%;
|
||||
width: max(0px, var(--tlui-cmt-preview-bridge, 0px));
|
||||
}
|
||||
|
||||
/* A pin is anchored at its bottom-left corner, so its full width lies between anchor and panel.
|
||||
The popover and preview share a first-comment position now that the actions float outside the
|
||||
panel rather than reserving a header row, so no vertical shift is needed. */
|
||||
.tlui-cmt-canvas-preview--thread {
|
||||
--tlui-cmt-preview-bridge: calc(var(--tlui-cmt-preview-offset) - var(--tlui-cmt-pin-size));
|
||||
}
|
||||
|
||||
/* A badge is anchored at its bottom-left corner like a pin, so its full width does too. */
|
||||
.tlui-cmt-canvas-preview--list {
|
||||
--tlui-cmt-preview-bridge: calc(var(--tlui-cmt-preview-offset) - var(--tlui-cmt-marker-size));
|
||||
}
|
||||
|
||||
.tlui-cmt-canvas-preview__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Previewing a single pin: the thread panel itself, minus the header — the same surface, width,
|
||||
and flattened comment (see `.tlui-cmt-thread .tlui-cmt-card`), so the hover shows exactly what
|
||||
the click opens. */
|
||||
.tlui-cmt-canvas-preview__panel--thread {
|
||||
box-sizing: border-box;
|
||||
width: 300px;
|
||||
gap: var(--tlui-cmt-thread-gap);
|
||||
/* Top/bottom only — the card pads its own sides now (12px), matching the expanded thread. A
|
||||
little extra below so the (often truncated) last line doesn't sit against the rounded edge. */
|
||||
padding: 4px 0 8px;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: var(--tl-radius-4);
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
}
|
||||
/* The reply count is a short line that sits closer to the edge than a wrapped comment does, so give
|
||||
it more room below when it's the panel's last line. */
|
||||
.tlui-cmt-canvas-preview__panel--thread:has(.tlui-cmt-card__replies) {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Previewing a stack or cluster: one card per thread, matching the card list a click opens. */
|
||||
.tlui-cmt-canvas-preview__panel--list {
|
||||
gap: 8px;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
/* Matches the stack list's card surface, so a preview and the list it previews read as the same
|
||||
object seen twice — once on hover, once opened. */
|
||||
.tlui-cmt-preview-card {
|
||||
box-sizing: border-box;
|
||||
width: 300px;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
/* A 2px nudge above the byline's centring — a boxed card reads a touch better with it than the
|
||||
single preview's flat card, which pads 0 vertically and leans on the panel + byline. */
|
||||
padding: 2px 12px 10px;
|
||||
}
|
||||
|
||||
.tlui-cmt-preview-card--selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Every card surface that tints on hover — a collapsed thread in a stack list, a preview card, and
|
||||
the single-comment preview panel. One rule, because they're the same object to the eye.
|
||||
|
||||
The opaque low-surface token, not a translucent muted one: these float directly over the canvas,
|
||||
where a translucent fill would let shapes show through.
|
||||
|
||||
The second line is why this isn't a one-liner. `--tl-shadow-3` ends with a 1px *inset* ring in
|
||||
`--tl-color-panel-contrast` — pure white in light mode — and an inset shadow paints over the
|
||||
background. At rest that's invisible (the panel is 99% white under a 100% white ring), but tint
|
||||
the background to grey and the ring stays white, hugging the edge as a pale outline. Re-tinting
|
||||
`--tl-color-panel-contrast` here wouldn't reach it: `--tl-shadow-3` is declared on the container,
|
||||
so its var() was already substituted there and the white is baked into what we inherit. Instead
|
||||
lay a ring of the new surface over the old one — first shadow in the list paints on top. */
|
||||
.tlui-cmt-stack-list__card:hover,
|
||||
.tlui-cmt-preview-card--selectable:hover,
|
||||
.tlui-cmt-canvas-preview__panel--thread.tlui-cmt-canvas-preview__panel--selectable:hover {
|
||||
--tlui-cmt-preview-hover-surface: color-mix(
|
||||
in srgb,
|
||||
var(--tl-color-low) 50%,
|
||||
var(--tl-color-panel)
|
||||
);
|
||||
background: var(--tlui-cmt-preview-hover-surface);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--tlui-cmt-preview-hover-surface),
|
||||
var(--tl-shadow-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tlui-cmt-preview-card .tlui-cmt-card {
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* A preview is a glance, not a list — past a few cards it says how many were left out rather than
|
||||
growing to the height of the cluster. */
|
||||
.tlui-cmt-preview-more {
|
||||
box-sizing: border-box;
|
||||
width: 300px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--tl-color-panel);
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
color: var(--tl-color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.tlui-cmt-canvas-preview {
|
||||
animation: tlui-cmt-preview-in 120ms ease;
|
||||
}
|
||||
@keyframes tlui-cmt-preview-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A region anchor's area: a dashed box under the pins, non-interactive so canvas events pass
|
||||
through. Drawn for the live drag draft and for a region thread while hovered or open. */
|
||||
.tlui-cmt-canvas-region {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 2px dashed var(--tl-color-selected);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--tl-color-selected) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Corner resize handles on a region box (three corners; the pin corner is the move handle). */
|
||||
.tlui-cmt-canvas-region-handle {
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--tl-color-panel);
|
||||
border: 1.5px solid var(--tl-color-selected);
|
||||
border-radius: 2px;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Anchor the pin by its bottom-left corner, so the marker's one sharp corner (the pin shape's
|
||||
3px bottom-left) points at the exact anchor point. */
|
||||
.tlui-cmt-canvas-pin__marker {
|
||||
position: relative;
|
||||
width: max-content;
|
||||
transform: translate(0, -100%);
|
||||
/* Default at rest, like hovering a shape; the editor's move cursor while dragging (below). */
|
||||
cursor: var(--tl-cursor-default);
|
||||
touch-action: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dragging a pin shows the same move cursor as dragging a shape. */
|
||||
.tlui-cmt-canvas-pin--dragging .tlui-cmt-canvas-pin__marker {
|
||||
cursor: var(--tl-cursor-move);
|
||||
}
|
||||
|
||||
/* The predictable hit area: a fixed square over the marker's box, and the only part of the pin
|
||||
that takes pointer events. Events over it target the marker (a pseudo-element's events report
|
||||
its originating element), so the marker's handlers and :hover keep working — but the hit area
|
||||
no longer stretches with pin content or scales with the hover transform. */
|
||||
.tlui-cmt-canvas-pin__marker::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* With the hit layer targeting the marker, the pin visual is never the hovered element itself
|
||||
(`:hover` matches the target and its ancestors, and the pin is the marker's child) — so the
|
||||
canvas pin's hover has to key off the hit layer instead of the marker's own `:hover`. Only the
|
||||
selector differs: the values come from the same tokens the shared rule uses, so a pin on the
|
||||
canvas and a badge (which is hovered directly) lift by exactly the same amount. */
|
||||
.tlui-cmt-canvas-pin__marker:hover .tlui-cmt-marker {
|
||||
transform: scale(var(--tlui-cmt-marker-hover-scale));
|
||||
}
|
||||
.tlui-cmt-canvas-pin__marker:hover .tlui-cmt-marker:not(.tlui-cmt-marker--open) {
|
||||
box-shadow: var(--tlui-cmt-marker-shadow-hover);
|
||||
}
|
||||
/* The comment pin doesn't scale on hover — only the shadow lifts (badges still scale). */
|
||||
.tlui-cmt-canvas-pin__marker:hover .tlui-cmt-pin {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* The open thread's popover — portaled to the menus layer (above the UI), positioned at the pin. */
|
||||
.tlui-cmt-canvas-popover {
|
||||
position: absolute;
|
||||
z-index: var(--tl-layer-menus);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The placement composer — a distinct floating view (portaled to the menus layer, below the click
|
||||
point): a draft avatar pin beside a pill field that is itself the surface (no wrapping panel).
|
||||
Scoped here so the in-thread reply composer keeps its own squarer look. */
|
||||
.tlui-cmt-canvas-composer {
|
||||
position: absolute;
|
||||
z-index: var(--tl-layer-menus);
|
||||
pointer-events: auto;
|
||||
/* Anchor the draft avatar bottom-left like the pins it becomes: it is a pin wide and sits 4px
|
||||
into the row, so its bottom-left corner is 4px right of and 4px + a pin below the composer's
|
||||
top-left corner. */
|
||||
transform: translate(-4px, calc(-4px - var(--tlui-cmt-pin-size)));
|
||||
}
|
||||
|
||||
/* A region placement centres the draft avatar on the region's pin corner — the pin it becomes
|
||||
straddles that corner. The avatar's centre sits 4px + half a pin into the composer. */
|
||||
.tlui-cmt-canvas-composer--region {
|
||||
--tlui-cmt-draft-avatar-centre: calc(4px + var(--tlui-cmt-pin-size) / 2);
|
||||
transform: translate(
|
||||
calc(-1 * var(--tlui-cmt-draft-avatar-centre)),
|
||||
calc(-1 * var(--tlui-cmt-draft-avatar-centre))
|
||||
);
|
||||
}
|
||||
|
||||
/* Fallback placement (no composer to show): no draft avatar to anchor by, so no offset — a small
|
||||
panel hanging off the click point, sized to its content (e.g. the host's sign-in prompt). */
|
||||
.tlui-cmt-canvas-composer--fallback {
|
||||
transform: none;
|
||||
width: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: var(--tl-radius-4);
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer {
|
||||
gap: 6px;
|
||||
}
|
||||
/* The card: a fixed 300×42 surface whose uniform 3px padding derives the 294×36 focusable input. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer__field {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 300px;
|
||||
min-height: 42px;
|
||||
padding: 3px;
|
||||
border: none;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: var(--tl-radius-3);
|
||||
box-shadow: var(--tl-shadow-2);
|
||||
/* The pin is anchored by its bottom corner at the click point and can't move; raise the taller
|
||||
card onto the shorter pin's centre instead. */
|
||||
margin-top: -6px;
|
||||
}
|
||||
/* Focused (the placement composer autofocuses on mount): a selection-blue ring inset 3px from the
|
||||
card edge, so the card frames a bordered input rather than the border sitting on the card edge. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer__field:focus-within::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border: 1px solid var(--tl-color-selected);
|
||||
border-radius: var(--tl-radius-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The text sits 12px in from the focus ring rather than against it. The placeholder is absolutely
|
||||
positioned (it ignores the wrap's padding), so it carries the same offset itself. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer__input-wrap {
|
||||
padding: 4.5px 12px;
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer__field--expanded .tlui-cmt-composer__input-wrap {
|
||||
/* The base expanded rule re-pads the right edge for the squarer composer's geometry; here the
|
||||
12px is already symmetric. */
|
||||
padding-right: 12px;
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-input__placeholder {
|
||||
left: 12px;
|
||||
}
|
||||
/* The draft avatar previews the pin it becomes — a white 28px pin holding the author's 22px avatar
|
||||
circle — sharing the composer's lift. Not interactive: it drops the pin's pointer cursor and
|
||||
hover affordances (scale + shadow bump), but keeps a 40×40 hit area (the ::after below). */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-pin {
|
||||
position: relative;
|
||||
box-shadow: var(--tl-shadow-2);
|
||||
cursor: default;
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-pin::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-pin:hover {
|
||||
transform: none;
|
||||
box-shadow: var(--tl-shadow-2);
|
||||
}
|
||||
/* The placement composer's send button — a bare arrow, no pill: grey while empty, black once
|
||||
there's something to send. A 28px square centred in the 36px input height, so its hover wash
|
||||
clears the focus ring rather than butting against it; the ::after keeps a 40×40 hit area. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-send {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 4px;
|
||||
background: transparent;
|
||||
border-radius: var(--tl-radius-2);
|
||||
color: var(--tl-color-text-3);
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-send::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-send:not(:disabled) {
|
||||
color: var(--tl-color-text-1);
|
||||
}
|
||||
/* Hover and press wash grey behind the bare arrow (the same treatment as the thread actions),
|
||||
overriding the base button's blue fill. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-send:hover:not(:disabled) {
|
||||
background: var(--tl-color-muted-2);
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-send:active:not(:disabled) {
|
||||
background: var(--tl-color-muted-1);
|
||||
}
|
||||
/* In the expanded (multi-line) layout the send drops to the bottom-right corner; give it the same
|
||||
4px gap from the focus ring on the bottom that it already keeps on the right. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer__field--expanded .tlui-cmt-send {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* The comments list panel — a right-side surface shown while the comments sidebar is open. Below
|
||||
the popover layer, so an open thread still draws over it. Its top aligns flush with the style
|
||||
panel (48px from the top, clearing the share panel) and it sizes to its content, scrolling past
|
||||
max-height rather than stretching the full canvas height. */
|
||||
.tlui-cmt-canvas-sidebar {
|
||||
position: absolute;
|
||||
top: 48px;
|
||||
right: 8px;
|
||||
width: 280px;
|
||||
max-height: calc(100% - 56px);
|
||||
z-index: var(--tl-layer-panels);
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: var(--tl-radius-4);
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
133
packages/commenting/src/canvas/cluster-badge.tsx
Normal file
133
packages/commenting/src/canvas/cluster-badge.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
TLCommentThread,
|
||||
useContainer,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import type { ClusterNode } from '../clustering/types'
|
||||
import { CountBadge } from '../ui/count-badge'
|
||||
import { forwardPointerEventToCanvas, isCanvasPanGesture } from './canvas-events'
|
||||
import { type CommentingContext } from './context'
|
||||
import { sortThreadsForPreview, ThreadPreview, useMarkerPreview } from './thread-preview'
|
||||
import { impreciseShapePinInset, isInInflatedViewport } from './thread-state'
|
||||
|
||||
/**
|
||||
* The count badge standing in for several threads folded together at the current zoom. Hovering it
|
||||
* previews its threads as a list; clicking zooms to the zoom level at which the cluster splits.
|
||||
*
|
||||
* Memoized: cluster nodes and thread records are identity-stable while unchanged, so pins and
|
||||
* badges skip re-rendering when the parent re-renders for reasons that don't concern them
|
||||
* (leaf recomputes during shape drags, partition changes elsewhere). Camera tracking still
|
||||
* works — each component subscribes to its own viewport position via signals, not via props.
|
||||
*/
|
||||
export const ClusterBadge = memo(function ClusterBadge({
|
||||
editor,
|
||||
node,
|
||||
onExpand,
|
||||
onSelectThread,
|
||||
threadsById,
|
||||
...props
|
||||
}: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
|
||||
editor: Editor
|
||||
node: ClusterNode
|
||||
onExpand(node: ClusterNode): void
|
||||
onSelectThread(thread: TLCommentThread): void
|
||||
threadsById: ReadonlyMap<string, TLCommentThread>
|
||||
}) {
|
||||
const container = useContainer()
|
||||
const msg = useTranslation()
|
||||
const badgeRef = useRef<HTMLButtonElement>(null)
|
||||
const { previewShown, previewHandlers } = useMarkerPreview(editor, `cluster:${node.id}`)
|
||||
// Wheel pass-through sits on the badge (never scrollable), not the layer root — see the
|
||||
// note on the layer.
|
||||
usePassThroughWheelEvents(badgeRef)
|
||||
// The mean of the members' render offsets, in screen px. Imprecise members' pins draw tucked
|
||||
// into their shape (see impreciseShapePinInset), and the merge thresholds are priced at those
|
||||
// visual positions — so the badge draws at the centroid of the pins as drawn, which (screen
|
||||
// mapping being affine) is the anchor centroid plus this mean. Camera-independent: its own
|
||||
// computed so the per-frame point below only adds two numbers, and a cluster of precise
|
||||
// members tracks nothing and adds zero.
|
||||
const meanInset = useValue(
|
||||
'cluster badge inset',
|
||||
() => {
|
||||
let x = 0
|
||||
let y = 0
|
||||
for (const id of node.members) {
|
||||
const thread = threadsById.get(id)
|
||||
if (!thread) continue
|
||||
const inset = impreciseShapePinInset(editor, thread.anchor)
|
||||
if (inset) {
|
||||
x += inset.x
|
||||
y += inset.y
|
||||
}
|
||||
}
|
||||
return { x: x / node.count, y: y / node.count }
|
||||
},
|
||||
[editor, node, threadsById]
|
||||
)
|
||||
const point = useValue(
|
||||
'cluster badge point',
|
||||
() => {
|
||||
const pagePoint = editor.pageToViewport(node.centroid)
|
||||
if (!isInInflatedViewport(editor, pagePoint)) return null
|
||||
return { x: pagePoint.x + meanInset.x, y: pagePoint.y + meanInset.y }
|
||||
},
|
||||
[editor, node, meanInset]
|
||||
)
|
||||
|
||||
// `node.members` is sorted by id (the clustering table's ordering); the preview wants them in
|
||||
// the order a reader would expect. Only computed while the preview is up.
|
||||
const previewThreads = useMemo(() => {
|
||||
if (!previewShown) return []
|
||||
const threads: TLCommentThread[] = []
|
||||
for (const id of node.members) {
|
||||
const thread = threadsById.get(id)
|
||||
if (thread) threads.push(thread)
|
||||
}
|
||||
return sortThreadsForPreview(threads)
|
||||
}, [previewShown, node.members, threadsById])
|
||||
|
||||
if (!point) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={badgeRef}
|
||||
type="button"
|
||||
className="tlui-cmt-button tlui-cmt-canvas-cluster"
|
||||
style={{ left: point.x, top: point.y }}
|
||||
aria-label={msg('comments.cluster-label').replace('{count}', String(node.count))}
|
||||
onPointerDown={(e) => {
|
||||
if (isCanvasPanGesture(editor, e)) {
|
||||
forwardPointerEventToCanvas(container, e)
|
||||
return
|
||||
}
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onExpand(node)
|
||||
}}
|
||||
{...previewHandlers}
|
||||
onFocus={previewHandlers.onPointerEnter}
|
||||
onBlur={previewHandlers.onPointerLeave}
|
||||
>
|
||||
<CountBadge count={node.count} />
|
||||
</button>
|
||||
{previewShown && previewThreads.length > 0 && (
|
||||
<ThreadPreview
|
||||
editor={editor}
|
||||
threads={previewThreads}
|
||||
variant="list"
|
||||
point={point}
|
||||
onSelectThread={onSelectThread}
|
||||
{...previewHandlers}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
102
packages/commenting/src/canvas/cluster-fade.ts
Normal file
102
packages/commenting/src/canvas/cluster-fade.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ClusterNode } from '../clustering/types'
|
||||
|
||||
const CLUSTER_FADE_MS = 150
|
||||
|
||||
export type ClusterFadePhase = 'entering' | 'present' | 'exiting'
|
||||
|
||||
export interface ClusterFadeNode {
|
||||
node: ClusterNode
|
||||
phase: ClusterFadePhase
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-fades the visible cluster nodes as the partition changes: a node that appears starts
|
||||
* `entering` and flips to `present` on the next frame, and one that leaves is kept as `exiting`
|
||||
* until the CSS transition has run. `resetKey` is compared by identity — a new cluster model
|
||||
* replaces the whole world, so its nodes snap to `present` instead of animating.
|
||||
*/
|
||||
export function useFadeVisibleNodes(
|
||||
nodes: readonly ClusterNode[],
|
||||
resetKey: object
|
||||
): ClusterFadeNode[] {
|
||||
const resetKeyRef = useRef(resetKey)
|
||||
const didReset = resetKeyRef.current !== resetKey
|
||||
if (didReset) {
|
||||
resetKeyRef.current = resetKey
|
||||
}
|
||||
|
||||
const [fadeNodes, setFadeNodes] = useState<ClusterFadeNode[]>(() => toPresentFadeNodes(nodes))
|
||||
const renderedNodes = didReset ? toPresentFadeNodes(nodes) : fadeNodes
|
||||
|
||||
useEffect(() => {
|
||||
setFadeNodes(toPresentFadeNodes(nodes))
|
||||
// Resets only on a new model (resetKey); node-list changes within the same model are
|
||||
// handled by the reconcile effect below, which fades entries in/out instead of snapping.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resetKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (didReset) return
|
||||
setFadeNodes((previous) => reconcileFadeNodes(previous, nodes))
|
||||
}, [didReset, nodes])
|
||||
|
||||
const hasEntering = renderedNodes.some((item) => item.phase === 'entering')
|
||||
useEffect(() => {
|
||||
if (!hasEntering) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
setFadeNodes((previous) =>
|
||||
previous.map((item) => (item.phase === 'entering' ? { ...item, phase: 'present' } : item))
|
||||
)
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [hasEntering, renderedNodes])
|
||||
|
||||
const hasExiting = renderedNodes.some((item) => item.phase === 'exiting')
|
||||
useEffect(() => {
|
||||
if (!hasExiting) return
|
||||
const timeout = window.setTimeout(() => {
|
||||
setFadeNodes((previous) => previous.filter((item) => item.phase !== 'exiting'))
|
||||
}, CLUSTER_FADE_MS)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [hasExiting, renderedNodes])
|
||||
|
||||
return renderedNodes
|
||||
}
|
||||
|
||||
function toPresentFadeNodes(nodes: readonly ClusterNode[]): ClusterFadeNode[] {
|
||||
return nodes.map((node) => ({ node, phase: 'present' }))
|
||||
}
|
||||
|
||||
function reconcileFadeNodes(
|
||||
previous: readonly ClusterFadeNode[],
|
||||
nextNodes: readonly ClusterNode[]
|
||||
): ClusterFadeNode[] {
|
||||
const previousById = new Map(previous.map((item) => [item.node.id, item]))
|
||||
const nextIds = new Set(nextNodes.map((node) => node.id))
|
||||
const next: ClusterFadeNode[] = []
|
||||
|
||||
for (const node of nextNodes) {
|
||||
const previousItem = previousById.get(node.id)
|
||||
// A node that was fading out and came back snaps to present rather than re-entering.
|
||||
next.push({
|
||||
node,
|
||||
phase: !previousItem
|
||||
? 'entering'
|
||||
: previousItem.phase === 'exiting'
|
||||
? 'present'
|
||||
: previousItem.phase,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of previous) {
|
||||
if (nextIds.has(item.node.id)) continue
|
||||
next.push(item.phase === 'exiting' ? item : { ...item, phase: 'exiting' })
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function clusterFadeClassName(phase: ClusterFadePhase): string {
|
||||
return `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`
|
||||
}
|
||||
365
packages/commenting/src/canvas/cluster-input.test.ts
Normal file
365
packages/commenting/src/canvas/cluster-input.test.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { Box, Mat, type Editor, type TLCommentAnchor, type TLCommentThread } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// This import is red until step 6's filter module is implemented — that is
|
||||
// intentional. Implement `cluster-input.ts` per CLUSTERING-STEPS.md step 6
|
||||
// until this suite passes, without modifying this file.
|
||||
import type { LeafInput, LeafScreenOffsets } from '../clustering/types'
|
||||
import {
|
||||
type ClusterInput,
|
||||
clusterInputEqual,
|
||||
clusterInputIdsEqual,
|
||||
collectClusterLeaves,
|
||||
} from './cluster-input'
|
||||
import { defaultCommentingOptions, type CommentingOptions } from './options'
|
||||
|
||||
const CURRENT_PAGE = 'page:one'
|
||||
const OTHER_PAGE = 'page:two'
|
||||
|
||||
/** Minimal thread record for filter tests. */
|
||||
function thread(
|
||||
id: string,
|
||||
anchor: TLCommentAnchor,
|
||||
opts: { pageId?: string; resolved?: boolean } = {}
|
||||
): TLCommentThread {
|
||||
return {
|
||||
id,
|
||||
typeName: 'comment-thread',
|
||||
pageId: opts.pageId ?? CURRENT_PAGE,
|
||||
anchor,
|
||||
createdBy: 'user:1',
|
||||
createdAt: 0,
|
||||
resolved: opts.resolved ? { at: 1, by: 'user:1' } : null,
|
||||
meta: {},
|
||||
} as unknown as TLCommentThread
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub editor: the filter's editor dependencies are the current page id, shape
|
||||
* geometry and page transform, and the commenting options (via anchorPagePoint,
|
||||
* read off the registered comment tool). `shapes` maps shape id → page bounds for
|
||||
* unrotated shapes that exist, modeled as local geometry sized by the box plus a
|
||||
* translate-only page transform; anything else resolves to undefined (deleted
|
||||
* shape). `options` stands in for `CommentTool.configure({ ... })`; omit it for a
|
||||
* editor with no comment tool registered, which falls back to the defaults.
|
||||
*/
|
||||
function stubEditor(
|
||||
shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {},
|
||||
options?: Partial<CommentingOptions>
|
||||
): Editor {
|
||||
const shapeId = (shape: string | { id: string }) => (typeof shape === 'string' ? shape : shape.id)
|
||||
return {
|
||||
getCurrentPageId: () => CURRENT_PAGE,
|
||||
getStateDescendant: () =>
|
||||
options ? { options: { ...defaultCommentingOptions, ...options } } : undefined,
|
||||
getShape: (id: string) => (shapes[id] ? { id } : undefined),
|
||||
getShapeGeometry: (shape: string | { id: string }) => {
|
||||
const bounds = shapes[shapeId(shape)]
|
||||
return { bounds: new Box(0, 0, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) }
|
||||
},
|
||||
getShapePageTransform: (shape: string | { id: string }) => {
|
||||
const bounds = shapes[shapeId(shape)]
|
||||
return bounds ? Mat.Translate(bounds.minX, bounds.minY) : undefined
|
||||
},
|
||||
} as unknown as Editor
|
||||
}
|
||||
|
||||
function leafIds(leaves: { id: string }[]): string[] {
|
||||
return leaves.map((l) => l.id).sort()
|
||||
}
|
||||
|
||||
describe('collectClusterLeaves anchor resolution', () => {
|
||||
it('maps point anchors to their page coordinates', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor(),
|
||||
[thread('t1', { type: 'point', x: 12, y: 34 })],
|
||||
null
|
||||
).leaves
|
||||
expect(leaves).toEqual([{ id: 't1', point: { x: 12, y: 34 } }])
|
||||
})
|
||||
|
||||
it('maps region anchors to their bottom-right corner (x + w, y + h)', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor(),
|
||||
[thread('t1', { type: 'region', x: 10, y: 20, w: 30, h: 40 })],
|
||||
null
|
||||
).leaves
|
||||
expect(leaves).toEqual([{ id: 't1', point: { x: 40, y: 60 } }])
|
||||
})
|
||||
|
||||
it('maps shape anchors to the shape bounds top-right corner', () => {
|
||||
const editor = stubEditor({
|
||||
'shape:a': { minX: 0, minY: 5, maxX: 100, maxY: 50 },
|
||||
})
|
||||
const leaves = collectClusterLeaves(
|
||||
editor,
|
||||
[thread('t1', { type: 'shape', shapeId: 'shape:a' as any, x: 0, y: 0, isPrecise: false })],
|
||||
null
|
||||
).leaves
|
||||
expect(leaves).toEqual([{ id: 't1', point: { x: 100, y: 5 } }])
|
||||
})
|
||||
|
||||
it('places imprecise shape leaves at a custom impreciseShapeAnchor, matching pin rendering', () => {
|
||||
// bottom-center instead of the default top-right
|
||||
const editor = stubEditor(
|
||||
{ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } },
|
||||
{ impreciseShapeAnchor: { x: 0.5, y: 1 } }
|
||||
)
|
||||
const threads = [
|
||||
thread('imprecise', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:a' as any,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPrecise: false,
|
||||
}),
|
||||
thread('precise', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:a' as any,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
isPrecise: true,
|
||||
}),
|
||||
]
|
||||
const leaves = collectClusterLeaves(editor, threads, null).leaves
|
||||
expect(leaves).toEqual([
|
||||
{ id: 'imprecise', point: { x: 50, y: 50 } },
|
||||
{ id: 'precise', point: { x: 50, y: 25 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes shape anchors whose shape no longer resolves', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor({}), // no shapes exist
|
||||
[
|
||||
thread('gone', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:deleted' as any,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPrecise: false,
|
||||
}),
|
||||
thread('kept', { type: 'point', x: 1, y: 2 }),
|
||||
],
|
||||
null
|
||||
).leaves
|
||||
expect(leafIds(leaves)).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('excludes page anchors (no spatial position)', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor(),
|
||||
[thread('pageThread', { type: 'page' }), thread('kept', { type: 'point', x: 1, y: 2 })],
|
||||
null
|
||||
).leaves
|
||||
expect(leafIds(leaves)).toEqual(['kept'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectClusterLeaves filtering', () => {
|
||||
it('excludes threads on other pages', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor(),
|
||||
[
|
||||
thread('here', { type: 'point', x: 0, y: 0 }),
|
||||
thread('elsewhere', { type: 'point', x: 0, y: 0 }, { pageId: OTHER_PAGE }),
|
||||
],
|
||||
null
|
||||
).leaves
|
||||
expect(leafIds(leaves)).toEqual(['here'])
|
||||
})
|
||||
|
||||
it('excludes the open thread, and only the open thread', () => {
|
||||
const threads = [
|
||||
thread('open', { type: 'point', x: 0, y: 0 }),
|
||||
thread('closed', { type: 'point', x: 10, y: 0 }),
|
||||
]
|
||||
expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'open').leaves)).toEqual(['closed'])
|
||||
expect(leafIds(collectClusterLeaves(stubEditor(), threads, null).leaves)).toEqual([
|
||||
'closed',
|
||||
'open',
|
||||
])
|
||||
// an id that matches no thread excludes nothing
|
||||
expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'unknown').leaves)).toEqual([
|
||||
'closed',
|
||||
'open',
|
||||
])
|
||||
})
|
||||
|
||||
it('includes resolved threads (v1 decision — resolve is appearance-only)', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor(),
|
||||
[
|
||||
thread('resolved', { type: 'point', x: 0, y: 0 }, { resolved: true }),
|
||||
thread('unresolved', { type: 'point', x: 10, y: 0 }),
|
||||
],
|
||||
null
|
||||
).leaves
|
||||
expect(leafIds(leaves)).toEqual(['resolved', 'unresolved'])
|
||||
})
|
||||
|
||||
it('returns [] for no threads', () => {
|
||||
expect(collectClusterLeaves(stubEditor(), [], null).leaves).toEqual([])
|
||||
})
|
||||
|
||||
it('applies every rule at once on a mixed set', () => {
|
||||
const editor = stubEditor({
|
||||
'shape:live': { minX: 0, minY: 0, maxX: 10, maxY: 10 },
|
||||
})
|
||||
const threads = [
|
||||
thread('point', { type: 'point', x: 1, y: 2 }),
|
||||
thread('region', { type: 'region', x: 0, y: 0, w: 5, h: 5 }),
|
||||
thread('onShape', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:live' as any,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPrecise: false,
|
||||
}),
|
||||
thread('orphaned', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:gone' as any,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPrecise: false,
|
||||
}),
|
||||
thread('pageLevel', { type: 'page' }),
|
||||
thread('otherPage', { type: 'point', x: 9, y: 9 }, { pageId: OTHER_PAGE }),
|
||||
thread('openOne', { type: 'point', x: 3, y: 3 }),
|
||||
thread('resolvedOne', { type: 'point', x: 4, y: 4 }, { resolved: true }),
|
||||
]
|
||||
const leaves = collectClusterLeaves(editor, threads, 'openOne').leaves
|
||||
expect(leafIds(leaves)).toEqual(['onShape', 'point', 'region', 'resolvedOne'])
|
||||
})
|
||||
|
||||
it('does not mutate the threads array', () => {
|
||||
const threads = [thread('a', { type: 'point', x: 0, y: 0 }), thread('b', { type: 'page' })]
|
||||
const snapshot = JSON.parse(JSON.stringify(threads))
|
||||
collectClusterLeaves(stubEditor(), threads, 'a')
|
||||
expect(JSON.parse(JSON.stringify(threads))).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectClusterLeaves screen offsets', () => {
|
||||
it('is undefined when every pin renders on its anchor', () => {
|
||||
const editor = stubEditor({ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } })
|
||||
const threads = [
|
||||
thread('point', { type: 'point', x: 1, y: 2 }),
|
||||
thread('region', { type: 'region', x: 0, y: 0, w: 5, h: 5 }),
|
||||
thread('precise', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:a' as any,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
isPrecise: true,
|
||||
}),
|
||||
]
|
||||
expect(collectClusterLeaves(editor, threads, null).screenOffsets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps imprecise leaves to their pin inset, and only those', () => {
|
||||
const editor = stubEditor({ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } })
|
||||
const threads = [
|
||||
thread('imprecise', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:a' as any,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPrecise: false,
|
||||
}),
|
||||
thread('point', { type: 'point', x: 1, y: 2 }),
|
||||
]
|
||||
const { screenOffsets } = collectClusterLeaves(editor, threads, null)
|
||||
// Default top-right anchor spot: the pin tucks left and down into the shape.
|
||||
expect(screenOffsets).toEqual(new Map([['imprecise', { x: -20, y: 20 }]]))
|
||||
})
|
||||
|
||||
it('excludes threads that produced no leaf', () => {
|
||||
const editor = stubEditor({}) // shape does not resolve
|
||||
const threads = [
|
||||
thread('gone', { type: 'shape', shapeId: 'shape:x' as any, x: 0, y: 0, isPrecise: false }),
|
||||
]
|
||||
const input = collectClusterLeaves(editor, threads, null)
|
||||
expect(input.leaves).toEqual([])
|
||||
expect(input.screenOffsets).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// These two gate the O(N²) rebuild in `useClusterModel`. Both compare the leaves positionally, so
|
||||
// the order cases below are what pin the gate to `collectClusterLeaves` walking threads in order.
|
||||
const leaf = (id: string, x: number, y: number) => ({ id, point: { x, y } })
|
||||
const input = (leaves: LeafInput[], screenOffsets?: LeafScreenOffsets): ClusterInput => ({
|
||||
leaves,
|
||||
screenOffsets,
|
||||
})
|
||||
|
||||
describe('clusterInputEqual', () => {
|
||||
it('accepts the same leaves at the same positions, whatever the identity', () => {
|
||||
const a = input([leaf('t1', 0, 0), leaf('t2', 10, 20)])
|
||||
expect(clusterInputEqual(a, a)).toBe(true)
|
||||
expect(clusterInputEqual(a, input([leaf('t1', 0, 0), leaf('t2', 10, 20)]))).toBe(true)
|
||||
expect(clusterInputEqual(input([]), input([]))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a position change — a moved pin must rebuild the table', () => {
|
||||
expect(clusterInputEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 0, 0.5)]))).toBe(false)
|
||||
expect(clusterInputEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 0.5, 0)]))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an added, removed, renamed, or reordered leaf', () => {
|
||||
const one = input([leaf('t1', 0, 0)])
|
||||
const two = input([leaf('t1', 0, 0), leaf('t2', 1, 1)])
|
||||
expect(clusterInputEqual(one, two)).toBe(false)
|
||||
expect(clusterInputEqual(two, one)).toBe(false)
|
||||
expect(clusterInputEqual(one, input([leaf('t2', 0, 0)]))).toBe(false)
|
||||
expect(clusterInputEqual(two, input([leaf('t2', 1, 1), leaf('t1', 0, 0)]))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an offset change, which prices the same leaves differently', () => {
|
||||
const leaves = [leaf('t1', 0, 0)]
|
||||
const offsets = (x: number) => new Map([['t1', { x, y: 20 }]])
|
||||
expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves, offsets(-20)))).toBe(true)
|
||||
expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves, offsets(20)))).toBe(false)
|
||||
// gaining or losing offsets entirely (a pin's precision changed)
|
||||
expect(clusterInputEqual(input(leaves), input(leaves, offsets(-20)))).toBe(false)
|
||||
expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clusterInputIdsEqual', () => {
|
||||
it('ignores positions and offset vectors — this is what defers the rebuild mid-drag', () => {
|
||||
expect(clusterInputIdsEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 999, 999)]))).toBe(
|
||||
true
|
||||
)
|
||||
// rotating mid-drag turns the inset; deferred like the anchor move it comes with
|
||||
expect(
|
||||
clusterInputIdsEqual(
|
||||
input([leaf('t1', 0, 0)], new Map([['t1', { x: -20, y: 20 }]])),
|
||||
input([leaf('t1', 5, 5)], new Map([['t1', { x: 20, y: -20 }]]))
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('still rejects an added or removed thread, so those rebuild even mid-drag', () => {
|
||||
const one = input([leaf('t1', 0, 0)])
|
||||
const two = input([leaf('t1', 0, 0), leaf('t2', 1, 1)])
|
||||
expect(clusterInputIdsEqual(one, two)).toBe(false)
|
||||
expect(clusterInputIdsEqual(two, one)).toBe(false)
|
||||
expect(clusterInputIdsEqual(one, input([leaf('t2', 0, 0)]))).toBe(false)
|
||||
})
|
||||
|
||||
it('still rejects a leaf that gained or lost its offset — a precision change, not a gesture', () => {
|
||||
const leaves = [leaf('t1', 0, 0)]
|
||||
expect(
|
||||
clusterInputIdsEqual(input(leaves), input(leaves, new Map([['t1', { x: -20, y: 20 }]])))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a reorder', () => {
|
||||
expect(
|
||||
clusterInputIdsEqual(
|
||||
input([leaf('t1', 0, 0), leaf('t2', 1, 1)]),
|
||||
input([leaf('t2', 1, 1), leaf('t1', 0, 0)])
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
115
packages/commenting/src/canvas/cluster-input.ts
Normal file
115
packages/commenting/src/canvas/cluster-input.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Editor, TLCommentThread, VecLike } from 'tldraw'
|
||||
import type { LeafInput, LeafScreenOffsets } from '../clustering/types'
|
||||
import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
|
||||
|
||||
/** The clustering input for the current page's pinned threads. @internal */
|
||||
export interface ClusterInput {
|
||||
/** One leaf per pinned thread, at its anchor page point. */
|
||||
leaves: LeafInput[]
|
||||
/**
|
||||
* The render offsets of the imprecise pins among the leaves — those draw tucked into their
|
||||
* shape by a constant screen-px inset rather than on their anchor, and the clustering prices
|
||||
* their merges at the visual positions. Undefined when every pin renders on its anchor,
|
||||
* which guarantees a byte-identical offset-unaware clustering run.
|
||||
*/
|
||||
screenOffsets: LeafScreenOffsets | undefined
|
||||
}
|
||||
|
||||
/** The cluster leaves for the threads pinned on the current page. @internal */
|
||||
export function collectClusterLeaves(
|
||||
editor: Editor,
|
||||
threads: readonly TLCommentThread[],
|
||||
openThreadId: string | null
|
||||
): ClusterInput {
|
||||
const pageId = editor.getCurrentPageId()
|
||||
const leaves: LeafInput[] = []
|
||||
let screenOffsets: Map<string, VecLike> | undefined
|
||||
|
||||
for (const thread of threads) {
|
||||
if (thread.id === openThreadId) continue
|
||||
if (thread.pageId !== pageId) continue
|
||||
|
||||
const point = anchorPagePoint(editor, thread.anchor)
|
||||
if (!point) continue
|
||||
|
||||
leaves.push({
|
||||
id: thread.id,
|
||||
point: { x: point.x, y: point.y },
|
||||
})
|
||||
|
||||
const inset = impreciseShapePinInset(editor, thread.anchor)
|
||||
if (inset) (screenOffsets ??= new Map()).set(thread.id, inset)
|
||||
}
|
||||
|
||||
return { leaves, screenOffsets }
|
||||
}
|
||||
|
||||
/**
|
||||
* Value equality over everything `computeClusterTable` reads. Keeps the input's identity stable
|
||||
* across recomputes that changed nothing, so the O(N²) rebuild only runs when it has to.
|
||||
* @internal
|
||||
*/
|
||||
export function clusterInputEqual(a: ClusterInput, b: ClusterInput): boolean {
|
||||
if (a === b) return true
|
||||
return (
|
||||
clusterLeavesEqual(a.leaves, b.leaves) && screenOffsetsEqual(a.screenOffsets, b.screenOffsets)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, ignoring everything positional — anchor points and offset vectors alike. The mid-drag
|
||||
* gate: a move defers, but a thread added, removed, or changed precision still rebuilds.
|
||||
* @internal
|
||||
*/
|
||||
export function clusterInputIdsEqual(a: ClusterInput, b: ClusterInput): boolean {
|
||||
if (a === b) return true
|
||||
return (
|
||||
clusterLeafIdsEqual(a.leaves, b.leaves) &&
|
||||
screenOffsetIdsEqual(a.screenOffsets, b.screenOffsets)
|
||||
)
|
||||
}
|
||||
|
||||
function screenOffsetsEqual(a: LeafScreenOffsets | undefined, b: LeafScreenOffsets | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.size !== b.size) return false
|
||||
for (const [id, offset] of b) {
|
||||
const prev = a.get(id)
|
||||
if (!prev || prev.x !== offset.x || prev.y !== offset.y) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Which leaves carry an offset, ignoring the vectors: a leaf gains or loses one by changing
|
||||
* precision, which is a record change rather than a gesture. */
|
||||
function screenOffsetIdsEqual(a: LeafScreenOffsets | undefined, b: LeafScreenOffsets | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.size !== b.size) return false
|
||||
for (const id of b.keys()) {
|
||||
if (!a.has(id)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether two leaf lists have the same threads at the same positions, in the same order. */
|
||||
function clusterLeavesEqual(a: readonly LeafInput[], b: readonly LeafInput[]): boolean {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const la = a[i]
|
||||
const lb = b[i]
|
||||
if (la.id !== lb.id || la.point.x !== lb.point.x || la.point.y !== lb.point.y) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether two leaf lists contain the same thread ids in the same order, ignoring positions. */
|
||||
function clusterLeafIdsEqual(a: readonly LeafInput[], b: readonly LeafInput[]): boolean {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].id !== b[i].id) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
151
packages/commenting/src/canvas/cluster-model.test.ts
Normal file
151
packages/commenting/src/canvas/cluster-model.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
commentSchemaRecords,
|
||||
createCommentThread,
|
||||
createShapeId,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
PageRecordType,
|
||||
} from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createClusterRuntime } from '../clustering/runtime'
|
||||
import type { ClusterNode, ClusterTable, LeafInput } from '../clustering/types'
|
||||
import type { ClusterInput } from './cluster-input'
|
||||
import { anyFoldedLeafMoved, revealThreadPin, type ClusterModel } from './cluster-model'
|
||||
import { defaultCommentingOptions } from './options'
|
||||
|
||||
function node(ids: string[], x = 0, y = 0): ClusterNode {
|
||||
const members = ids.slice().sort()
|
||||
return {
|
||||
id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
|
||||
centroid: { x, y },
|
||||
count: members.length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
/** t1 and t2 fold into one badge below zoom 1; t3 sits far away and never merges. */
|
||||
const TABLE: ClusterTable = {
|
||||
leaves: [node(['t1'], 0, 0), node(['t2'], 10, 0), node(['t3'], 5000, 0)],
|
||||
events: [
|
||||
{
|
||||
zMerge: 1,
|
||||
zSplit: 2,
|
||||
children: [node(['t1']), node(['t2'])],
|
||||
result: node(['t1', 't2'], 5, 0),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/** The rendered partition at `zoom`: below 1 the badge is up, above 2 every pin stands alone. */
|
||||
function rendered(zoom: number): ClusterModel {
|
||||
const runtime = createClusterRuntime(TABLE)
|
||||
runtime.seed(zoom)
|
||||
return { runtime, table: TABLE }
|
||||
}
|
||||
|
||||
const leaf = (id: string, x: number, y: number): LeafInput => ({ id, point: { x, y } })
|
||||
const input = (leaves: LeafInput[]): ClusterInput => ({ leaves, screenOffsets: undefined })
|
||||
|
||||
const AT_REST = [leaf('t1', 0, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)]
|
||||
|
||||
describe('anyFoldedLeafMoved', () => {
|
||||
it('breaks the freeze when a leaf folded into a badge moves', () => {
|
||||
const model = rendered(0.5)
|
||||
expect([...model.runtime.getVisible().keys()].sort()).toEqual(['cluster:2:t1', 't3'])
|
||||
const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), next, model)).toBe(true)
|
||||
})
|
||||
|
||||
it('holds the freeze when a visible leaf moves — its pin already rides its anchor', () => {
|
||||
const next = input([leaf('t1', 0, 0), leaf('t2', 10, 0), leaf('t3', 5040, 0)])
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), next, rendered(0.5))).toBe(false)
|
||||
// and with the badge split, the same move of a former badge member is visible too
|
||||
const split = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), split, rendered(3))).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the freeze when an orphan moves — it is not in the rendered partition to pop out of', () => {
|
||||
// t4 was added since the last adopted rebuild: it is in the input but not in the rendered
|
||||
// table, so it renders as a plain live pin and no pop-out can ever hold it. Counting it as
|
||||
// folded would rebuild on every pointermove for the rest of the drag.
|
||||
const atRest = [...AT_REST, leaf('t4', 20, 0)]
|
||||
const next = input([...AT_REST, leaf('t4', 60, 0)])
|
||||
expect(anyFoldedLeafMoved(input(atRest), next, rendered(0.5))).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the freeze when a detached leaf moves — it left the badge already', () => {
|
||||
const model = rendered(0.5)
|
||||
model.runtime.detachLeaves(['t1'])
|
||||
expect(
|
||||
anyFoldedLeafMoved(input(AT_REST), input([leaf('t1', 40, 0), ...AT_REST.slice(1)]), model)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the freeze when nothing moved', () => {
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), input([...AT_REST]), rendered(0.5))).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the freeze with no rendered model yet', () => {
|
||||
const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), next, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the freeze on mismatched inputs rather than reading past the end', () => {
|
||||
const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0)])
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), next, rendered(0.5))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revealThreadPin', () => {
|
||||
it('records a cross-page jump as its own undo step', () => {
|
||||
const editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: defaultTools,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
try {
|
||||
const page1Id = editor.getCurrentPageId()
|
||||
const page2Id = PageRecordType.createId()
|
||||
const boxId = createShapeId()
|
||||
|
||||
// Set up the second page outside of history so the only undoable steps are the ones
|
||||
// the test creates: one edit on page 1, then the jump to the pin on page 2.
|
||||
editor.run(() => editor.createPage({ id: page2Id, name: 'Page 2' }), { history: 'ignore' })
|
||||
editor.clearHistory()
|
||||
|
||||
editor.markHistoryStoppingPoint('create box')
|
||||
editor.createShape({ id: boxId, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } })
|
||||
|
||||
const thread = createCommentThread({
|
||||
pageId: page2Id,
|
||||
anchor: { type: 'point', x: 50, y: 50 },
|
||||
createdBy: 'me',
|
||||
})
|
||||
revealThreadPin(
|
||||
editor,
|
||||
thread,
|
||||
{ leaves: [], events: [] },
|
||||
{ minZoom: 0.1, maxZoom: 8 },
|
||||
{ ...defaultCommentingOptions, enableClustering: false },
|
||||
0
|
||||
)
|
||||
expect(editor.getCurrentPageId()).toBe(page2Id)
|
||||
|
||||
// The first undo only takes us back to page 1; the edit made there is still intact.
|
||||
editor.undo()
|
||||
expect(editor.getCurrentPageId()).toBe(page1Id)
|
||||
expect(editor.getShape(boxId)).toBeDefined()
|
||||
|
||||
editor.undo()
|
||||
expect(editor.getShape(boxId)).toBeUndefined()
|
||||
} finally {
|
||||
editor.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
416
packages/commenting/src/canvas/cluster-model.ts
Normal file
416
packages/commenting/src/canvas/cluster-model.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { clamp, Editor, react, TLCommentThread, useValue } from 'tldraw'
|
||||
import { computeClusterTable } from '../clustering/computeClusterTable'
|
||||
import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
|
||||
import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
|
||||
import { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'
|
||||
import {
|
||||
type ClusterInput,
|
||||
clusterInputEqual,
|
||||
clusterInputIdsEqual,
|
||||
collectClusterLeaves,
|
||||
} from './cluster-input'
|
||||
import { type CommentingOptions } from './options'
|
||||
import { openThreadId } from './state'
|
||||
import { anchorPagePoint, commentCenterScreenOffset } from './thread-state'
|
||||
|
||||
/** Duration of the click-a-badge zoom-to-split animation. */
|
||||
export const CLUSTER_EXPAND_ZOOM_MS = 450
|
||||
/** How far past a cluster's split zoom to land when expanding it — a 5% overshoot, so the badge
|
||||
* lands clear of the threshold it just crossed rather than flickering on it. */
|
||||
const CLUSTER_SPLIT_ZOOM_FACTOR = 1.05
|
||||
|
||||
const EMPTY_SET: ReadonlySet<string> = new Set()
|
||||
const MOVED_LEAF_EPSILON = 1e-6
|
||||
|
||||
/** Default select-tool states that move shapes continuously, one store write per pointermove. A
|
||||
* custom tool in their place just never matches, falling back to a rebuild per frame. */
|
||||
const SHAPE_DRAG_STATE_PATHS = [
|
||||
'select.translating',
|
||||
'select.resizing',
|
||||
'select.rotating',
|
||||
'select.dragging_handle',
|
||||
'select.crop.cropping',
|
||||
] as const
|
||||
|
||||
/** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the
|
||||
* gesture starts or settles. */
|
||||
function isShapeDragInProgress(editor: Editor): boolean {
|
||||
return editor.isInAny(...SHAPE_DRAG_STATE_PATHS)
|
||||
}
|
||||
|
||||
/** The clustering table for the current scene, plus the runtime walking its merge events. */
|
||||
export interface ClusterModel {
|
||||
runtime: ClusterRuntime
|
||||
table: ClusterTable
|
||||
}
|
||||
|
||||
export interface ClusterZoomBounds {
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
}
|
||||
|
||||
export interface ClusterModelState {
|
||||
/** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */
|
||||
model: ClusterModel
|
||||
zoomBounds: ClusterZoomBounds
|
||||
/** The displayed nodes, each tagged with its cross-fade phase. */
|
||||
fadeNodes: ClusterFadeNode[]
|
||||
/** Threads in the input that the displayed partition doesn't show — render them as plain pins. */
|
||||
orphanThreads: TLCommentThread[]
|
||||
/** Threads held out of clustering because their anchor moved while folded into a badge. */
|
||||
heldThreads: TLCommentThread[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The clustering state machine behind the comments layer.
|
||||
*
|
||||
* The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is
|
||||
* computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only
|
||||
* changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
|
||||
* (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in
|
||||
* place and nothing else on the canvas moves.
|
||||
*
|
||||
* Threads the displayed partition can't represent are returned separately (`orphanThreads`,
|
||||
* `heldThreads`) for the layer to draw as ordinary pins.
|
||||
*/
|
||||
export function useClusterModel(
|
||||
editor: Editor,
|
||||
threads: readonly TLCommentThread[],
|
||||
openId: string | null
|
||||
): ClusterModelState {
|
||||
// Threads held out of clustering because their anchor moved while folded inside a badge
|
||||
// (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
|
||||
// as live pins riding their anchor and rejoin clustering on the next zoom-out.
|
||||
const [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
|
||||
const adoptOnRebuild = useRef(false)
|
||||
// This input's identity keys the O(N²) table rebuild below, so it's gated on value equality: a
|
||||
// reply, a reaction, a resolve — anything that touches comment records without moving a pin —
|
||||
// returns the previous input and rebuilds nothing.
|
||||
//
|
||||
// Mid-drag the gate ignores positions too — except a folded leaf's, which a badge can't
|
||||
// follow. Its first move passes through, the pop-out below holds the pin out as a live pin,
|
||||
// and the input is static again for the rest of the drag.
|
||||
const clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })
|
||||
const renderedModelRef = useRef<ClusterModel | null>(null)
|
||||
const clusterInput = useValue(
|
||||
'comment cluster leaves',
|
||||
() => {
|
||||
const next = collectClusterLeaves(
|
||||
editor,
|
||||
threads.filter((thread) => !heldThreadIds.has(thread.id)),
|
||||
openThreadId.get(editor)
|
||||
)
|
||||
const prev = clusterInputRef.current
|
||||
if (
|
||||
isShapeDragInProgress(editor) &&
|
||||
clusterInputIdsEqual(prev, next) &&
|
||||
!anyFoldedLeafMoved(prev, next, renderedModelRef.current)
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
if (clusterInputEqual(prev, next)) return prev
|
||||
clusterInputRef.current = next
|
||||
return next
|
||||
},
|
||||
[editor, threads, heldThreadIds]
|
||||
)
|
||||
const clusterZoomBounds = useValue(
|
||||
'comment cluster zoom bounds',
|
||||
() => getClusterZoomBounds(editor),
|
||||
[editor]
|
||||
)
|
||||
const latestModel = useMemo(() => {
|
||||
const table = computeClusterTable(
|
||||
clusterInput.leaves,
|
||||
clusterZoomBounds,
|
||||
clusterInput.screenOffsets
|
||||
)
|
||||
const runtime = createClusterRuntime(table)
|
||||
runtime.seed(editor.getZoomLevel())
|
||||
return { runtime, table }
|
||||
}, [clusterInput, clusterZoomBounds, editor])
|
||||
const [renderedModel, setRenderedModel] = useState(latestModel)
|
||||
let clusterModel = renderedModel
|
||||
// A page switch replaces the whole scene: hard-reset rather than detach the world.
|
||||
const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
|
||||
const pageRef = useRef(pageId)
|
||||
if (pageRef.current !== pageId) {
|
||||
pageRef.current = pageId
|
||||
adoptOnRebuild.current = false
|
||||
latestModel.runtime.seed(editor.getZoomLevel())
|
||||
if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
|
||||
setRenderedModel(latestModel)
|
||||
clusterModel = latestModel
|
||||
}
|
||||
// adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust
|
||||
// it once that pairing is visible here, or an unrelated re-render can land in the gap.
|
||||
const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
|
||||
if (renderedModel !== latestModel && rejoinPending) {
|
||||
adoptOnRebuild.current = false
|
||||
// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
|
||||
// nothing changes state because of the swap alone. Idempotent, so safe during render.
|
||||
latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
|
||||
setRenderedModel(latestModel)
|
||||
clusterModel = latestModel
|
||||
} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
|
||||
// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
|
||||
// survive to force-adopt a later, unrelated rebuild.
|
||||
adoptOnRebuild.current = false
|
||||
}
|
||||
// For the input gate above: folded-vs-visible is judged against what's on screen.
|
||||
renderedModelRef.current = clusterModel
|
||||
// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
|
||||
// is baked into the model), so when its live position drifts from the baked one, hold it out.
|
||||
// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
|
||||
const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
|
||||
if (newlyMovedIds.length > 0) {
|
||||
const next = new Set(heldThreadIds)
|
||||
for (const id of newlyMovedIds) next.add(id)
|
||||
setHeldThreadIds(next)
|
||||
}
|
||||
const latestLeafIds = useMemo(
|
||||
() => new Set(latestModel.table.leaves.map((leaf) => leaf.id)),
|
||||
[latestModel]
|
||||
)
|
||||
// Local partition maintenance — the only non-zoom visual change. Any displayed leaf that has left the
|
||||
// cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel
|
||||
// awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.
|
||||
if (clusterModel !== latestModel) {
|
||||
const removedLeafIds: string[] = []
|
||||
for (const leaf of clusterModel.table.leaves) {
|
||||
if (!latestLeafIds.has(leaf.id)) {
|
||||
removedLeafIds.push(leaf.id)
|
||||
}
|
||||
}
|
||||
// Batched: one patch rebuild and one version bump for the whole set.
|
||||
if (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)
|
||||
}
|
||||
// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
|
||||
// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
|
||||
// never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
|
||||
useEffect(() => {
|
||||
if (heldThreadIds.size === 0) return
|
||||
return reactOnZoomOut(editor, 'rejoin moved comment pins on zoom out', () => {
|
||||
adoptOnRebuild.current = true
|
||||
setHeldThreadIds(EMPTY_SET)
|
||||
})
|
||||
}, [heldThreadIds, editor])
|
||||
// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
|
||||
// a merge, and merging only happens while zooming out. While zooming in, the stale table still
|
||||
// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
|
||||
useEffect(() => {
|
||||
if (clusterModel === latestModel) return
|
||||
return reactOnZoomOut(editor, 'adopt pending cluster model on zoom out', (zoom) => {
|
||||
latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
|
||||
setRenderedModel(latestModel)
|
||||
})
|
||||
}, [clusterModel, latestModel, editor])
|
||||
// Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone
|
||||
// deletions) render as plain pins until the next zoom-out folds them in. Judged against the
|
||||
// displayed partition, so a detached-then-restored leaf reappears.
|
||||
const partitionVersion = clusterModel.runtime.version
|
||||
const orphanThreads = useMemo(() => {
|
||||
if (clusterModel === latestModel) return []
|
||||
const displayed = new Set<string>()
|
||||
for (const node of clusterModel.runtime.getVisible().values()) {
|
||||
for (const member of node.members) displayed.add(member)
|
||||
}
|
||||
return threads.filter((thread) => latestLeafIds.has(thread.id) && !displayed.has(thread.id))
|
||||
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clusterModel, latestModel, latestLeafIds, threads, partitionVersion])
|
||||
const heldThreads = useMemo(
|
||||
() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
|
||||
[threads, heldThreadIds, openId]
|
||||
)
|
||||
// Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster
|
||||
// changes rather than every camera frame. The memo below re-reads the version inline because
|
||||
// render-time detaches bump it after the subscription's computed already evaluated.
|
||||
useValue(
|
||||
'comment cluster version',
|
||||
() => {
|
||||
clusterModel.runtime.onCamera(editor.getZoomLevel())
|
||||
return clusterModel.runtime.version
|
||||
},
|
||||
[clusterModel, editor]
|
||||
)
|
||||
const visibleNodes = useMemo(() => {
|
||||
return Array.from(clusterModel.runtime.getVisible().values())
|
||||
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clusterModel, partitionVersion])
|
||||
const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
|
||||
|
||||
return {
|
||||
model: clusterModel,
|
||||
zoomBounds: clusterZoomBounds,
|
||||
fadeNodes,
|
||||
orphanThreads,
|
||||
heldThreads,
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `onZoomOut` on each zoom change that decreased the zoom, starting from the current level. */
|
||||
function reactOnZoomOut(editor: Editor, name: string, onZoomOut: (zoom: number) => void) {
|
||||
let lastZoom = editor.getZoomLevel()
|
||||
return react(name, () => {
|
||||
const zoom = editor.getZoomLevel()
|
||||
const prevZoom = lastZoom
|
||||
lastZoom = zoom
|
||||
if (zoom < prevZoom) onZoomOut(zoom)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a position-only input change (ids equal, same order) moved a leaf folded inside a badge
|
||||
* of the rendered partition — the one move the mid-drag freeze must let through.
|
||||
*
|
||||
* Folded means a member of a displayed badge, not merely "absent from the displayed partition":
|
||||
* the input also carries orphans (threads the rendered partition has never seen) and detached
|
||||
* leaves, which already ride their anchors as plain pins. Neither is in the rendered table, so the
|
||||
* pop-out below can never hold one — counting them here would break the freeze on every
|
||||
* pointermove for the rest of the drag.
|
||||
* @internal
|
||||
*/
|
||||
export function anyFoldedLeafMoved(
|
||||
prev: ClusterInput,
|
||||
next: ClusterInput,
|
||||
rendered: ClusterModel | null
|
||||
): boolean {
|
||||
if (!rendered) return false
|
||||
if (prev.leaves.length !== next.leaves.length) return false
|
||||
const folded = new Set<string>()
|
||||
for (const node of rendered.runtime.getVisible().values()) {
|
||||
if (node.count < 2) continue
|
||||
for (const member of node.members) folded.add(member)
|
||||
}
|
||||
// No badges on screen, so nothing can be folded — the common case, and the cheap way out of it.
|
||||
if (folded.size === 0) return false
|
||||
for (let i = 0; i < next.leaves.length; i++) {
|
||||
if (!folded.has(next.leaves[i].id)) continue
|
||||
const a = prev.leaves[i].point
|
||||
const b = next.leaves[i].point
|
||||
if (Math.abs(a.x - b.x) > MOVED_LEAF_EPSILON || Math.abs(a.y - b.y) > MOVED_LEAF_EPSILON) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves folded inside a badge whose live anchor no longer matches the position the rendered
|
||||
* model was built with. Visible (unclustered) leaf pins track their anchor live, so they can
|
||||
* stay deferred; a badge can't follow a member, so these must pop out of clustering.
|
||||
*/
|
||||
function findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {
|
||||
if (rendered.table === latest.table) return []
|
||||
const visible = rendered.runtime.getVisible()
|
||||
const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))
|
||||
const moved: string[] = []
|
||||
for (const leaf of rendered.table.leaves) {
|
||||
if (visible.has(leaf.id)) continue
|
||||
const current = latestById.get(leaf.id)
|
||||
if (!current) continue
|
||||
if (
|
||||
Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||
|
||||
Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON
|
||||
) {
|
||||
moved.push(leaf.id)
|
||||
}
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
function getClusterZoomBounds(editor: Editor): ClusterZoomBounds {
|
||||
const cameraOptions = editor.getCameraOptions()
|
||||
const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
|
||||
const zoomSteps = cameraOptions.zoomSteps
|
||||
return {
|
||||
minZoom: zoomSteps[0] * baseZoom,
|
||||
maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split
|
||||
* that unfolds it from its badge (or just centre on it when it isn't clustered).
|
||||
*/
|
||||
export function revealThreadPin(
|
||||
editor: Editor,
|
||||
thread: TLCommentThread,
|
||||
table: ClusterTable,
|
||||
zoomBounds: ClusterZoomBounds,
|
||||
options: CommentingOptions,
|
||||
duration = 200
|
||||
) {
|
||||
if (thread.pageId !== editor.getCurrentPageId()) {
|
||||
editor.markHistoryStoppingPoint('change-page')
|
||||
editor.setCurrentPage(thread.pageId as any)
|
||||
}
|
||||
|
||||
const point = anchorPagePoint(editor, thread.anchor)
|
||||
if (!point) return
|
||||
|
||||
// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
|
||||
// badge never exists) and just center on the pin.
|
||||
if (options.enableClustering) {
|
||||
const parentEvent = findDirectParentEvent(table, thread.id)
|
||||
if (
|
||||
parentEvent &&
|
||||
Number.isFinite(parentEvent.zSplit) &&
|
||||
parentEvent.zSplit <= zoomBounds.maxZoom
|
||||
) {
|
||||
centerOnPointAtZoom(editor, point, splitZoom(parentEvent, zoomBounds), duration)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
|
||||
editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The
|
||||
* event that created a visible cluster is the event that splits it, and has the smallest zSplit of
|
||||
* everything applied inside it — so its zSplit is exactly the first split within those comments.
|
||||
* The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.
|
||||
*/
|
||||
export function zoomToClusterSplit(
|
||||
editor: Editor,
|
||||
table: ClusterTable,
|
||||
zoomBounds: ClusterZoomBounds,
|
||||
node: ClusterNode
|
||||
) {
|
||||
const event = table.events.find((e) => e.result.id === node.id)
|
||||
if (!event || !Number.isFinite(event.zSplit)) return
|
||||
centerOnPointAtZoom(editor, node.centroid, splitZoom(event, zoomBounds), CLUSTER_EXPAND_ZOOM_MS)
|
||||
}
|
||||
|
||||
/** The zoom to land on to see a merge event's cluster just after it splits. */
|
||||
function splitZoom(event: MergeEvent, zoomBounds: ClusterZoomBounds): number {
|
||||
return clamp(event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR, zoomBounds.minZoom, zoomBounds.maxZoom)
|
||||
}
|
||||
|
||||
function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
|
||||
return table.events.find((event) => event.children.some((child) => child.id === threadId))
|
||||
}
|
||||
|
||||
function centerOnPointAtZoom(
|
||||
editor: Editor,
|
||||
point: { x: number; y: number },
|
||||
zoom: number,
|
||||
duration = 200
|
||||
) {
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.
|
||||
const offset = commentCenterScreenOffset(editor)
|
||||
editor.setCamera(
|
||||
{
|
||||
x: (viewport.w / 2 - offset) / zoom - point.x,
|
||||
y: viewport.h / (2 * zoom) - point.y,
|
||||
z: zoom,
|
||||
},
|
||||
{ animation: { duration } }
|
||||
)
|
||||
}
|
||||
21
packages/commenting/src/canvas/comment-body.tsx
Normal file
21
packages/commenting/src/canvas/comment-body.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from 'react'
|
||||
import { TLRichText } from 'tldraw'
|
||||
import { renderCommentHtml } from './comment-render'
|
||||
|
||||
/** @public */
|
||||
export interface CommentBodyProps {
|
||||
richText: TLRichText
|
||||
/** Maps a member id to its current display name, so \@mentions show the live name. */
|
||||
resolveName?(id: string): string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment's rich-text body read-only through the limited comment extension set (no
|
||||
* headings), so formatting (bold, links, lists, highlight) is preserved rather than flattened, and
|
||||
* headings can never render. Use this as the `body` of a `CommentCard` on a canvas.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentBody({ richText, resolveName }: CommentBodyProps) {
|
||||
const html = useMemo(() => renderCommentHtml(richText, resolveName), [richText, resolveName])
|
||||
return <div className="tlui-cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
55
packages/commenting/src/canvas/comment-drafts.test.ts
Normal file
55
packages/commenting/src/canvas/comment-drafts.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/* eslint-disable tldraw/no-direct-storage */
|
||||
import type { TLRichText } from 'tldraw'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearCommentDraft,
|
||||
getCommentDraft,
|
||||
NEW_COMMENT_DRAFT,
|
||||
replyDraftSlot,
|
||||
saveCommentDraft,
|
||||
} from './comment-drafts'
|
||||
|
||||
function richText(text: string): TLRichText {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||
} as unknown as TLRichText
|
||||
}
|
||||
|
||||
const EMPTY = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as TLRichText
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('comment drafts', () => {
|
||||
it('round-trips a draft through its slot', () => {
|
||||
saveCommentDraft(NEW_COMMENT_DRAFT, richText('hello'))
|
||||
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toEqual(richText('hello'))
|
||||
})
|
||||
|
||||
it('keeps reply drafts apart by thread id', () => {
|
||||
saveCommentDraft(replyDraftSlot('comment-thread:a'), richText('for a'))
|
||||
saveCommentDraft(replyDraftSlot('comment-thread:b'), richText('for b'))
|
||||
expect(getCommentDraft(replyDraftSlot('comment-thread:a'))).toEqual(richText('for a'))
|
||||
expect(getCommentDraft(replyDraftSlot('comment-thread:b'))).toEqual(richText('for b'))
|
||||
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('saving empty content clears the slot instead', () => {
|
||||
saveCommentDraft(NEW_COMMENT_DRAFT, richText('typed then deleted'))
|
||||
saveCommentDraft(NEW_COMMENT_DRAFT, EMPTY)
|
||||
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears a slot explicitly', () => {
|
||||
saveCommentDraft(NEW_COMMENT_DRAFT, richText('posted'))
|
||||
clearCommentDraft(NEW_COMMENT_DRAFT)
|
||||
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for a corrupt stored value', () => {
|
||||
localStorage.setItem('tldraw-comment-draft:new', '{not json')
|
||||
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
46
packages/commenting/src/canvas/comment-drafts.ts
Normal file
46
packages/commenting/src/canvas/comment-drafts.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { deleteFromLocalStorage, getFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
|
||||
import type { TLRichText } from 'tldraw'
|
||||
import { isCommentEmpty } from '../ui/comment-extensions'
|
||||
|
||||
const DRAFT_KEY_PREFIX = 'tldraw-comment-draft'
|
||||
|
||||
/** The single slot for an unsent new-comment (placement composer) draft. The anchor is re-picked
|
||||
* by clicking when the composer reopens, so only the text is worth keeping — and without a
|
||||
* universal document id in the SDK, one slot is the honest scope. */
|
||||
export const NEW_COMMENT_DRAFT = 'new'
|
||||
|
||||
/** The draft slot for an unsent reply on a thread. Thread record ids are globally unique and
|
||||
* stable across reloads, so replies never need a document key. @public */
|
||||
export function replyDraftSlot(threadId: string): string {
|
||||
return `reply:${threadId}`
|
||||
}
|
||||
|
||||
/** Read a saved unsent draft, or undefined when there is none (or it fails to parse). @public */
|
||||
export function getCommentDraft(slot: string): TLRichText | undefined {
|
||||
const raw = getFromLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`)
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
return JSON.parse(raw) as TLRichText
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an unsent draft so click-away and reload don't lose it — the flip side of not warning
|
||||
* before discard. Saving empty content clears the slot instead, so deleting your text and
|
||||
* closing doesn't resurrect an empty draft later.
|
||||
* @public
|
||||
*/
|
||||
export function saveCommentDraft(slot: string, value: TLRichText) {
|
||||
if (isCommentEmpty(value)) {
|
||||
clearCommentDraft(slot)
|
||||
return
|
||||
}
|
||||
setInLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`, JSON.stringify(value))
|
||||
}
|
||||
|
||||
/** Remove a saved draft (after posting, or when its content was emptied). @public */
|
||||
export function clearCommentDraft(slot: string) {
|
||||
deleteFromLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`)
|
||||
}
|
||||
452
packages/commenting/src/canvas/comment-mutations.test.ts
Normal file
452
packages/commenting/src/canvas/comment-mutations.test.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import {
|
||||
commentSchemaRecords,
|
||||
createCommentThread,
|
||||
createShapeId,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
TLComment,
|
||||
TLCommentThread,
|
||||
toRichText,
|
||||
createComment as tlCreateComment,
|
||||
} from 'tldraw'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
commitCommentMutation,
|
||||
deleteComment,
|
||||
deleteThread,
|
||||
editComment,
|
||||
putCommentRecords,
|
||||
removeCommentRecords,
|
||||
reopenThread,
|
||||
resolveThread,
|
||||
} from './comment-mutations'
|
||||
import {
|
||||
getCommentRecord,
|
||||
getComments,
|
||||
getCommentThreads,
|
||||
getLiveComments,
|
||||
getLiveCommentThreads,
|
||||
} from './comment-store'
|
||||
import { CommentTool } from './comment-tool'
|
||||
import { openThreadId } from './state'
|
||||
|
||||
/**
|
||||
* These need a real editor: what's under test is how each write interacts with the store and the
|
||||
* undo stack, which is exactly what the history option governs.
|
||||
*/
|
||||
|
||||
let editors: Editor[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const editor of editors) editor.dispose()
|
||||
editors = []
|
||||
})
|
||||
|
||||
function makeEditor(tool?: ReturnType<typeof CommentTool.configure>): Editor {
|
||||
const editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: tool ? [...defaultTools, tool] : defaultTools,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
editors.push(editor)
|
||||
return editor
|
||||
}
|
||||
|
||||
function makeThread(editor: Editor, createdBy = 'me') {
|
||||
const thread = createCommentThread({
|
||||
pageId: editor.getCurrentPageId(),
|
||||
anchor: { type: 'point', x: 0, y: 0 },
|
||||
createdBy,
|
||||
})
|
||||
const comment = tlCreateComment({
|
||||
threadId: thread.id,
|
||||
pageId: thread.pageId,
|
||||
authorId: createdBy,
|
||||
body: toRichText('hello'),
|
||||
})
|
||||
putCommentRecords(editor, [thread, comment])
|
||||
return { thread, comment }
|
||||
}
|
||||
|
||||
function addComment(editor: Editor, thread: TLCommentThread, authorId = 'me') {
|
||||
const comment = tlCreateComment({
|
||||
threadId: thread.id,
|
||||
pageId: thread.pageId,
|
||||
authorId,
|
||||
body: toRichText('reply'),
|
||||
})
|
||||
putCommentRecords(editor, [comment])
|
||||
return comment
|
||||
}
|
||||
|
||||
function readComment(editor: Editor, comment: TLComment) {
|
||||
return getCommentRecord(editor, comment.id) as TLComment | undefined
|
||||
}
|
||||
|
||||
function readThread(editor: Editor, thread: TLCommentThread) {
|
||||
return getCommentRecord(editor, thread.id) as TLCommentThread | undefined
|
||||
}
|
||||
|
||||
describe('editComment', () => {
|
||||
it('replaces the body and stamps editedAt', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
expect(comment.editedAt).toBe(null)
|
||||
|
||||
editComment(editor, comment, toRichText('changed'))
|
||||
|
||||
const updated = readComment(editor, comment)!
|
||||
expect(updated.body).toEqual(toRichText('changed'))
|
||||
expect(typeof updated.editedAt).toBe('number')
|
||||
})
|
||||
|
||||
// The caller's record is a snapshot. Writing it back would revert every field it doesn't mean
|
||||
// to touch — here `meta`, set after the caller took its copy.
|
||||
it('edits the version in the store, not the copy it was handed', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
putCommentRecords(editor, [{ ...comment, meta: { pinned: true } }])
|
||||
|
||||
editComment(editor, comment, toRichText('changed'))
|
||||
|
||||
expect(readComment(editor, comment)).toMatchObject({
|
||||
body: toRichText('changed'),
|
||||
meta: { pinned: true },
|
||||
})
|
||||
})
|
||||
|
||||
// `put` is an upsert, so writing a snapshot back would re-create the record — with
|
||||
// `isDeleted: false` — after someone else's delete had already taken it away.
|
||||
it('does not resurrect a comment that has been removed since', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
removeCommentRecords(editor, [comment.id])
|
||||
|
||||
editComment(editor, comment, toRichText('changed'))
|
||||
|
||||
expect(readComment(editor, comment)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveThread and reopenThread', () => {
|
||||
it('stamps who resolved the thread and when', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
|
||||
resolveThread(editor, thread, 'ada')
|
||||
|
||||
const resolved = readThread(editor, thread)!.resolved!
|
||||
expect(resolved.by).toBe('ada')
|
||||
expect(typeof resolved.at).toBe('number')
|
||||
})
|
||||
|
||||
it('clears the resolution when reopened', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
resolveThread(editor, thread, 'ada')
|
||||
|
||||
reopenThread(editor, readThread(editor, thread)!)
|
||||
|
||||
expect(readThread(editor, thread)!.resolved).toBe(null)
|
||||
})
|
||||
|
||||
// A thread's anchor moves without anyone touching the thread — a pin drag, or a pinned shape's
|
||||
// delete converting it to a point. Resolving from a copy taken before that must not undo it.
|
||||
it('resolves the version in the store, leaving an anchor moved since where it is', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
putCommentRecords(editor, [{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }])
|
||||
|
||||
resolveThread(editor, thread, 'ada')
|
||||
|
||||
const updated = readThread(editor, thread)!
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 50, y: 50 })
|
||||
expect(updated.resolved!.by).toBe('ada')
|
||||
})
|
||||
|
||||
it('reopens the version in the store, leaving an anchor moved since where it is', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
resolveThread(editor, thread, 'ada')
|
||||
putCommentRecords(editor, [
|
||||
{ ...readThread(editor, thread)!, anchor: { type: 'point', x: 50, y: 50 } },
|
||||
])
|
||||
|
||||
reopenThread(editor, thread)
|
||||
|
||||
const updated = readThread(editor, thread)!
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 50, y: 50 })
|
||||
expect(updated.resolved).toBe(null)
|
||||
})
|
||||
|
||||
it('does nothing to a thread that has been removed since', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
removeCommentRecords(editor, [thread.id])
|
||||
|
||||
resolveThread(editor, thread, 'ada')
|
||||
reopenThread(editor, thread)
|
||||
|
||||
expect(readThread(editor, thread)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteComment', () => {
|
||||
it('soft-deletes: the record stays put with its flag set, for the server to prune', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(readComment(editor, comment)).toMatchObject({ isDeleted: true })
|
||||
expect(getComments(editor)).toHaveLength(1)
|
||||
expect(getLiveComments(editor)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('closes the thread when its last comment goes', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
openThreadId.set(editor, thread.id)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(openThreadId.get(editor)).toBe(null)
|
||||
})
|
||||
|
||||
it('leaves the thread open while it still has comments', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
addComment(editor, thread)
|
||||
openThreadId.set(editor, thread.id)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(openThreadId.get(editor)).toBe(thread.id)
|
||||
})
|
||||
|
||||
// Deleting the same comment twice — a double activation, a handler holding a copy taken before
|
||||
// the first delete — shouldn't read as "the last comment went" while a sibling is still live.
|
||||
it('leaves the thread open when the comment was already deleted', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
addComment(editor, thread)
|
||||
deleteComment(editor, comment)
|
||||
openThreadId.set(editor, thread.id)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(openThreadId.get(editor)).toBe(thread.id)
|
||||
})
|
||||
|
||||
it('does not resurrect a comment that has been removed since', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
removeCommentRecords(editor, [comment.id])
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(readComment(editor, comment)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a different open thread alone', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
const other = makeThread(editor)
|
||||
openThreadId.set(editor, other.thread.id)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(openThreadId.get(editor)).toBe(other.thread.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteThread', () => {
|
||||
it('soft-deletes the thread record', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
|
||||
deleteThread(editor, thread)
|
||||
|
||||
expect(readThread(editor, thread)).toMatchObject({ isDeleted: true })
|
||||
expect(getCommentThreads(editor)).toHaveLength(1)
|
||||
expect(getLiveCommentThreads(editor)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('closes the thread when it is the open one', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
openThreadId.set(editor, thread.id)
|
||||
|
||||
deleteThread(editor, thread)
|
||||
|
||||
expect(openThreadId.get(editor)).toBe(null)
|
||||
})
|
||||
|
||||
it('flags the version in the store, leaving an anchor moved since where it is', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
putCommentRecords(editor, [{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }])
|
||||
|
||||
deleteThread(editor, thread)
|
||||
|
||||
expect(readThread(editor, thread)).toMatchObject({
|
||||
isDeleted: true,
|
||||
anchor: { type: 'point', x: 50, y: 50 },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not resurrect a thread that has been removed since', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
removeCommentRecords(editor, [thread.id])
|
||||
|
||||
deleteThread(editor, thread)
|
||||
|
||||
expect(readThread(editor, thread)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('history', () => {
|
||||
it('does not put comment writes on the undo stack by default', () => {
|
||||
const editor = makeEditor()
|
||||
editor.markHistoryStoppingPoint()
|
||||
const { comment } = makeThread(editor)
|
||||
|
||||
editor.undo()
|
||||
|
||||
expect(readComment(editor, comment)).toBeDefined()
|
||||
})
|
||||
|
||||
it('puts them on the undo stack when the history option asks for it', () => {
|
||||
const editor = makeEditor(CommentTool.configure({ history: 'record' }))
|
||||
editor.markHistoryStoppingPoint()
|
||||
const { comment } = makeThread(editor)
|
||||
|
||||
editor.undo()
|
||||
|
||||
expect(readComment(editor, comment)).toBeUndefined()
|
||||
})
|
||||
|
||||
// The flag is write-once server-side, so an undo clearing it would be vetoed and rebased —
|
||||
// leaving the UI showing a comment the server has already pruned. The shape gives the undo
|
||||
// something recorded to rewind, so what's under test is that the delete isn't on the stack
|
||||
// rather than that the stack is empty.
|
||||
it('never makes a delete undoable, even with history: record', () => {
|
||||
const editor = makeEditor(CommentTool.configure({ history: 'record' }))
|
||||
const { comment } = makeThread(editor)
|
||||
editor.markHistoryStoppingPoint()
|
||||
const shapeId = createShapeId()
|
||||
editor.createShape({ id: shapeId, type: 'geo', x: 0, y: 0 })
|
||||
|
||||
deleteComment(editor, comment)
|
||||
editor.undo()
|
||||
|
||||
expect(editor.getShape(shapeId)).toBeUndefined()
|
||||
expect(readComment(editor, comment)).toMatchObject({ isDeleted: true })
|
||||
})
|
||||
|
||||
it('never makes a thread delete undoable, even with history: record', () => {
|
||||
const editor = makeEditor(CommentTool.configure({ history: 'record' }))
|
||||
const { thread } = makeThread(editor)
|
||||
editor.markHistoryStoppingPoint()
|
||||
const shapeId = createShapeId()
|
||||
editor.createShape({ id: shapeId, type: 'geo', x: 0, y: 0 })
|
||||
|
||||
deleteThread(editor, thread)
|
||||
editor.undo()
|
||||
|
||||
expect(editor.getShape(shapeId)).toBeUndefined()
|
||||
expect(readThread(editor, thread)).toMatchObject({ isDeleted: true })
|
||||
})
|
||||
|
||||
it('rejects a nested mutation that resolves to a different history mode', () => {
|
||||
const editor = makeEditor(CommentTool.configure({ history: 'record' }))
|
||||
const { comment } = makeThread(editor)
|
||||
|
||||
// A delete always ignores history, so it can't run inside a `record` commit.
|
||||
expect(() => commitCommentMutation(editor, () => deleteComment(editor, comment))).toThrow(
|
||||
"records history as 'ignore' can't run inside one recording it as 'record'"
|
||||
)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
expect(readComment(editor, comment)).toMatchObject({ isDeleted: true })
|
||||
})
|
||||
|
||||
// Store history flushes synchronously under test, so this listener writes from inside the commit
|
||||
// that triggered it — where a side effect always sits, with no "after the mutation" to defer to.
|
||||
// Matching modes have nothing to disagree about, so its write goes through.
|
||||
it('lets a store listener write comments during a commit when the modes match', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
let hasReacted = false
|
||||
editor.store.listen(() => {
|
||||
if (hasReacted) return
|
||||
hasReacted = true
|
||||
putCommentRecords(editor, [{ ...thread, meta: { lastEditedComment: comment.id } }])
|
||||
})
|
||||
|
||||
editComment(editor, comment, toRichText('edited'))
|
||||
|
||||
expect(readThread(editor, thread)!.meta).toEqual({ lastEditedComment: comment.id })
|
||||
})
|
||||
|
||||
it('rejects a writer used after its commit', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
let writeAfterCommit: () => void
|
||||
|
||||
commitCommentMutation(editor, ({ put }) => {
|
||||
writeAfterCommit = () => put([{ ...thread, resolved: { at: 1, by: 'ada' } }])
|
||||
})
|
||||
|
||||
expect(() => writeAfterCommit!()).toThrow('cannot be used after its commit has finished')
|
||||
})
|
||||
|
||||
// A host can want pin drags undoable while posts and edits aren't. The drag owns its commit, and
|
||||
// its records go through the writer, which keeps `dragHistory` in charge of them.
|
||||
it('lets dragHistory govern a drag on its own', () => {
|
||||
const editor = makeEditor(CommentTool.configure({ history: 'ignore', dragHistory: 'record' }))
|
||||
const { thread } = makeThread(editor)
|
||||
editor.markHistoryStoppingPoint()
|
||||
|
||||
commitCommentMutation(
|
||||
editor,
|
||||
({ put }) => put([{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }]),
|
||||
'drag'
|
||||
)
|
||||
editor.undo()
|
||||
|
||||
expect(readThread(editor, thread)!.anchor).toEqual({ type: 'point', x: 0, y: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLiveComments and getLiveCommentThreads', () => {
|
||||
it('hides a thread whose comments have all been deleted', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
// The thread record itself is untouched — the deleter may not be its creator — but with no
|
||||
// live comment left it has no surface.
|
||||
expect(readThread(editor, thread)!.isDeleted).toBe(false)
|
||||
expect(getLiveCommentThreads(editor)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps a thread that still has a live comment', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
addComment(editor, thread)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(getLiveComments(editor)).toHaveLength(1)
|
||||
expect(getLiveCommentThreads(editor)).toEqual([readThread(editor, thread)])
|
||||
})
|
||||
})
|
||||
299
packages/commenting/src/canvas/comment-mutations.ts
Normal file
299
packages/commenting/src/canvas/comment-mutations.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
Editor,
|
||||
TLComment,
|
||||
TLCommentId,
|
||||
TLCommentReactionId,
|
||||
TLCommentThread,
|
||||
TLCommentThreadId,
|
||||
TLHistoryBatchOptions,
|
||||
TLRecord,
|
||||
TLRichText,
|
||||
} from 'tldraw'
|
||||
import { getCommentRecord, getLiveComments, type TLCommentRecord } from './comment-store'
|
||||
import { getCommentingOptions, type CommentingOptions } from './options'
|
||||
import { openThreadId } from './state'
|
||||
|
||||
/**
|
||||
* Every write to a comment record, and the undo/redo policy governing them.
|
||||
*
|
||||
* The file layers bottom-up: {@link commitCommentMutation} resolves the history mode and hands its
|
||||
* callback a writer, {@link putCommentRecords} and {@link removeCommentRecords} are the typed
|
||||
* writes that run under it, and the verbs below are those writes plus the one rule each carries.
|
||||
* Every verb takes the record it acts on as the identity of what to change, not the value to write
|
||||
* back — see {@link readLatest}.
|
||||
*
|
||||
* Posting carries no such rule, so it isn't a verb here: build the records with
|
||||
* `createCommentThread`/`createComment` and write them with {@link putCommentRecords}.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which history policy a comment write follows:
|
||||
*
|
||||
* - `mutation` — {@link CommentingOptions.history}: posts, replies, edits, resolves.
|
||||
* - `drag` — {@link CommentingOptions.dragHistory}, falling back to `history`: pin and region
|
||||
* re-anchors, which are spatial edits a host may reasonably want undoable alongside a shape move.
|
||||
* - `delete` — always `'ignore'`, whatever the options say. A soft-delete flag is write-once
|
||||
* server-side, so an undo clearing it would be vetoed and rebased rather than restore anything.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type CommentMutationKind = 'delete' | 'drag' | 'mutation'
|
||||
|
||||
interface CommentMutationWriter {
|
||||
put(records: TLCommentRecord[]): void
|
||||
remove(ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]): void
|
||||
}
|
||||
|
||||
const activeCommentMutations = new WeakMap<Editor, { history: TLHistoryBatchOptions['history'] }>()
|
||||
|
||||
/** The undo/redo mode a write of the given kind runs under. See {@link CommentMutationKind}. */
|
||||
function historyModeFor(
|
||||
options: CommentingOptions,
|
||||
kind: CommentMutationKind
|
||||
): TLHistoryBatchOptions['history'] {
|
||||
switch (kind) {
|
||||
case 'delete':
|
||||
return 'ignore'
|
||||
case 'drag':
|
||||
return options.dragHistory ?? options.history
|
||||
case 'mutation':
|
||||
return options.history
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a comment mutation with the configured undo/redo behavior, so the
|
||||
* {@link CommentingOptions.history} option governs whether it lands on the undo stack. Defaults to
|
||||
* `'ignore'`. See {@link CommentMutationKind} for what each kind resolves to.
|
||||
*
|
||||
* `editor.run`'s history option isn't additive — a nested run overwrites the enclosing mode — so
|
||||
* constituent records go through the callback's writer instead of opening a commit of their own,
|
||||
* which would quietly make a `drag` write non-undoable.
|
||||
*
|
||||
* Commits that nest are only a problem when they resolve to different modes, and then neither is
|
||||
* the right one to keep, so it throws. Matching modes have to nest: a store side effect runs inside
|
||||
* the write that triggered it, with no "after the commit" to defer to. (A `store.listen` handler
|
||||
* normally flushes on a later frame, so its writes open a commit of their own, but a synchronous
|
||||
* flush — as under test — lands it inside too.)
|
||||
* @internal
|
||||
*/
|
||||
export function commitCommentMutation<T>(
|
||||
editor: Editor,
|
||||
fn: (writer: CommentMutationWriter) => T,
|
||||
kind: CommentMutationKind = 'mutation'
|
||||
): T {
|
||||
const history = historyModeFor(getCommentingOptions(editor), kind)
|
||||
const enclosing = activeCommentMutations.get(editor)
|
||||
if (enclosing && enclosing.history !== history) {
|
||||
throw new Error(
|
||||
`A comment mutation that records history as '${history}' can't run inside one recording it as '${enclosing.history}': one of the two modes would be silently discarded. Use the provided writer for constituent records, or run this operation after the enclosing one has committed.`
|
||||
)
|
||||
}
|
||||
|
||||
activeCommentMutations.set(editor, { history })
|
||||
try {
|
||||
let result: T
|
||||
editor.run(
|
||||
() => {
|
||||
let isWriterActive = true
|
||||
const assertWriterActive = () => {
|
||||
if (!isWriterActive) {
|
||||
throw new Error(
|
||||
'A comment mutation writer cannot be used after its commit has finished.'
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
result = fn({
|
||||
put: (records) => {
|
||||
assertWriterActive()
|
||||
editor.store.put(records as unknown as TLRecord[])
|
||||
},
|
||||
remove: (ids) => {
|
||||
assertWriterActive()
|
||||
editor.store.remove(ids as unknown as TLRecord['id'][])
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
isWriterActive = false
|
||||
}
|
||||
},
|
||||
{ history }
|
||||
)
|
||||
return result!
|
||||
} finally {
|
||||
if (enclosing) {
|
||||
activeCommentMutations.set(editor, enclosing)
|
||||
} else {
|
||||
activeCommentMutations.delete(editor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write comment records to the store, under the configured {@link CommentingOptions.history}
|
||||
* behavior. Defaults to `'ignore'`.
|
||||
*
|
||||
* Use it to seed or import threads, and to save an edit. To delete, prefer {@link deleteComment}
|
||||
* and {@link deleteThread}: comments are soft-deleted, and a synced server rejects the hard delete.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function putCommentRecords(editor: Editor, records: TLCommentRecord[]): void {
|
||||
commitCommentMutation(editor, ({ put }) => put(records))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove comment records from the store by id, under the configured
|
||||
* {@link CommentingOptions.history} behavior.
|
||||
*
|
||||
* This is a hard delete, which is rarely what you want: the built-in UI soft-deletes
|
||||
* ({@link deleteComment}, {@link deleteThread}) so the server can prune the records, and a server
|
||||
* enforcing per-record permissions vetoes a hard delete outright. Reach for this on a local,
|
||||
* unsynced store, or to drop a reaction (see {@link toggleCommentReaction}).
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function removeCommentRecords(
|
||||
editor: Editor,
|
||||
ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]
|
||||
): void {
|
||||
commitCommentMutation(editor, ({ remove }) => remove(ids))
|
||||
}
|
||||
|
||||
/**
|
||||
* The record as the store currently holds it, or `undefined` if it isn't there any more.
|
||||
*
|
||||
* A verb is handed a record, but that record is a snapshot and comment records move underneath it:
|
||||
* deleting a pinned shape converts the anchor to a point, reparenting rehomes the thread, a drag
|
||||
* re-anchors it. Writing the caller's snapshot back would revert those fields for everyone. `put`
|
||||
* is also an upsert, so a record a remote delete already removed would come back.
|
||||
*
|
||||
* So a verb reads what it's changing rather than trusting what it was given, and a record that's
|
||||
* gone is a no-op.
|
||||
*/
|
||||
function readLatest<T extends TLComment | TLCommentThread>(
|
||||
editor: Editor,
|
||||
record: T
|
||||
): T | undefined {
|
||||
const current = getCommentRecord(editor, record.id)
|
||||
// Record ids carry their type, so a matching `typeName` means a record of exactly T.
|
||||
return current?.typeName === record.typeName ? (current as T) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a comment's body and stamp it as edited, which renders the "(edited)" marker on its
|
||||
* byline. Editing is the author's to do by default ({@link CommentingOptions.canModifyComment}),
|
||||
* and a server enforcing per-record permissions rejects anyone else's. Widening one end without the
|
||||
* other leaves an edit that's offered and then rejected, so widen both.
|
||||
*
|
||||
* The body lands on the version the store currently holds, so a stale copy can't revert a later
|
||||
* change or re-create a removed comment — editing one of those does nothing.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* editComment(editor, comment, toRichText('Actually, make it dashed'))
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function editComment(editor: Editor, comment: TLComment, body: TLRichText): void {
|
||||
commitCommentMutation(editor, ({ put }) => {
|
||||
const current = readLatest(editor, comment)
|
||||
if (!current) return
|
||||
put([{ ...current, body, editedAt: Date.now() }])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a thread resolved, stamping who resolved it and when. Resolved threads keep their pin (a
|
||||
* checked one) and are hidden from the sidebar until its "show resolved" filter is on.
|
||||
*
|
||||
* Only the resolution is written — the rest of the thread is read fresh, so a stale copy can't drag
|
||||
* a pin back. A no-op on a thread that's gone.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function resolveThread(editor: Editor, thread: TLCommentThread, userId: string): void {
|
||||
commitCommentMutation(editor, ({ put }) => {
|
||||
const current = readLatest(editor, thread)
|
||||
if (!current) return
|
||||
put([{ ...current, resolved: { at: Date.now(), by: userId } }])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen a resolved thread, clearing the resolution. A no-op on a thread that isn't resolved, and
|
||||
* on one that's gone. Like {@link resolveThread}, it touches only the resolution.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function reopenThread(editor: Editor, thread: TLCommentThread): void {
|
||||
commitCommentMutation(editor, ({ put }) => {
|
||||
const current = readLatest(editor, thread)
|
||||
if (!current) return
|
||||
put([{ ...current, resolved: null }])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment.
|
||||
*
|
||||
* This is a soft delete: it sets `isDeleted` rather than removing the record, and the server prunes
|
||||
* the comment and its reactions once the flag is persisted — so no client removes records it
|
||||
* doesn't own, and a server enforcing per-record permissions has a write it can check.
|
||||
*
|
||||
* Deleting is the author's to do by default; {@link CommentingOptions.canModifyComment} widens
|
||||
* that, as does its counterpart on the server.
|
||||
*
|
||||
* Never undoable, whatever {@link CommentingOptions.history} says: the flag is write-once
|
||||
* server-side, so an undo clearing it would be vetoed rather than bring the comment back.
|
||||
*
|
||||
* Deleting a thread's last comment closes it and leaves the thread record for the server to prune,
|
||||
* since the deleter may not be its creator. An already-deleted comment is a no-op.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function deleteComment(editor: Editor, comment: TLComment): void {
|
||||
commitCommentMutation(
|
||||
editor,
|
||||
({ put }) => {
|
||||
const current = readLatest(editor, comment)
|
||||
// Deleting twice is nothing to do rather than something to redo. The check below counts this
|
||||
// comment among the live ones, so it only reads as "the last one" while this delete takes it away.
|
||||
if (!current || current.isDeleted) return
|
||||
const isLastInThread =
|
||||
getLiveComments(editor).filter((c) => c.threadId === current.threadId).length <= 1
|
||||
if (isLastInThread && openThreadId.get(editor) === current.threadId) {
|
||||
openThreadId.set(editor, null)
|
||||
}
|
||||
put([{ ...current, isDeleted: true }])
|
||||
},
|
||||
'delete'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a thread and, with it, the whole conversation.
|
||||
*
|
||||
* A soft delete on the same model as {@link deleteComment}: the server prunes the thread, its
|
||||
* comments, and their reactions once the flag is persisted. Deleting a thread is its creator's to
|
||||
* do by default ({@link CommentingOptions.canModifyComment}), and the write is never undoable.
|
||||
* Closes the thread if it's the open one; a pruned thread is a no-op.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function deleteThread(editor: Editor, thread: TLCommentThread): void {
|
||||
commitCommentMutation(
|
||||
editor,
|
||||
({ put }) => {
|
||||
const current = readLatest(editor, thread)
|
||||
if (!current) return
|
||||
if (openThreadId.get(editor) === current.id) {
|
||||
openThreadId.set(editor, null)
|
||||
}
|
||||
put([{ ...current, isDeleted: true }])
|
||||
},
|
||||
'delete'
|
||||
)
|
||||
}
|
||||
154
packages/commenting/src/canvas/comment-reactions.test.ts
Normal file
154
packages/commenting/src/canvas/comment-reactions.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { TLComment, TLCommentId, TLCommentReaction, createCommentReactionId } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { summarizeReactions } from './comment-reactions'
|
||||
|
||||
const COMMENT_A = 'comment:a' as TLCommentId
|
||||
const COMMENT_B = 'comment:b' as TLCommentId
|
||||
|
||||
function reaction(userId: string, emoji: string, createdAt: number): TLCommentReaction {
|
||||
return {
|
||||
id: createCommentReactionId(COMMENT_A, userId, emoji),
|
||||
typeName: 'comment-reaction',
|
||||
commentId: COMMENT_A,
|
||||
threadId: 'comment-thread:t' as TLCommentReaction['threadId'],
|
||||
pageId: 'page:page' as TLCommentReaction['pageId'],
|
||||
userId,
|
||||
emoji,
|
||||
createdAt,
|
||||
meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('summarizeReactions', () => {
|
||||
it('returns nothing when there are no reactions', () => {
|
||||
expect(summarizeReactions([], 'user1')).toEqual([])
|
||||
})
|
||||
|
||||
it('counts each emoji, marks the current user’s active, and lists reactors', () => {
|
||||
expect(
|
||||
summarizeReactions(
|
||||
[reaction('user1', '👍', 100), reaction('user2', '👍', 200), reaction('user3', '🎉', 300)],
|
||||
'user1'
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
emoji: '👍',
|
||||
count: 2,
|
||||
active: true,
|
||||
// no resolveName here, so names fall back to the generic 'Someone'
|
||||
reactors: [
|
||||
{ name: 'Someone', you: true },
|
||||
{ name: 'Someone', you: false },
|
||||
],
|
||||
},
|
||||
{ emoji: '🎉', count: 1, active: false, reactors: [{ name: 'Someone', you: false }] },
|
||||
])
|
||||
})
|
||||
|
||||
// the row shouldn't reshuffle as later reactions land, so groups sort by first use
|
||||
it('orders emoji by when each was first used', () => {
|
||||
expect(
|
||||
summarizeReactions(
|
||||
[reaction('user1', '🎉', 300), reaction('user2', '👍', 100), reaction('user3', '👍', 200)],
|
||||
undefined
|
||||
).map((r) => r.emoji)
|
||||
).toEqual(['👍', '🎉'])
|
||||
})
|
||||
|
||||
it('marks nothing active when there is no current user', () => {
|
||||
expect(summarizeReactions([reaction('user1', '👍', 100)], undefined)[0].active).toBe(false)
|
||||
})
|
||||
|
||||
it('names reactors via resolveName, falling back to a generic name', () => {
|
||||
const resolveName = (id: string) => (id === 'user1' ? 'Ada' : undefined)
|
||||
expect(
|
||||
summarizeReactions(
|
||||
[reaction('user1', '👍', 100), reaction('user2', '👍', 200)],
|
||||
'user1',
|
||||
resolveName
|
||||
)[0].reactors
|
||||
).toEqual([
|
||||
{ name: 'Ada', you: true },
|
||||
// user2 can't be resolved — a generic name, never the raw id
|
||||
{ name: 'Someone', you: false },
|
||||
])
|
||||
})
|
||||
|
||||
// reactors keep the order they're passed in (the caller sorts by reaction time), so the hover
|
||||
// list is stable
|
||||
it('lists reactors in input order', () => {
|
||||
expect(
|
||||
summarizeReactions(
|
||||
[reaction('user2', '👍', 100), reaction('user1', '👍', 200)],
|
||||
undefined,
|
||||
(id) => id.toUpperCase()
|
||||
)[0].reactors.map((r) => r.name)
|
||||
).toEqual(['USER2', 'USER1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeReactions input', () => {
|
||||
// the notifications panel tallies rows synced from the app database, which carry only the
|
||||
// fields the tally needs — full TLCommentReaction records must not be required
|
||||
it('accepts minimal rows with only userId, emoji, and createdAt', () => {
|
||||
const rows = [
|
||||
{ userId: 'user1', emoji: '👍', createdAt: 100 },
|
||||
{ userId: 'user2', emoji: '👍', createdAt: 200 },
|
||||
]
|
||||
expect(summarizeReactions(rows, 'user1')).toEqual([
|
||||
{
|
||||
emoji: '👍',
|
||||
count: 2,
|
||||
active: true,
|
||||
reactors: [
|
||||
{ name: 'Someone', you: true },
|
||||
{ name: 'Someone', you: false },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCommentReactionId', () => {
|
||||
// the id is what makes reaction identity structural: re-picking the same emoji addresses the
|
||||
// same record (toggle off), while a different emoji is its own record (independent)
|
||||
it('is stable for the same comment, user, and emoji', () => {
|
||||
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).toBe(
|
||||
createCommentReactionId(COMMENT_A, 'user1', '👍')
|
||||
)
|
||||
})
|
||||
|
||||
it('differs per user, per comment, and per emoji', () => {
|
||||
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
|
||||
createCommentReactionId(COMMENT_A, 'user2', '👍')
|
||||
)
|
||||
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
|
||||
createCommentReactionId(COMMENT_B, 'user1', '👍')
|
||||
)
|
||||
// same user + comment, different emoji → a distinct record (this is what enables multi-react)
|
||||
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
|
||||
createCommentReactionId(COMMENT_A, 'user1', '🎉')
|
||||
)
|
||||
})
|
||||
|
||||
it('is a comment-reaction id, not a comment id', () => {
|
||||
const id: string = createCommentReactionId(COMMENT_A, 'user1', '👍')
|
||||
expect(id.startsWith('comment-reaction:')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reaction records', () => {
|
||||
// a comment's reactions are found by commentId, so a reaction to another comment must not
|
||||
// leak into this one's tally
|
||||
it('are scoped to one comment by commentId', () => {
|
||||
const forOtherComment: TLCommentReaction = {
|
||||
...reaction('user9', '💩', 400),
|
||||
commentId: COMMENT_B,
|
||||
}
|
||||
const all = [reaction('user1', '👍', 100), forOtherComment]
|
||||
const mine = all.filter((r) => r.commentId === (COMMENT_A as TLComment['id']))
|
||||
expect(summarizeReactions(mine, 'user1')).toEqual([
|
||||
{ emoji: '👍', count: 1, active: true, reactors: [{ name: 'Someone', you: true }] },
|
||||
])
|
||||
})
|
||||
})
|
||||
246
packages/commenting/src/canvas/comment-reactions.tsx
Normal file
246
packages/commenting/src/canvas/comment-reactions.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
TLComment,
|
||||
TLCommentReaction,
|
||||
createCommentReaction,
|
||||
createCommentReactionId,
|
||||
useEditor,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { RenderReaction } from '../ui/reaction'
|
||||
import { ReactionPicker } from '../ui/reaction-picker'
|
||||
import { Reactions, ReactionSummary } from '../ui/reactions'
|
||||
import { commitCommentMutation } from './comment-mutations'
|
||||
import { UNKNOWN_AUTHOR } from './comment-render'
|
||||
import { getCommentReactions } from './comment-store'
|
||||
import { getCommentingOptions, useCommentingOptions } from './options'
|
||||
|
||||
/**
|
||||
* One comment's reactions, oldest first, reactively.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useCommentReactions(
|
||||
editor: Editor,
|
||||
commentId: TLComment['id']
|
||||
): TLCommentReaction[] {
|
||||
return useValue(
|
||||
'comment reactions',
|
||||
() =>
|
||||
getCommentReactions(editor)
|
||||
.filter((reaction) => reaction.commentId === commentId)
|
||||
.sort((a, b) => a.createdAt - b.createdAt),
|
||||
[editor, commentId]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reaction fields {@link summarizeReactions} needs — a structural subset of
|
||||
* {@link tldraw#TLCommentReaction}, so tallies can also be built from rows synced outside the
|
||||
* editor store.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ReactionSummaryInput {
|
||||
userId: string
|
||||
emoji: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Tally a comment's reactions into an entry per emoji, ordered by when that emoji was first used so
|
||||
* the row stays stable as later reactions arrive. `active` marks the current user's emoji and
|
||||
* `reactors` lists who reacted, in reaction order. `resolveName` names each reactor; an id it can't
|
||||
* name falls back to a generic "Someone", never the raw user id.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function summarizeReactions(
|
||||
reactions: readonly ReactionSummaryInput[],
|
||||
currentUserId?: string | null,
|
||||
resolveName?: (userId: string) => string | undefined
|
||||
): ReactionSummary[] {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ count: number; active: boolean; firstAt: number; reactors: ReactionSummary['reactors'] }
|
||||
>()
|
||||
for (const reaction of reactions) {
|
||||
const mine = currentUserId != null && reaction.userId === currentUserId
|
||||
// Fall back to a generic name, never the raw user id, when the id can't be resolved.
|
||||
const reactor = { name: resolveName?.(reaction.userId) ?? UNKNOWN_AUTHOR, you: mine }
|
||||
const group = groups.get(reaction.emoji)
|
||||
if (group) {
|
||||
group.count++
|
||||
group.active ||= mine
|
||||
group.firstAt = Math.min(group.firstAt, reaction.createdAt)
|
||||
group.reactors.push(reactor)
|
||||
} else {
|
||||
groups.set(reaction.emoji, {
|
||||
count: 1,
|
||||
active: mine,
|
||||
firstAt: reaction.createdAt,
|
||||
reactors: [reactor],
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...groups]
|
||||
.sort(([, a], [, b]) => a.firstAt - b.firstAt)
|
||||
.map(([emoji, group]) => ({
|
||||
emoji,
|
||||
count: group.count,
|
||||
active: group.active,
|
||||
reactors: group.reactors,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle one user's reaction with a given emoji on a comment.
|
||||
*
|
||||
* Each reaction is its own record keyed by (comment, user, emoji), so this only touches that user's
|
||||
* own records and two people reacting at once never conflict. Behaviour depends on
|
||||
* `allowMultipleReactions`:
|
||||
*
|
||||
* - **multiple** (default): the emoji toggles independently, leaving other reactions alone.
|
||||
* - **single**: a new emoji replaces the user's existing reaction; the same one removes it.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function toggleCommentReaction(
|
||||
editor: Editor,
|
||||
comment: TLComment,
|
||||
userId: string,
|
||||
emoji: string,
|
||||
now = Date.now()
|
||||
): void {
|
||||
const { allowMultipleReactions, isAllowedReaction } = getCommentingOptions(editor)
|
||||
const targetId = createCommentReactionId(comment.id, userId, emoji)
|
||||
const mine = getCommentReactions(editor).filter(
|
||||
(reaction) => reaction.commentId === comment.id && reaction.userId === userId
|
||||
)
|
||||
const removing = mine.some((reaction) => reaction.id === targetId)
|
||||
// Only a token the configured palette allows may be added. Removals always go through — if a
|
||||
// reaction somehow carries an off-palette token, the user must still be able to clear it.
|
||||
if (!removing && !isAllowedReaction(emoji)) return
|
||||
commitCommentMutation(editor, ({ put, remove }) => {
|
||||
if (removing) {
|
||||
remove([targetId])
|
||||
return
|
||||
}
|
||||
// Single-select: a new emoji replaces the user's existing reaction(s) on this comment.
|
||||
if (!allowMultipleReactions && mine.length > 0) {
|
||||
remove(mine.map((reaction) => reaction.id))
|
||||
}
|
||||
put([
|
||||
createCommentReaction({
|
||||
commentId: comment.id,
|
||||
threadId: comment.threadId,
|
||||
pageId: comment.pageId,
|
||||
userId,
|
||||
emoji,
|
||||
now,
|
||||
}),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface CommentReactionsProps {
|
||||
comment: TLComment
|
||||
/** The reacting user. Null/omitted gives a read-only row (signed out): counts show, but the
|
||||
* pills don't toggle. */
|
||||
currentUserId?: string | null
|
||||
/** Names a reactor id for the hover list. Ids it can't name fall back to a generic "Someone". */
|
||||
resolveName?(userId: string): string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts the `ReactionContent` override into a `renderReaction` function for the presentational
|
||||
* components. Undefined when no override is set, so they fall back to drawing the token string.
|
||||
*/
|
||||
function useReactionRenderer(): RenderReaction | undefined {
|
||||
const { components } = useCommentingOptions()
|
||||
const ReactionContent = components.ReactionContent
|
||||
return useMemo(
|
||||
() => (ReactionContent ? (token: string) => <ReactionContent token={token} /> : undefined),
|
||||
[ReactionContent]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The tallied reaction row under one comment. Pair with `CommentReactionPicker`, which is what
|
||||
* adds a reaction.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentReactions({ comment, currentUserId, resolveName }: CommentReactionsProps) {
|
||||
const editor = useEditor()
|
||||
const renderReaction = useReactionRenderer()
|
||||
const { components } = useCommentingOptions()
|
||||
const reactions = useCommentReactions(editor, comment.id)
|
||||
const summaries = useMemo(
|
||||
() => summarizeReactions(reactions, currentUserId, resolveName),
|
||||
[reactions, currentUserId, resolveName]
|
||||
)
|
||||
// Suppress the hover list while any menu is open (the reaction picker, an overflow menu…) so it
|
||||
// doesn't compete with the menu the user is actually working in. Edit mode already hides the
|
||||
// pills entirely (the card becomes a composer), so it needs no special case here.
|
||||
const anyMenuOpen = useValue('any menu open', () => editor.menus.getOpenMenus().length > 0, [
|
||||
editor,
|
||||
])
|
||||
return (
|
||||
<Reactions
|
||||
reactions={summaries}
|
||||
canReact={currentUserId != null}
|
||||
enableHoverList={!anyMenuOpen}
|
||||
renderReaction={renderReaction}
|
||||
ReactionTooltip={components.ReactionTooltip}
|
||||
onToggle={(value) => {
|
||||
if (currentUserId == null) return
|
||||
toggleCommentReaction(editor, comment, currentUserId, value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface CommentReactionPickerProps {
|
||||
comment: TLComment
|
||||
/** The reacting user. Null/omitted renders nothing — there's nobody to react as. */
|
||||
currentUserId?: string | null
|
||||
/** The emoji the picker offers. Defaults to `DEFAULT_REACTION_EMOJI`. */
|
||||
emoji?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The add-reaction button for one comment. Belongs with the comment card's hover actions rather
|
||||
* than in the reaction row, so opening it doesn't chase the row as reactions are added.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentReactionPicker({
|
||||
comment,
|
||||
currentUserId,
|
||||
emoji,
|
||||
}: CommentReactionPickerProps) {
|
||||
const editor = useEditor()
|
||||
const { components } = useCommentingOptions()
|
||||
const renderReaction = useReactionRenderer()
|
||||
const reactions = useCommentReactions(editor, comment.id)
|
||||
const selected = useMemo(
|
||||
() =>
|
||||
reactions
|
||||
.filter((reaction) => currentUserId != null && reaction.userId === currentUserId)
|
||||
.map((reaction) => reaction.emoji),
|
||||
[reactions, currentUserId]
|
||||
)
|
||||
if (currentUserId == null) return null
|
||||
return (
|
||||
<ReactionPicker
|
||||
emoji={emoji}
|
||||
selected={selected}
|
||||
renderReaction={renderReaction}
|
||||
palette={components.ReactionPalette}
|
||||
menuId={`comment-reactions-${comment.id}`}
|
||||
onSelect={(value) => toggleCommentReaction(editor, comment, currentUserId, value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
90
packages/commenting/src/canvas/comment-render.test.ts
Normal file
90
packages/commenting/src/canvas/comment-render.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
||||
import { renderCommentHtml, renderCommentPlaintext } from './comment-render'
|
||||
|
||||
const doc = (...content: any[]) => ({ type: 'doc', content }) as any
|
||||
const para = (...content: any[]) => ({ type: 'paragraph', content })
|
||||
const text = (value: string, marks?: any[]) => ({
|
||||
type: 'text',
|
||||
text: value,
|
||||
...(marks ? { marks } : {}),
|
||||
})
|
||||
const mention = (id: string, label?: string) => ({ type: 'mention', attrs: { id, label } })
|
||||
|
||||
describe('renderCommentHtml', () => {
|
||||
it('preserves bold, links, and lists from the limited set', () => {
|
||||
const html = renderCommentHtml(doc(para(text('hi', [{ type: 'bold' }]))))
|
||||
expect(html).toContain('<strong>hi</strong>')
|
||||
|
||||
const list = renderCommentHtml(
|
||||
doc({
|
||||
type: 'bulletList',
|
||||
content: [{ type: 'listItem', content: [para(text('one'))] }],
|
||||
})
|
||||
)
|
||||
expect(list).toContain('<ul')
|
||||
expect(list).toContain('one')
|
||||
})
|
||||
|
||||
it('renders a heading node as a paragraph, never a heading', () => {
|
||||
const html = renderCommentHtml(
|
||||
doc({ type: 'heading', attrs: { level: 1 }, content: [text('Title')] })
|
||||
)
|
||||
expect(html).not.toMatch(/<h[1-6]/)
|
||||
expect(html).toContain('<p')
|
||||
expect(html).toContain('Title')
|
||||
})
|
||||
|
||||
it('renders a mention as a pill and resolves its id to the current name', () => {
|
||||
const body = doc(para(text('hey '), mention('u1', 'Ada')))
|
||||
const html = renderCommentHtml(body, (id) => (id === 'u1' ? 'Ada Lovelace' : '?'))
|
||||
expect(html).toContain('tlui-cmt-mention')
|
||||
// the live name from the resolver, not the label stored at insert time
|
||||
expect(html).toContain('@Ada Lovelace')
|
||||
expect(html).not.toContain('@Ada<')
|
||||
})
|
||||
|
||||
it('falls back to the stored label when no resolver is given', () => {
|
||||
const html = renderCommentHtml(doc(para(mention('u1', 'Ada'))))
|
||||
expect(html).toContain('@Ada')
|
||||
})
|
||||
|
||||
it('escapes a resolved name, so it can never inject markup', () => {
|
||||
const html = renderCommentHtml(doc(para(mention('u1'))), () => '<img src=x onerror=alert(1)>')
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).toContain('<img')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderCommentPlaintext', () => {
|
||||
it('flattens paragraphs and demotes headings without throwing', () => {
|
||||
const flat = renderCommentPlaintext(
|
||||
doc({ type: 'heading', attrs: { level: 2 }, content: [text('Title')] }, para(text('body')))
|
||||
)
|
||||
expect(flat).toBe('Title\nbody')
|
||||
})
|
||||
|
||||
it('returns an empty string for an empty body', () => {
|
||||
expect(renderCommentPlaintext(EMPTY_COMMENT)).toBe('')
|
||||
})
|
||||
|
||||
it('renders a mention as @name, resolved to the current name', () => {
|
||||
const result = renderCommentPlaintext(
|
||||
doc(para(text('hi '), mention('u1', 'Ada'))),
|
||||
() => 'Ada Lovelace'
|
||||
)
|
||||
expect(result).toBe('hi @Ada Lovelace')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCommentEmpty', () => {
|
||||
it('treats the empty seed and empty encodings as empty', () => {
|
||||
expect(isCommentEmpty(EMPTY_COMMENT)).toBe(true)
|
||||
expect(isCommentEmpty(doc())).toBe(true)
|
||||
expect(isCommentEmpty(doc(para()))).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a body with text as non-empty', () => {
|
||||
expect(isCommentEmpty(doc(para(text('hi'))))).toBe(false)
|
||||
})
|
||||
})
|
||||
98
packages/commenting/src/canvas/comment-render.ts
Normal file
98
packages/commenting/src/canvas/comment-render.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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' })
|
||||
)
|
||||
}
|
||||
100
packages/commenting/src/canvas/comment-store.ts
Normal file
100
packages/commenting/src/canvas/comment-store.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Editor, TLComment, TLCommentReaction, TLCommentThread, TLRecord } from 'tldraw'
|
||||
|
||||
/**
|
||||
* Typed reads of comment records on the editor store.
|
||||
*
|
||||
* Comment records live on the editor's local store so the canvas can render them reactively, but
|
||||
* they're opt-in and aren't part of the `TLRecord` union — so `editor.store` is statically typed
|
||||
* `Store<TLRecord>` and every access has to reinterpret the type. These helpers own that
|
||||
* reinterpretation behind one boundary and keep call sites typed.
|
||||
*
|
||||
* Writes do the same, but also answer to the undo/redo policy, so they live in
|
||||
* `comment-mutations.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A record that lives in a comment thread: the thread itself, one of its messages, or a reaction
|
||||
* to one of those messages.
|
||||
* @public
|
||||
*/
|
||||
export type TLCommentRecord = TLComment | TLCommentThread | TLCommentReaction
|
||||
|
||||
/** Read one comment record by id, or `undefined` if the id isn't a present comment record. @public */
|
||||
export function getCommentRecord(editor: Editor, id: string): TLCommentRecord | undefined {
|
||||
const record = editor.store.get(id as TLRecord['id']) as unknown as TLCommentRecord | undefined
|
||||
if (!record) return undefined
|
||||
if (
|
||||
record.typeName === 'comment' ||
|
||||
record.typeName === 'comment-thread' ||
|
||||
record.typeName === 'comment-reaction'
|
||||
) {
|
||||
return record
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Read one comment thread by id, or `undefined` if the id isn't a present thread. @internal */
|
||||
export function getCommentThread(editor: Editor, id: string): TLCommentThread | undefined {
|
||||
const record = getCommentRecord(editor, id)
|
||||
return record?.typeName === 'comment-thread' ? record : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Every comment thread in the store, **including soft-deleted and emptied ones** awaiting the
|
||||
* server's prune, which nothing renders. For the set the UI shows, use {@link getLiveCommentThreads}.
|
||||
*
|
||||
* Non-reactive; wrap in `useValue`, or use `useCommentThreads`, to react.
|
||||
* @public
|
||||
*/
|
||||
export function getCommentThreads(editor: Editor): TLCommentThread[] {
|
||||
return queryCommentRecords<TLCommentThread>(editor, 'comment-thread')
|
||||
}
|
||||
|
||||
/**
|
||||
* Every comment in the store, **including soft-deleted ones** awaiting the server's prune. For the
|
||||
* set the UI shows, use {@link getLiveComments}.
|
||||
*
|
||||
* Non-reactive; wrap in `useValue`, or use `useComments`, to react.
|
||||
* @public
|
||||
*/
|
||||
export function getComments(editor: Editor): TLComment[] {
|
||||
return queryCommentRecords<TLComment>(editor, 'comment')
|
||||
}
|
||||
|
||||
/**
|
||||
* The comments that should render: not soft-deleted. A deleted record lingers in the store until
|
||||
* the server prunes it, so build counts and lists from this rather than {@link getComments}.
|
||||
*
|
||||
* Non-reactive; the reactive equivalent is `useComments` (which also sorts oldest first).
|
||||
* @public
|
||||
*/
|
||||
export function getLiveComments(editor: Editor): TLComment[] {
|
||||
return getComments(editor).filter((comment) => !comment.isDeleted)
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment threads that should render (pins, sidebar): not soft-deleted, and still holding at
|
||||
* least one live comment. A thread emptied by its last comment's delete lingers with no surface
|
||||
* until the server's prune lands.
|
||||
*
|
||||
* Non-reactive; the reactive equivalent is `useCommentThreads`.
|
||||
* @public
|
||||
*/
|
||||
export function getLiveCommentThreads(editor: Editor): TLCommentThread[] {
|
||||
const threadIdsWithComments = new Set(getLiveComments(editor).map((comment) => comment.threadId))
|
||||
return getCommentThreads(editor).filter(
|
||||
(thread) => !thread.isDeleted && threadIdsWithComments.has(thread.id)
|
||||
)
|
||||
}
|
||||
|
||||
/** All comment reactions currently in the store (non-reactive; wrap in `useValue` to react). @public */
|
||||
export function getCommentReactions(editor: Editor): TLCommentReaction[] {
|
||||
return queryCommentRecords<TLCommentReaction>(editor, 'comment-reaction')
|
||||
}
|
||||
|
||||
function queryCommentRecords<T extends TLCommentRecord>(
|
||||
editor: Editor,
|
||||
typeName: T['typeName']
|
||||
): T[] {
|
||||
return editor.store.query.records(typeName as TLRecord['typeName']).get() as unknown as T[]
|
||||
}
|
||||
267
packages/commenting/src/canvas/comment-tool.test.ts
Normal file
267
packages/commenting/src/canvas/comment-tool.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { Driver } from '@tldraw/driver'
|
||||
import {
|
||||
commentSchemaRecords,
|
||||
createShapeId,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
TLShapeId,
|
||||
TLStateNodeConstructor,
|
||||
} from 'tldraw'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { CommentTool } from './comment-tool'
|
||||
import { commentsSidebarOpen, pendingComment, regionDraft } from './state'
|
||||
|
||||
/**
|
||||
* Pointer-driven tests for the comment tool's placement affordances. These need a real editor
|
||||
* and real pointer dispatch: the behavior under test is how the tool's state transitions
|
||||
* (idle → pointing → dragging) maintain the editor's hinting state.
|
||||
*/
|
||||
|
||||
let editor: Editor
|
||||
|
||||
function makeEditor(tool: TLStateNodeConstructor = CommentTool) {
|
||||
editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: [...defaultTools, tool],
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
return new Driver(editor)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
editor.dispose()
|
||||
})
|
||||
|
||||
function makeShape(x = 100, y = 100, w = 100, h = 50): TLShapeId {
|
||||
const id = createShapeId()
|
||||
editor.createShape({ id, type: 'geo', x, y, props: { w, h } })
|
||||
return id
|
||||
}
|
||||
|
||||
function makeFrame(x = 300, y = 100, w = 200, h = 200): TLShapeId {
|
||||
const id = createShapeId()
|
||||
editor.createShape({ id, type: 'frame', x, y, props: { w, h } })
|
||||
return id
|
||||
}
|
||||
|
||||
describe('comment tool placement hints', () => {
|
||||
let driver: Driver
|
||||
|
||||
beforeEach(() => {
|
||||
driver = makeEditor()
|
||||
})
|
||||
|
||||
it('hints the shape under the pointer while hovering', () => {
|
||||
const id = makeShape()
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(150, 125)
|
||||
expect(editor.getHintingShapeIds()).toEqual([id])
|
||||
|
||||
driver.pointerMove(400, 400)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps hinting the anchor target while the composer follows the pointer', () => {
|
||||
const id = makeShape()
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(400, 400)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
|
||||
// Press away from the shape, then drag the follow composer over it: the shape a release
|
||||
// would anchor to stays hinted, exactly like the idle hover.
|
||||
driver.pointerDown(400, 400)
|
||||
driver.pointerMove(150, 125)
|
||||
expect(editor.isIn('comment.pointing')).toBe(true)
|
||||
expect(editor.getHintingShapeIds()).toEqual([id])
|
||||
|
||||
// ...and dragging back off the shape drops the hint.
|
||||
driver.pointerMove(400, 400)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears the hint when the tool exits', () => {
|
||||
const id = makeShape()
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(150, 125)
|
||||
expect(editor.getHintingShapeIds()).toEqual([id])
|
||||
|
||||
// Select owns its own hover state, so the comment hint leaves with the tool.
|
||||
editor.setCurrentTool('select')
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('comment tool placement lifecycle', () => {
|
||||
let driver: Driver
|
||||
|
||||
beforeEach(() => {
|
||||
driver = makeEditor()
|
||||
})
|
||||
|
||||
it('stays in the tool with the composer open after placing', () => {
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(400, 300)
|
||||
driver.pointerUp(400, 300)
|
||||
// Placement opens the composer but the interaction isn't over — the tool holds on until
|
||||
// the comment is posted or dismissed, so the surrounding UI stays stable.
|
||||
expect(editor.isIn('comment.idle')).toBe(true)
|
||||
expect(pendingComment.get(editor)).toEqual({
|
||||
anchor: { type: 'point', x: 400, y: 300 },
|
||||
point: { x: 400, y: 300 },
|
||||
})
|
||||
})
|
||||
|
||||
it('re-places the composer on a second click', () => {
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(400, 300)
|
||||
driver.pointerUp(400, 300)
|
||||
driver.pointerDown(200, 200)
|
||||
driver.pointerUp(200, 200)
|
||||
expect(editor.isIn('comment.idle')).toBe(true)
|
||||
expect(pendingComment.get(editor)).toEqual({
|
||||
anchor: { type: 'point', x: 200, y: 200 },
|
||||
point: { x: 200, y: 200 },
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the pending composer when the tool exits', () => {
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(400, 300)
|
||||
driver.pointerUp(400, 300)
|
||||
expect(pendingComment.get(editor)).not.toBeNull()
|
||||
|
||||
// A direct tool switch (toolbar, shortcut) abandons the draft composer — it belongs to
|
||||
// the tool. The text itself survives in the comment draft store.
|
||||
editor.setCurrentTool('select')
|
||||
expect(pendingComment.get(editor)).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the comments sidebar for the whole interaction', () => {
|
||||
commentsSidebarOpen.set(editor, true)
|
||||
editor.setCurrentTool('comment')
|
||||
expect(commentsSidebarOpen.get(editor)).toBe(false)
|
||||
|
||||
// ...and placing doesn't reopen it: the tool (and the closed sidebar) hold through the
|
||||
// composer. Reopening is the host's button, never a side effect of leaving the tool.
|
||||
driver.pointerDown(400, 300)
|
||||
driver.pointerUp(400, 300)
|
||||
expect(editor.isIn('comment.idle')).toBe(true)
|
||||
expect(commentsSidebarOpen.get(editor)).toBe(false)
|
||||
})
|
||||
|
||||
it('escape leaves the tool for select and drops the draft', () => {
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(400, 300)
|
||||
driver.pointerUp(400, 300)
|
||||
editor.cancel()
|
||||
expect(editor.isIn('select')).toBe(true)
|
||||
expect(pendingComment.get(editor)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('comment tool placement hints with regions enabled', () => {
|
||||
it('clears the hint when a drag becomes a region', () => {
|
||||
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
||||
const id = makeShape()
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(150, 125)
|
||||
expect(editor.getHintingShapeIds()).toEqual([id])
|
||||
|
||||
// Past the drag threshold this is a region draw — a single-shape outline under the dashed
|
||||
// box would be stale, so the hint clears even with the pointer still over the shape.
|
||||
driver.pointerDown(150, 125)
|
||||
driver.pointerMove(180, 140)
|
||||
expect(editor.isIn('comment.dragging')).toBe(true)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('stays in the tool with the composer open after a region drag', () => {
|
||||
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(100, 100)
|
||||
driver.pointerMove(200, 180)
|
||||
driver.pointerUp(200, 180)
|
||||
expect(editor.isIn('comment.idle')).toBe(true)
|
||||
expect(pendingComment.get(editor)).toEqual({
|
||||
anchor: { type: 'region', x: 100, y: 100, w: 100, h: 80, pinX: 1, pinY: 1 },
|
||||
point: { x: 200, y: 180 },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hint a shape under the pin corner of a placed region', () => {
|
||||
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
||||
makeShape(100, 100)
|
||||
editor.setCurrentTool('comment')
|
||||
// Release on top of the shape: a region anchors to its rectangle, so the outline that
|
||||
// normally previews a shape anchor would be a lie here.
|
||||
driver.pointerDown(50, 50)
|
||||
driver.pointerMove(150, 125)
|
||||
driver.pointerUp(150, 125)
|
||||
expect(editor.isIn('comment.idle')).toBe(true)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the region draft when the tool exits mid-drag', () => {
|
||||
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(100, 100)
|
||||
driver.pointerMove(200, 180)
|
||||
expect(regionDraft.get(editor)).not.toBeNull()
|
||||
|
||||
// A direct tool switch (shortcut, toolbar) mid-drag must not strand the drawn rectangle.
|
||||
editor.setCurrentTool('select')
|
||||
expect(regionDraft.get(editor)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('comment tool anchoring to frames', () => {
|
||||
let driver: Driver
|
||||
|
||||
beforeEach(() => {
|
||||
driver = makeEditor()
|
||||
})
|
||||
|
||||
it('hints a frame when hovering its empty interior', () => {
|
||||
// A frame is hit only on its edge/label by default; the comment tool passes hitFrameInside
|
||||
// so a click in its body anchors the frame. (300,100)–(500,300): (400,200) is well inside,
|
||||
// clear of the edges and the (above-frame) label.
|
||||
const frame = makeFrame(300, 100, 200, 200)
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(400, 200)
|
||||
expect(editor.getHintingShapeIds()).toEqual([frame])
|
||||
})
|
||||
|
||||
it('a shape over the frame still wins the hit', () => {
|
||||
const frame = makeFrame(300, 100, 200, 200)
|
||||
const child = makeShape(340, 140, 60, 40) // sits inside the frame, drawn above it
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(370, 160)
|
||||
expect(editor.getHintingShapeIds()).toEqual([child])
|
||||
// ...and the frame's empty interior beside it still anchors the frame.
|
||||
driver.pointerMove(470, 260)
|
||||
expect(editor.getHintingShapeIds()).toEqual([frame])
|
||||
})
|
||||
|
||||
it('keeps hinting a frame while the composer follows the pointer into it', () => {
|
||||
// The click-drag path, not just idle hover: press away from the frame, then drag the follow
|
||||
// composer into its body. The pointing state hints the anchor target too, so the frame stays
|
||||
// hinted while dragging over it (regions are off by default, so this doesn't become a region).
|
||||
const frame = makeFrame(300, 100, 200, 200)
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(50, 50)
|
||||
driver.pointerDown(50, 50)
|
||||
driver.pointerMove(400, 200)
|
||||
expect(editor.isIn('comment.pointing')).toBe(true)
|
||||
expect(editor.getHintingShapeIds()).toEqual([frame])
|
||||
|
||||
// ...and dragging back off the frame clears it.
|
||||
driver.pointerMove(50, 400)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
294
packages/commenting/src/canvas/comment-tool.tsx
Normal file
294
packages/commenting/src/canvas/comment-tool.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
BoxModel,
|
||||
Editor,
|
||||
StateNode,
|
||||
TLCommentAnchor,
|
||||
TLStateNodeConstructor,
|
||||
TLUiOverrides,
|
||||
VecLike,
|
||||
} from 'tldraw'
|
||||
import { type CommentingOptions, defaultCommentingOptions, getCommentingOptions } from './options'
|
||||
import { commentsSidebarOpen, pendingComment, regionDraft } from './state'
|
||||
import { commentTargetShapeAt, regionPinPoint, shapeAnchorAt } from './thread-state'
|
||||
|
||||
/** A comment being placed but not yet posted: where its composer sits and what it will anchor
|
||||
* to. Shared between the tool (which sets it on click) and the overlay (which renders the
|
||||
* composer) through the internal `pendingComment` atom.
|
||||
* @internal */
|
||||
export interface PendingComment {
|
||||
anchor: TLCommentAnchor
|
||||
/** Page point where the composer opens (the click location, or a region's pin corner). */
|
||||
point: VecLike
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge configure options over a base. Shallow for scalars; `components` is merged rather than
|
||||
* replaced so chained `configure` calls layer their slots — a later `{ components: { PinContent } }`
|
||||
* doesn't drop an earlier `{ components: { CommentBody } }`.
|
||||
*/
|
||||
function mergeCommentingOptions(
|
||||
base: CommentingOptions,
|
||||
overrides: Partial<CommentingOptions>
|
||||
): CommentingOptions {
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
components: { ...base.components, ...overrides.components },
|
||||
}
|
||||
}
|
||||
|
||||
/** The page-space rectangle spanned by two points, normalized so w/h are non-negative. Shared by
|
||||
* region creation (pointer-down → cursor) and corner resize (fixed corner → cursor). */
|
||||
export function regionBetween(a: VecLike, b: VecLike): BoxModel {
|
||||
return {
|
||||
x: Math.min(a.x, b.x),
|
||||
y: Math.min(a.y, b.y),
|
||||
w: Math.abs(a.x - b.x),
|
||||
h: Math.abs(a.y - b.y),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment tool. Pressing down opens the comment composer at the pointer and it follows until
|
||||
* release — like placing a sticky note — settling on a point, or on a shape when released over one.
|
||||
* With region comments enabled, dragging past the threshold draws a region rectangle instead.
|
||||
* Placement only opens a composer; the records are created when the comment is posted. The tool
|
||||
* stays active while the composer is open — posting returns to select, and clicking elsewhere
|
||||
* re-places the composer.
|
||||
* @public
|
||||
*/
|
||||
export class CommentTool extends StateNode {
|
||||
static override id = 'comment'
|
||||
static override initial = 'idle'
|
||||
static override children(): TLStateNodeConstructor[] {
|
||||
return [CommentIdle, CommentPointing, CommentDragging]
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure this tool's {@link CommentTool.options | `options`}, returning a configured subclass
|
||||
* to register via `tools`. Mirrors `ShapeUtil.configure`. Layers over any prior `configure`, so
|
||||
* calls can be chained.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Tldraw tools={[CommentTool.configure({ history: 'ignore', enableClustering: false })]} />
|
||||
* ```
|
||||
*/
|
||||
static configure<T extends TLStateNodeConstructor>(
|
||||
this: T,
|
||||
options: T extends new (...args: any[]) => { options: infer Options } ? Partial<Options> : never
|
||||
): T {
|
||||
// @ts-expect-error -- mirrors ShapeUtil.configure; extending `this` is sound at runtime.
|
||||
return class extends this {
|
||||
// @ts-expect-error
|
||||
options = mergeCommentingOptions(this.options, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The merged commenting options for this editor. Read from anywhere via
|
||||
* {@link getCommentingOptions}. Override with {@link CommentTool.configure}.
|
||||
*/
|
||||
options: CommentingOptions = defaultCommentingOptions
|
||||
|
||||
override onEnter() {
|
||||
this.editor.setCursor({ type: 'comment', rotation: 0 })
|
||||
// Placing comments is canvas-focused — the thread list gets out of the way while the tool is
|
||||
// active. Reopened via its own control (a button), never by leaving the tool.
|
||||
commentsSidebarOpen.set(this.editor, false)
|
||||
}
|
||||
|
||||
override onExit() {
|
||||
// Drop the hover hint painted while pointing at shapes (see CommentIdle). The cursor resets
|
||||
// when the next tool takes over. The draft composer and region draft belong to the tool, so
|
||||
// they leave with it; the draft's text survives in the comment draft store.
|
||||
this.editor.setHintingShapes([])
|
||||
pendingComment.set(this.editor, null)
|
||||
regionDraft.set(this.editor, null)
|
||||
}
|
||||
|
||||
// Escape leaves the tool, like the built-in tools (the editor dispatches `cancel` on Escape).
|
||||
override onCancel() {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
||||
/** Hint the shape a comment placed at the pointer would anchor to, using the same hit-test as the
|
||||
* anchor resolution on release. Hinting shapes render an indicator ungated by the active tool, so
|
||||
* this shows the select-style outline while the comment tool — not select — is active. */
|
||||
function updateAnchorHint(editor: Editor) {
|
||||
const hit = commentTargetShapeAt(editor, editor.inputs.getCurrentPagePoint())
|
||||
editor.setHintingShapes(hit ? [hit.id] : [])
|
||||
}
|
||||
|
||||
class CommentIdle extends StateNode {
|
||||
static override id = 'idle'
|
||||
|
||||
override onEnter() {
|
||||
// Back to hovering: restore the pin cursor (a prior placing state may have hidden it).
|
||||
this.editor.setCursor({ type: 'comment', rotation: 0 })
|
||||
// With a region composer open, a shape outline under its pin corner would imply a shape
|
||||
// anchor the region will never use — and it would stick, since moves over the composer
|
||||
// never reach the canvas. Point placements keep the hint: it previews the next click.
|
||||
if (pendingComment.get(this.editor)?.anchor.type === 'region') {
|
||||
this.editor.setHintingShapes([])
|
||||
} else {
|
||||
updateAnchorHint(this.editor)
|
||||
}
|
||||
}
|
||||
|
||||
override onPointerMove() {
|
||||
updateAnchorHint(this.editor)
|
||||
}
|
||||
|
||||
override onPointerDown() {
|
||||
this.parent.transition('pointing')
|
||||
}
|
||||
}
|
||||
|
||||
class CommentPointing extends StateNode {
|
||||
static override id = 'pointing'
|
||||
|
||||
override onEnter() {
|
||||
// Open the composer immediately at the press point so it's visible from pointer-down (not just
|
||||
// on release), and let it trail the pointer while dragging — like placing a sticky note. The
|
||||
// anchor is a bare point for now; it's resolved (shape or point) on pointer up.
|
||||
const { editor } = this
|
||||
// Hide the cursor while placing: the draft composer is the pointer's stand-in now, so the pin
|
||||
// cursor sitting over it just reads as clutter.
|
||||
editor.setCursor({ type: 'none', rotation: 0 })
|
||||
const point = editor.inputs.getCurrentPagePoint()
|
||||
pendingComment.set(editor, {
|
||||
anchor: { type: 'point', x: point.x, y: point.y },
|
||||
point: { x: point.x, y: point.y },
|
||||
})
|
||||
}
|
||||
|
||||
override onPointerMove() {
|
||||
const { editor } = this
|
||||
// Once the pointer passes the drag threshold with region comments enabled, this is a region,
|
||||
// not a follow — hand off to the region drag (which clears this composer and draws the box).
|
||||
if (getCommentingOptions(editor).enableRegions && editor.inputs.getIsDragging()) {
|
||||
this.parent.transition('dragging')
|
||||
return
|
||||
}
|
||||
// Otherwise the composer trails the pointer — keep hinting the shape a release here would
|
||||
// anchor to, like the idle hover does.
|
||||
updateAnchorHint(editor)
|
||||
const point = editor.inputs.getCurrentPagePoint()
|
||||
pendingComment.update(editor, (p) => (p ? { ...p, point: { x: point.x, y: point.y } } : p))
|
||||
}
|
||||
|
||||
// Settle where the pointer is released: anchor to the shape under it, or drop a point.
|
||||
override onPointerUp() {
|
||||
const { editor } = this
|
||||
const point = editor.inputs.getCurrentPagePoint()
|
||||
const hit = commentTargetShapeAt(editor, point)
|
||||
const anchor: TLCommentAnchor = hit
|
||||
? shapeAnchorAt(
|
||||
editor,
|
||||
hit.id,
|
||||
point,
|
||||
getCommentingOptions(editor).shouldBePrecise(editor, {
|
||||
shapeId: hit.id,
|
||||
point,
|
||||
altKey: editor.inputs.getAltKey(),
|
||||
})
|
||||
)
|
||||
: { type: 'point', x: point.x, y: point.y }
|
||||
pendingComment.set(editor, { anchor, point: { x: point.x, y: point.y } })
|
||||
// Stay in the tool while the composer is open — the interaction isn't over until the
|
||||
// comment is posted or dismissed, and staying keeps the surrounding UI (style panel,
|
||||
// sidebar) from churning mid-placement.
|
||||
this.parent.transition('idle')
|
||||
}
|
||||
|
||||
override onCancel() {
|
||||
this.cancel()
|
||||
}
|
||||
|
||||
override onInterrupt() {
|
||||
this.cancel()
|
||||
}
|
||||
|
||||
// Abandon the follow composer if placement is interrupted (Escape, focus loss, etc.). The
|
||||
// tool's onExit drops the draft.
|
||||
private cancel() {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
||||
class CommentDragging extends StateNode {
|
||||
static override id = 'dragging'
|
||||
|
||||
override onEnter() {
|
||||
// A region drag supersedes the point-follow composer opened in `pointing`. Show a crosshair again,
|
||||
// since the composer no longer stands in for the pointer, and drop the placement hint — a region
|
||||
// anchors to its rectangle, so a single-shape outline would be stale.
|
||||
pendingComment.set(this.editor, null)
|
||||
this.editor.setHintingShapes([])
|
||||
this.editor.setCursor({ type: 'cross', rotation: 0 })
|
||||
this.updateDraft()
|
||||
}
|
||||
|
||||
override onPointerMove() {
|
||||
this.updateDraft()
|
||||
}
|
||||
|
||||
// Commit the dragged rectangle as a region anchor; the composer opens at its pin corner.
|
||||
override onPointerUp() {
|
||||
const { editor } = this
|
||||
const origin = editor.inputs.getOriginPagePoint()
|
||||
const current = editor.inputs.getCurrentPagePoint()
|
||||
const region = regionBetween(origin, current)
|
||||
// The pin lives on the corner the drag released on — drag up-left, pin top-left.
|
||||
const pin = { x: current.x >= origin.x ? 1 : 0, y: current.y >= origin.y ? 1 : 0 }
|
||||
regionDraft.set(editor, null)
|
||||
pendingComment.set(editor, {
|
||||
anchor: { type: 'region', ...region, pinX: pin.x, pinY: pin.y },
|
||||
point: regionPinPoint(region, pin),
|
||||
})
|
||||
// Same as the point placement: the tool stays active while the composer is open.
|
||||
this.parent.transition('idle')
|
||||
}
|
||||
|
||||
override onCancel() {
|
||||
this.cancel()
|
||||
}
|
||||
|
||||
override onInterrupt() {
|
||||
this.cancel()
|
||||
}
|
||||
|
||||
private updateDraft() {
|
||||
const { editor } = this
|
||||
regionDraft.set(
|
||||
editor,
|
||||
regionBetween(editor.inputs.getOriginPagePoint(), editor.inputs.getCurrentPagePoint())
|
||||
)
|
||||
}
|
||||
|
||||
// The tool's onExit drops the region draft.
|
||||
private cancel() {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const commentTools = [CommentTool]
|
||||
|
||||
/** Registers the comment tool in the UI (icon, label, shortcut). Compose into your overrides.
|
||||
* Once registered, tldraw's `DefaultQuickActionsContent` shows the comment button.
|
||||
* @public */
|
||||
export const commentToolOverrides: TLUiOverrides = {
|
||||
tools(editor, tools) {
|
||||
tools.comment = {
|
||||
id: 'comment',
|
||||
icon: 'comment',
|
||||
label: 'Comment',
|
||||
kbd: 'c',
|
||||
onSelect: () => editor.setCurrentTool('comment'),
|
||||
}
|
||||
return tools
|
||||
},
|
||||
}
|
||||
88
packages/commenting/src/canvas/comments-filter-menu.tsx
Normal file
88
packages/commenting/src/canvas/comments-filter-menu.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
TldrawUiButton,
|
||||
TldrawUiDropdownMenuContent,
|
||||
TldrawUiDropdownMenuRoot,
|
||||
TldrawUiDropdownMenuTrigger,
|
||||
TldrawUiMenuCheckboxItem,
|
||||
TldrawUiMenuContextProvider,
|
||||
TldrawUiMenuGroup,
|
||||
useEditor,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { MoreMenuIcon } from '../ui/icons'
|
||||
import { SidebarFilters } from './sidebar-filters'
|
||||
import { sidebarFilters } from './state'
|
||||
|
||||
/** @public */
|
||||
export interface CommentsFilterMenuProps {
|
||||
/** Whether to offer the "only your threads" toggle (needs a known current user). */
|
||||
canFilterByAuthor?: boolean
|
||||
/** Whether to offer the "only unread" toggle (needs a read-status source). */
|
||||
canFilterByUnread?: boolean
|
||||
}
|
||||
|
||||
/** The funnel dropdown in the sidebar header: toggles for which threads the list shows.
|
||||
* @public @react */
|
||||
export function CommentsFilterMenu({
|
||||
canFilterByAuthor,
|
||||
canFilterByUnread,
|
||||
}: CommentsFilterMenuProps) {
|
||||
const editor = useEditor()
|
||||
const msg = useTranslation()
|
||||
const filters = useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
||||
const toggle = (key: keyof SidebarFilters) => {
|
||||
sidebarFilters.update(editor, (f) => ({ ...f, [key]: !f[key] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<TldrawUiDropdownMenuRoot id="comments-filter">
|
||||
<TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={msg('comments.filter')}
|
||||
title={msg('comments.filter')}
|
||||
className="tlui-cmt-header-btn"
|
||||
>
|
||||
<MoreMenuIcon />
|
||||
</TldrawUiButton>
|
||||
</TldrawUiDropdownMenuTrigger>
|
||||
{/* No tlui-cmt-menu here: the rows are tldraw menu items, which carry their own insets —
|
||||
the extra padding would set this menu apart from the canvas menus it should match. */}
|
||||
<TldrawUiDropdownMenuContent side="bottom" align="end" alignOffset={0}>
|
||||
<TldrawUiMenuContextProvider type="menu" sourceId="menu">
|
||||
<TldrawUiMenuGroup id="comments-filter">
|
||||
<TldrawUiMenuCheckboxItem
|
||||
id="show-all-pages"
|
||||
label="comments.show-all-pages"
|
||||
checked={!filters.onlyCurrentPage}
|
||||
onSelect={() => toggle('onlyCurrentPage')}
|
||||
/>
|
||||
{canFilterByAuthor && (
|
||||
<TldrawUiMenuCheckboxItem
|
||||
id="only-my-comments"
|
||||
label="comments.only-my-comments"
|
||||
checked={filters.onlyMine}
|
||||
onSelect={() => toggle('onlyMine')}
|
||||
/>
|
||||
)}
|
||||
{canFilterByUnread && (
|
||||
<TldrawUiMenuCheckboxItem
|
||||
id="only-unread"
|
||||
label="comments.only-unread"
|
||||
checked={filters.onlyUnread}
|
||||
onSelect={() => toggle('onlyUnread')}
|
||||
/>
|
||||
)}
|
||||
<TldrawUiMenuCheckboxItem
|
||||
id="show-resolved"
|
||||
label="comments.show-resolved"
|
||||
checked={filters.showResolved}
|
||||
onSelect={() => toggle('showResolved')}
|
||||
/>
|
||||
</TldrawUiMenuGroup>
|
||||
</TldrawUiMenuContextProvider>
|
||||
</TldrawUiDropdownMenuContent>
|
||||
</TldrawUiDropdownMenuRoot>
|
||||
)
|
||||
}
|
||||
24
packages/commenting/src/canvas/comments-menu-item.tsx
Normal file
24
packages/commenting/src/canvas/comments-menu-item.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { TldrawUiMenuCheckboxItem, useEditor, useValue } from 'tldraw'
|
||||
import { commentsHidden, toggleCommentsHidden } from './state'
|
||||
|
||||
/**
|
||||
* A checkbox menu item for a "View" menu that shows/hides comment pins on the canvas — checked
|
||||
* when comments are visible, like tldraw's own grid/snap toggles. Reads and toggles the shared
|
||||
* {@link commentsHidden} signal, so it stays in sync with the Shift+C shortcut and the sidebar
|
||||
* control. Drop it into whichever menu your app owns.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentsMenuItem() {
|
||||
const editor = useEditor()
|
||||
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
||||
return (
|
||||
<TldrawUiMenuCheckboxItem
|
||||
id="comments-visible"
|
||||
label="comments.title"
|
||||
kbd="!c"
|
||||
readonlyOk
|
||||
checked={!hidden}
|
||||
onSelect={() => toggleCommentsHidden(editor)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
60
packages/commenting/src/canvas/comments-overflow-menu.tsx
Normal file
60
packages/commenting/src/canvas/comments-overflow-menu.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
TldrawUiButton,
|
||||
TldrawUiDropdownMenuContent,
|
||||
TldrawUiDropdownMenuGroup,
|
||||
TldrawUiDropdownMenuItem,
|
||||
TldrawUiDropdownMenuRoot,
|
||||
TldrawUiDropdownMenuTrigger,
|
||||
TldrawUiIcon,
|
||||
useEditor,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { commentsHidden, toggleCommentsHidden } from './state'
|
||||
|
||||
// A keyboard-shortcut glyph, not translatable copy — kept out of JSX as a constant.
|
||||
const HIDE_SHORTCUT = '⇧C'
|
||||
|
||||
/** The overflow (⋯) dropdown in the sidebar header. For now it holds the hide/show-comments
|
||||
* toggle; it's the home for later comment-wide controls (notifications, mark all as read).
|
||||
* @public @react */
|
||||
export function CommentsOverflowMenu() {
|
||||
const editor = useEditor()
|
||||
const msg = useTranslation()
|
||||
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
||||
|
||||
return (
|
||||
<TldrawUiDropdownMenuRoot id="comments-overflow">
|
||||
<TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={msg('comments.more-options')}
|
||||
title={msg('comments.more-options')}
|
||||
className="tlui-cmt-header-btn"
|
||||
>
|
||||
<TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
|
||||
</TldrawUiButton>
|
||||
</TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiDropdownMenuContent
|
||||
className="tlui-cmt-menu"
|
||||
side="bottom"
|
||||
align="end"
|
||||
alignOffset={0}
|
||||
sideOffset={4}
|
||||
>
|
||||
<TldrawUiDropdownMenuGroup>
|
||||
<TldrawUiDropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="tlui-cmt-menu-item"
|
||||
onClick={() => toggleCommentsHidden(editor)}
|
||||
>
|
||||
<span>{hidden ? msg('comments.show') : msg('comments.hide')}</span>
|
||||
<span className="tlui-cmt-menu-item__shortcut">{HIDE_SHORTCUT}</span>
|
||||
</button>
|
||||
</TldrawUiDropdownMenuItem>
|
||||
</TldrawUiDropdownMenuGroup>
|
||||
</TldrawUiDropdownMenuContent>
|
||||
</TldrawUiDropdownMenuRoot>
|
||||
)
|
||||
}
|
||||
43
packages/commenting/src/canvas/comments-overlay.test.ts
Normal file
43
packages/commenting/src/canvas/comments-overlay.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ClusterNode } from '../clustering/types'
|
||||
import { fadeNodeMarkerThreadId } from './comments-overlay'
|
||||
|
||||
function node(ids: string[], x = 0, y = 0): ClusterNode {
|
||||
const members = ids.slice().sort()
|
||||
return {
|
||||
id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
|
||||
centroid: { x, y },
|
||||
count: members.length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
describe('fadeNodeMarkerThreadId', () => {
|
||||
it('resolves a count-1 node to its own thread', () => {
|
||||
expect(fadeNodeMarkerThreadId(node(['t1']), null, null)).toBe('t1')
|
||||
expect(fadeNodeMarkerThreadId(node(['t1']), null, 't2')).toBe('t1')
|
||||
})
|
||||
|
||||
it('returns null for the open thread — the open-thread render slot draws it', () => {
|
||||
// Drawing it here too would mount its popover twice while the node fades out.
|
||||
expect(fadeNodeMarkerThreadId(node(['t1']), null, 't1')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves a coincident stack to its owner', () => {
|
||||
expect(fadeNodeMarkerThreadId(node(['t1', 't2']), 't1', null)).toBe('t1')
|
||||
expect(fadeNodeMarkerThreadId(node(['t1', 't2']), 't1', 't2')).toBe('t1')
|
||||
})
|
||||
|
||||
it('returns null for a stack whose owner is the open thread', () => {
|
||||
// Otherwise this node and the open slot would both draw the stack.
|
||||
expect(fadeNodeMarkerThreadId(node(['t1', 't2']), 't1', 't1')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a stack whose owner is no longer a member', () => {
|
||||
expect(fadeNodeMarkerThreadId(node(['t2', 't3']), 't1', null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a stack with no owner', () => {
|
||||
expect(fadeNodeMarkerThreadId(node(['t1', 't2']), null, null)).toBeNull()
|
||||
})
|
||||
})
|
||||
363
packages/commenting/src/canvas/comments-overlay.tsx
Normal file
363
packages/commenting/src/canvas/comments-overlay.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { isMentionPickerOpen } from '@tldraw/mentions'
|
||||
import { Fragment, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { EditorPortal, TLCommentThread, useEditor, useValue } from 'tldraw'
|
||||
import type { ClusterNode } from '../clustering/types'
|
||||
import { registerCommentAnchorLifecycle } from './anchor-lifecycle'
|
||||
import { ClusterBadge } from './cluster-badge'
|
||||
import { clusterFadeClassName } from './cluster-fade'
|
||||
import {
|
||||
CLUSTER_EXPAND_ZOOM_MS,
|
||||
revealThreadPin,
|
||||
useClusterModel,
|
||||
zoomToClusterSplit,
|
||||
} from './cluster-model'
|
||||
import { getCommentRecord, getCommentThread } from './comment-store'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useCommentThreads } from './hooks'
|
||||
import { useCommentingEnabled } from './license'
|
||||
import { useCanComment, useCommentingOptions } from './options'
|
||||
import { PendingComposer } from './pending-composer'
|
||||
import { computePinStacks, isOpenStackKeyLive, pinStacksEqual } from './pin-stacking'
|
||||
import { RegionBox, RegionDraftBox } from './region-box'
|
||||
import {
|
||||
commentsHidden,
|
||||
openStackId,
|
||||
openThreadId,
|
||||
pendingComment,
|
||||
revealThreadRequest,
|
||||
sidebarFilters,
|
||||
toggleCommentsHidden,
|
||||
usePendingComment,
|
||||
} from './state'
|
||||
import { ThreadPin } from './thread-pin'
|
||||
import { ThreadStackPin } from './thread-stack'
|
||||
|
||||
/**
|
||||
* The host wiring for {@link CanvasComments} — see {@link CommentingContext}, which the sidebar
|
||||
* takes the same fields from.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CanvasCommentsProps = CommentingContext
|
||||
|
||||
/**
|
||||
* Which thread's pin a fade node should draw: its own thread for a single pin, `stackOwner` (when
|
||||
* still a member) for a coincident stack, null for neither. Never the open thread — the open-thread
|
||||
* render slot draws that one, and drawing it here too (which the fade-out window would otherwise
|
||||
* do) mounts its popover twice.
|
||||
* @internal
|
||||
*/
|
||||
export function fadeNodeMarkerThreadId(
|
||||
node: ClusterNode,
|
||||
stackOwner: string | null,
|
||||
openId: string | null
|
||||
): string | null {
|
||||
const candidate =
|
||||
node.count === 1 ? node.id : stackOwner && node.members.includes(stackOwner) ? stackOwner : null
|
||||
return candidate === openId ? null : candidate
|
||||
}
|
||||
|
||||
/**
|
||||
* A ready-to-use comments layer for a tldraw canvas: pins each thread at its anchor, opens a
|
||||
* thread popover (with a reply composer) on click, and shows a composer where the comment tool
|
||||
* placed a new thread. Reads/writes comment records straight from `editor.store`.
|
||||
*
|
||||
* It's the batteries-included default: every visible piece is a slot on
|
||||
* `CommentTool.configure({ components })`, and the pieces it composes (`CommentPin`,
|
||||
* `CommentThread`, `CommentComposer`, the hooks, the tool) are all exported, so a consumer can
|
||||
* rebuild it from parts instead. The host wiring is the {@link CommentingContext}, which
|
||||
* `CanvasCommentsSidebar` takes too.
|
||||
*
|
||||
* @public @react
|
||||
*/
|
||||
export function CanvasComments(props: CanvasCommentsProps) {
|
||||
// The inner component holds every other hook, so mounting it as the license resolves keeps hook
|
||||
// order stable here.
|
||||
const commentingEnabled = useCommentingEnabled()
|
||||
if (!commentingEnabled) return null
|
||||
return <CanvasCommentsLayer {...props} />
|
||||
}
|
||||
|
||||
function CanvasCommentsLayer(props: CommentingContext) {
|
||||
const editor = useEditor()
|
||||
const options = useCommentingOptions()
|
||||
const allThreads = useCommentThreads(editor)
|
||||
const pending = usePendingComment()
|
||||
const canComment = useCanComment(props.currentUserId)
|
||||
// Nothing renders a pending comment when composing is blocked and there's no fallback slot, and
|
||||
// the dismiss handlers live inside PendingComposer — so clear the atom rather than strand it,
|
||||
// and leave the tool too: it holds while a composer is open, so staying would turn every click
|
||||
// into a silently swallowed placement.
|
||||
const canRenderComposer = canComment || options.components.ComposerFallback != null
|
||||
const showPendingComposer = pending != null && canRenderComposer
|
||||
useEffect(() => {
|
||||
if (pending && !showPendingComposer) {
|
||||
pendingComment.set(editor, null)
|
||||
if (editor.isIn('comment')) editor.setCurrentTool('select')
|
||||
}
|
||||
}, [editor, pending, showPendingComposer])
|
||||
const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
|
||||
// Matches the sidebar's `showResolved` filter. The open thread stays in — resolving from its own
|
||||
// popover shouldn't make the pin vanish under it.
|
||||
const showResolved = useValue('show resolved', () => sidebarFilters.get(editor).showResolved, [
|
||||
editor,
|
||||
])
|
||||
const threads = useMemo(
|
||||
() => allThreads.filter((t) => showResolved || t.resolved == null || t.id === openId),
|
||||
[allThreads, showResolved, openId]
|
||||
)
|
||||
useEffect(() => registerCommentAnchorLifecycle(editor), [editor])
|
||||
const {
|
||||
model: clusterModel,
|
||||
zoomBounds: clusterZoomBounds,
|
||||
fadeNodes,
|
||||
orphanThreads,
|
||||
heldThreads,
|
||||
} = useClusterModel(editor, threads, openId)
|
||||
const threadsById = useMemo(
|
||||
() => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
|
||||
[threads]
|
||||
)
|
||||
// Pins with the *same* anchor point coincide at every zoom, so they render as one count-badge
|
||||
// stack. Keyed on page-space anchors, so camera moves never recompute this. Holding the map's
|
||||
// identity while the grouping is unchanged keeps a reply from re-rendering every pin — but the
|
||||
// map has no positions in it, so anything needing a stack's *point* must read the anchors itself.
|
||||
const pinStacksRef = useRef<Map<string, readonly string[]>>(new Map())
|
||||
const pinStacks = useValue(
|
||||
'comment pin stacks',
|
||||
() => {
|
||||
const stacks = computePinStacks(editor, threads)
|
||||
if (pinStacksEqual(pinStacksRef.current, stacks)) return pinStacksRef.current
|
||||
pinStacksRef.current = stacks
|
||||
return stacks
|
||||
},
|
||||
[editor, threads]
|
||||
)
|
||||
const openThread = openId ? threadsById.get(openId) : null
|
||||
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
openThreadId.set(editor, null)
|
||||
openStackId.set(editor, null)
|
||||
pendingComment.set(editor, null)
|
||||
revealThreadRequest.set(editor, null)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Clear a stale open-stack key: only the stack's own mounted handlers clear it, so collapsing to
|
||||
// a single pin strands it — and `useMarkerPreview` reads any non-null value as "a stack is open"
|
||||
// and suppresses every hover preview. Kept while any live stack still sits at that key. Reads the
|
||||
// anchors rather than keying off `pinStacks`, whose identity survives a stack moving as a whole.
|
||||
const openStackKeyIsStale = useValue(
|
||||
'open stack key stale',
|
||||
() => {
|
||||
const key = openStackId.get(editor)
|
||||
if (!key) return false
|
||||
return !isOpenStackKeyLive(editor, key, pinStacks, threadsById)
|
||||
},
|
||||
[editor, pinStacks, threadsById]
|
||||
)
|
||||
useEffect(() => {
|
||||
if (openStackKeyIsStale) openStackId.set(editor, null)
|
||||
}, [editor, openStackKeyIsStale])
|
||||
|
||||
// The requested thread, once it (and, for a comment id, its parent thread) has synced into the
|
||||
// store; null while records are still arriving or when no request is pending.
|
||||
const requestedRevealThread = useValue(
|
||||
'requested reveal thread',
|
||||
() => {
|
||||
const id = revealThreadRequest.get(editor)
|
||||
if (!id) return null
|
||||
const record = getCommentRecord(editor, id)
|
||||
if (!record) return null
|
||||
const thread =
|
||||
record.typeName === 'comment' ? getCommentThread(editor, record.threadId) : record
|
||||
return thread?.typeName === 'comment-thread' ? thread : null
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
|
||||
// Bring a thread's pin into view (zooming to the first cluster split that unfolds it if it's
|
||||
// folded into a badge) and open it.
|
||||
const revealAndOpenThread = useCallback(
|
||||
(thread: TLCommentThread, duration?: number) => {
|
||||
revealThreadPin(editor, thread, clusterModel.table, clusterZoomBounds, options, duration)
|
||||
openThreadId.set(editor, thread.id)
|
||||
},
|
||||
[clusterModel.table, clusterZoomBounds, editor, options]
|
||||
)
|
||||
|
||||
// Serve a pending reveal request. Also unhides pins: the popover opens on the layer that hiding
|
||||
// withholds.
|
||||
useEffect(() => {
|
||||
if (!requestedRevealThread) return
|
||||
revealThreadRequest.set(editor, null)
|
||||
commentsHidden.set(editor, false)
|
||||
revealAndOpenThread(requestedRevealThread)
|
||||
}, [requestedRevealThread, revealAndOpenThread, editor])
|
||||
|
||||
// Picking a thread out of a cluster's hover preview. `openThreadId` alone would work, but would
|
||||
// cut straight there from the badge — zoom in first, the same move the badge's own click makes.
|
||||
const revealClusteredThread = useCallback(
|
||||
(thread: TLCommentThread) => revealAndOpenThread(thread, CLUSTER_EXPAND_ZOOM_MS),
|
||||
[revealAndOpenThread]
|
||||
)
|
||||
|
||||
const expandCluster = useCallback(
|
||||
(node: ClusterNode) => {
|
||||
zoomToClusterSplit(editor, clusterModel.table, clusterZoomBounds, node)
|
||||
},
|
||||
[clusterModel.table, clusterZoomBounds, editor]
|
||||
)
|
||||
|
||||
// Escape collapses the open thread. Capture-phase so it runs ahead of the editor, which would
|
||||
// otherwise cancel the tool or clear the selection.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || openThreadId.get(editor) === null) return
|
||||
// The mention picker owns Escape while it's open — let it dismiss the roster alone.
|
||||
if (isMentionPickerOpen()) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && target.closest('.tlui-cmt-editing')) return
|
||||
openThreadId.set(editor, null)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [editor])
|
||||
|
||||
// Shift+C toggles pin visibility. Physical `KeyC` so it's layout-independent, and skipped while
|
||||
// typing so it never fires from inside a composer.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && target.closest('input, textarea, [contenteditable="true"]')) return
|
||||
toggleCommentsHidden(editor)
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [editor])
|
||||
|
||||
// The signal is read above so this stays mounted and its shortcut/Escape effects keep running.
|
||||
if (hidden) return null
|
||||
|
||||
// The node's pin-stack group when every member shares one — a stack standing on its own, which
|
||||
// renders as a cascading count-badge list rather than a zoom-to-split cluster badge. The group
|
||||
// can include an open or orphan member the node's own leaves omit.
|
||||
const stackGroupOf = (node: ClusterNode): readonly string[] | null => {
|
||||
const group = pinStacks.get(node.members[0])
|
||||
if (!group) return null
|
||||
return node.members.every((id) => group.includes(id)) ? group : null
|
||||
}
|
||||
|
||||
// Which threads are on screen this render. A stack renders once, owned by its first on-screen
|
||||
// member — members arrive by different paths, so ownership can't be decided per-path.
|
||||
const renderedThreadIds = new Set<string>()
|
||||
if (options.enableClustering) {
|
||||
for (const { node } of fadeNodes) {
|
||||
if (node.count === 1) renderedThreadIds.add(node.id)
|
||||
// A pure-stack node's members aren't count-1 leaves, so register them here for the owner
|
||||
// logic to pick from.
|
||||
else if (stackGroupOf(node)) for (const id of node.members) renderedThreadIds.add(id)
|
||||
}
|
||||
for (const thread of orphanThreads) renderedThreadIds.add(thread.id)
|
||||
for (const thread of heldThreads) renderedThreadIds.add(thread.id)
|
||||
} else {
|
||||
for (const thread of threads) renderedThreadIds.add(thread.id)
|
||||
}
|
||||
if (openThread) renderedThreadIds.add(openThread.id)
|
||||
|
||||
// A coincident-stack member renders as the group's single count-badge stack (if it owns it)
|
||||
// or not at all; everything else is an ordinary pin.
|
||||
const renderThreadPin = (thread: TLCommentThread): ReactNode => {
|
||||
const group = pinStacks.get(thread.id)
|
||||
if (group) {
|
||||
const owner = group.find((id) => renderedThreadIds.has(id))
|
||||
if (owner !== thread.id) return null
|
||||
const stackThreads = group
|
||||
.map((id) => threadsById.get(id))
|
||||
.filter((t): t is TLCommentThread => t !== undefined)
|
||||
return <ThreadStackPin editor={editor} threads={stackThreads} {...props} />
|
||||
}
|
||||
return <ThreadPin editor={editor} thread={thread} {...props} />
|
||||
}
|
||||
|
||||
// Portalled rather than rendered into this component's slot: pins sit below the collaborator
|
||||
// cursors and popovers above the UI panels, and no single canvas layer spans both. The portal
|
||||
// also fixes where the layer lands among the container's children, keeping it behind the UI's
|
||||
// skip link in the tab order.
|
||||
return (
|
||||
<EditorPortal>
|
||||
{/* Wheel pass-through lives on each interactive element, not this root: the root spans the
|
||||
whole canvas, so a pin past its bottom/right edge inflates scrollHeight and the wheel
|
||||
hook's is-this-scrollable guard silently disables pass-through. */}
|
||||
<div className="tlui-cmt-canvas-layer">
|
||||
{options.enableClustering ? (
|
||||
<>
|
||||
{fadeNodes.map(({ node, phase }) => {
|
||||
let content: ReactNode
|
||||
const stackGroup = node.count > 1 ? stackGroupOf(node) : null
|
||||
if (node.count === 1) {
|
||||
const markerId = fadeNodeMarkerThreadId(node, null, openId)
|
||||
const thread = markerId ? threadsById.get(markerId) : undefined
|
||||
if (!thread) return null
|
||||
content = renderThreadPin(thread)
|
||||
} else if (stackGroup) {
|
||||
// Routed through the stack's owner so the open/orphan/held slots stay deduped:
|
||||
// when the owner is one of them, that slot draws the stack and this draws nothing.
|
||||
const owner = stackGroup.find((id) => renderedThreadIds.has(id)) ?? null
|
||||
const markerId = fadeNodeMarkerThreadId(node, owner, openId)
|
||||
content = markerId ? renderThreadPin(threadsById.get(markerId)!) : null
|
||||
} else {
|
||||
content = (
|
||||
<ClusterBadge
|
||||
editor={editor}
|
||||
node={node}
|
||||
onExpand={expandCluster}
|
||||
onSelectThread={revealClusteredThread}
|
||||
threadsById={threadsById}
|
||||
currentUserId={props.currentUserId}
|
||||
resolveAuthor={props.resolveAuthor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{orphanThreads.map((thread) => (
|
||||
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
||||
))}
|
||||
{heldThreads.map((thread) => (
|
||||
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
// Clustering off: every thread renders its own pin. The open thread is excluded and
|
||||
// rendered once below, or it would mount a second, stacked pin.
|
||||
threads
|
||||
.filter((thread) => thread.id !== openId)
|
||||
.map((thread) => <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>)
|
||||
)}
|
||||
{openThread && (
|
||||
<Fragment key={`open:${openThread.id}`}>{renderThreadPin(openThread)}</Fragment>
|
||||
)}
|
||||
<RegionDraftBox editor={editor} />
|
||||
{/* Keep the region visible while composing — the drag draft is gone by now, and no thread
|
||||
exists yet, so the pending anchor is what shows the area under the open composer. */}
|
||||
{pending?.anchor.type === 'region' && showPendingComposer && (
|
||||
<RegionBox editor={editor} box={pending.anchor} />
|
||||
)}
|
||||
{pending && showPendingComposer && (
|
||||
<PendingComposer editor={editor} pending={pending} {...props} />
|
||||
)}
|
||||
</div>
|
||||
</EditorPortal>
|
||||
)
|
||||
}
|
||||
45
packages/commenting/src/canvas/comments-sidebar.test.ts
Normal file
45
packages/commenting/src/canvas/comments-sidebar.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CommentListItemProps } from '../ui/comments-list'
|
||||
import { sortSidebarRows } from './comments-sidebar'
|
||||
|
||||
function row(id: string, lastActivity: number, resolved = false) {
|
||||
return {
|
||||
item: {
|
||||
id,
|
||||
resolved,
|
||||
author: { id: 'user:1', name: 'A' },
|
||||
preview: '',
|
||||
date: '',
|
||||
} as unknown as CommentListItemProps,
|
||||
lastActivity,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sortSidebarRows', () => {
|
||||
it('orders threads by their most recent comment, not their first', () => {
|
||||
// t1 was started first but was just replied to; t2 has been quiet since it was created.
|
||||
const sorted = sortSidebarRows([row('t1', 300), row('t2', 200)])
|
||||
expect(sorted.map((r) => r.item.id)).toEqual(['t1', 't2'])
|
||||
})
|
||||
|
||||
it('sinks resolved threads below unresolved ones however recent they are', () => {
|
||||
const sorted = sortSidebarRows([row('t1', 100), row('t2', 999, true), row('t3', 200)])
|
||||
expect(sorted.map((r) => r.item.id)).toEqual(['t3', 't1', 't2'])
|
||||
})
|
||||
|
||||
it('orders resolved threads among themselves by recency too', () => {
|
||||
const sorted = sortSidebarRows([row('t1', 100, true), row('t2', 300, true)])
|
||||
expect(sorted.map((r) => r.item.id)).toEqual(['t2', 't1'])
|
||||
})
|
||||
|
||||
it('breaks activity ties by id so the list does not reshuffle', () => {
|
||||
const sorted = sortSidebarRows([row('t3', 5), row('t1', 5), row('t2', 5)])
|
||||
expect(sorted.map((r) => r.item.id)).toEqual(['t1', 't2', 't3'])
|
||||
})
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const input = [row('t1', 100), row('t2', 200)]
|
||||
sortSidebarRows(input)
|
||||
expect(input.map((r) => r.item.id)).toEqual(['t1', 't2'])
|
||||
})
|
||||
})
|
||||
208
packages/commenting/src/canvas/comments-sidebar.tsx
Normal file
208
packages/commenting/src/canvas/comments-sidebar.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { ReactNode, useCallback } from 'react'
|
||||
import { EditorPortal, TLCommentThread, useEditor, useTranslation, useValue } from 'tldraw'
|
||||
import { CommentListItemProps, CommentListItemRenderProps, CommentsList } from '../ui/comments-list'
|
||||
import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
||||
import { CommentsFilterMenu } from './comments-filter-menu'
|
||||
import { CommentsVisibilityToggle } from './comments-visibility-toggle'
|
||||
import { type CommentingContext } from './context'
|
||||
import { getCommentsByThread, useCommentThreads } from './hooks'
|
||||
import { useCommentingEnabled } from './license'
|
||||
import { useCommentingOptions } from './options'
|
||||
import { richTextToPlaintext } from './rich-text'
|
||||
import { commentsSidebarOpen, openThreadId, sidebarFilters } from './state'
|
||||
import { focusThread } from './thread-state'
|
||||
|
||||
/**
|
||||
* The host wiring for {@link CanvasCommentsSidebar}: the {@link CommentingContext} fields it reads,
|
||||
* plus the panel's own slots. A non-null `currentUserId` enables the "only your threads" filter, and
|
||||
* an `isCommentUnread` the "only unread" one. `CanvasComments` takes the same fields, so a host
|
||||
* mounting both can spread one object into each.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CanvasCommentsSidebarProps extends Pick<
|
||||
CommentingContext,
|
||||
'currentUserId' | 'resolveAuthor' | 'isCommentUnread' | 'getThreadHref'
|
||||
> {
|
||||
/** Header above the list. */
|
||||
header?: ReactNode
|
||||
/** Shown when the page has no threads. */
|
||||
empty?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A comments list panel for the current page, shown while {@link commentsSidebarOpen} is set (e.g.
|
||||
* toggled by a button). Clicking a thread brings its pin into view and opens it. Batteries-included
|
||||
* over the store (a sibling to `CanvasComments`); `CommentsList` is exported for a differently-placed
|
||||
* or always-on list.
|
||||
* @public @react
|
||||
*/
|
||||
export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
||||
const { resolveAuthor, currentUserId, isCommentUnread, header, empty, getThreadHref } = props
|
||||
// Name-only view of the resolver, for the plaintext previews (which resolve @-mentions).
|
||||
const resolveName = useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
|
||||
const editor = useEditor()
|
||||
const options = useCommentingOptions()
|
||||
const commentingEnabled = useCommentingEnabled()
|
||||
const msg = useTranslation()
|
||||
const threads = useCommentThreads(editor)
|
||||
const byThread = useValue('comments by thread', () => getCommentsByThread(editor).get(), [editor])
|
||||
const currentPageId = useValue('page id', () => editor.getCurrentPageId(), [editor])
|
||||
const open = useValue('sidebar open', () => commentsSidebarOpen.get(editor), [editor])
|
||||
const openId = useValue('open thread', () => openThreadId.get(editor), [editor])
|
||||
const filters = useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
||||
const pageNames = useValue(
|
||||
'page names',
|
||||
() => new Map(editor.getPages().map((page) => [page.id, page.name])),
|
||||
[editor]
|
||||
)
|
||||
|
||||
if (!commentingEnabled || !open) return null
|
||||
|
||||
// Page scoping is treated as scoping, not a filter: an empty page reads "no comments yet",
|
||||
// while a list emptied by the toggles below reads "nothing matches your filters".
|
||||
const pageThreads = threads.filter(
|
||||
(thread) => !filters.onlyCurrentPage || thread.pageId === currentPageId
|
||||
)
|
||||
|
||||
const rows: SidebarRow[] = pageThreads
|
||||
.filter((thread) => filters.showResolved || thread.resolved == null)
|
||||
// "Only mine" is ignored without a known user — otherwise a persisted onlyMine=true would
|
||||
// empty the list for a signed-out viewer, with the (hidden) toggle giving no way to clear it.
|
||||
.filter(
|
||||
(thread) => !filters.onlyMine || currentUserId === null || thread.createdBy === currentUserId
|
||||
)
|
||||
// "Only unread" is likewise ignored without a read-status source.
|
||||
.filter(
|
||||
(thread) =>
|
||||
!filters.onlyUnread ||
|
||||
isCommentUnread === undefined ||
|
||||
(byThread.get(thread.id) ?? []).some((c) => isCommentUnread(c.id))
|
||||
)
|
||||
.map((thread) => {
|
||||
const threadComments = byThread.get(thread.id) ?? []
|
||||
const first = threadComments[0]
|
||||
// Comments arrive oldest-first, so the last one is the thread's most recent activity.
|
||||
const last = threadComments[threadComments.length - 1]
|
||||
let preview: ReactNode = ''
|
||||
// The `ThreadPreview` component slot overrides the built-in plaintext default.
|
||||
const ThreadPreview = options.components.ThreadPreview
|
||||
if (first) {
|
||||
preview = ThreadPreview ? (
|
||||
<ThreadPreview comment={first} />
|
||||
) : (
|
||||
richTextToPlaintext(first.body, resolveName)
|
||||
)
|
||||
}
|
||||
return {
|
||||
item: {
|
||||
id: thread.id,
|
||||
author: resolveAuthor(thread.createdBy) ?? UNKNOWN_COMMENT_AUTHOR,
|
||||
preview,
|
||||
date: new Date((first ?? thread).createdAt).toISOString(),
|
||||
resolved: thread.resolved != null,
|
||||
// The page label only earns its place when it adds information: multiple pages
|
||||
// exist, and the thread is somewhere other than where you already are.
|
||||
page:
|
||||
pageNames.size > 1 && thread.pageId !== currentPageId
|
||||
? pageNames.get(thread.pageId)
|
||||
: undefined,
|
||||
count: threadComments.length,
|
||||
selected: openId === thread.id,
|
||||
href: getThreadHref?.(thread.id),
|
||||
},
|
||||
lastActivity: (last ?? thread).createdAt,
|
||||
}
|
||||
})
|
||||
|
||||
const items = sortSidebarRows(rows).map((row) => row.item)
|
||||
|
||||
const focus = (id: string) => {
|
||||
const thread = threads.find((t) => t.id === id)
|
||||
if (thread) focusThread(editor, thread)
|
||||
}
|
||||
|
||||
// The `ThreadRow` component slot overrides the built-in row. It gets the thread record alongside
|
||||
// the summarized row, so host state on `thread.meta` is in reach without a second lookup. Indexed
|
||||
// once rather than scanned per row — this runs for every row on every render of the panel.
|
||||
const ThreadRow = options.components.ThreadRow
|
||||
const renderItem = ThreadRow
|
||||
? (() => {
|
||||
// Keyed by plain string: a row's id comes back as one, not as a branded thread id.
|
||||
const threadsById = new Map<string, TLCommentThread>(
|
||||
threads.map((thread) => [thread.id, thread])
|
||||
)
|
||||
return (rowProps: CommentListItemRenderProps) => {
|
||||
const thread = threadsById.get(rowProps.id)
|
||||
// Can't happen — the items are built from these threads — but a row with no thread has
|
||||
// nothing to render, and the slot's contract says the record is there.
|
||||
if (!thread) return null
|
||||
return <ThreadRow {...rowProps} thread={thread} />
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<SidebarPanel>
|
||||
<CommentsList
|
||||
items={items}
|
||||
header={header ?? msg('comments.title')}
|
||||
headerAction={
|
||||
<div className="tlui-cmt-list__header-actions">
|
||||
<CommentsFilterMenu
|
||||
canFilterByAuthor={currentUserId !== null}
|
||||
canFilterByUnread={isCommentUnread !== undefined}
|
||||
/>
|
||||
<CommentsVisibilityToggle />
|
||||
</div>
|
||||
}
|
||||
empty={
|
||||
items.length === 0 && pageThreads.length > 0
|
||||
? msg('comments.empty-filtered')
|
||||
: (empty ?? msg('comments.empty'))
|
||||
}
|
||||
resolvedLabel={msg('comments.resolved')}
|
||||
onSelect={focus}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</SidebarPanel>
|
||||
)
|
||||
}
|
||||
|
||||
/** A list row paired with the sort key that isn't part of what the row displays. @public */
|
||||
export interface SidebarRow {
|
||||
item: CommentListItemProps
|
||||
/** When the thread's most recent comment was posted — what the list orders by. */
|
||||
lastActivity: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the list: unresolved threads first, then by most recent activity, id as a stable tiebreak.
|
||||
* Recency is the thread's *latest* comment, not its first, so a thread someone just replied to rises
|
||||
* to the top instead of staying wherever it was started. (The row still shows the thread's opening
|
||||
* comment and its date — that's what identifies the thread; only the ordering follows the replies.)
|
||||
*
|
||||
* Exported so a hand-built list can match the sidebar's ordering instead of re-deriving it.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function sortSidebarRows(rows: readonly SidebarRow[]): readonly SidebarRow[] {
|
||||
return [...rows].sort(
|
||||
(a, b) =>
|
||||
Number(!!a.item.resolved) - Number(!!b.item.resolved) ||
|
||||
b.lastActivity - a.lastActivity ||
|
||||
(a.item.id < b.item.id ? -1 : 1)
|
||||
)
|
||||
}
|
||||
|
||||
/** The sidebar surface, portaled into the container. It scrolls its own list, so — unlike tldraw's
|
||||
* wheel-transparent panels — a wheel over it doesn't pan the canvas. */
|
||||
function SidebarPanel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<EditorPortal>
|
||||
<div className="tlui-cmt-canvas-sidebar" onContextMenu={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</EditorPortal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TldrawUiButton, useEditor, useTranslation, useValue } from 'tldraw'
|
||||
import { EyeClosedIcon, EyeOpenIcon } from '../ui/icons'
|
||||
import { commentsHidden, toggleCommentsHidden } from './state'
|
||||
|
||||
/** The sidebar header's show/hide toggle for comment pins — an eye that closes while comments
|
||||
* are hidden. The same state as the Shift+C shortcut.
|
||||
* @public @react */
|
||||
export function CommentsVisibilityToggle() {
|
||||
const editor = useEditor()
|
||||
const msg = useTranslation()
|
||||
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
||||
const label = hidden ? msg('comments.show') : msg('comments.hide')
|
||||
|
||||
return (
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={label}
|
||||
title={label}
|
||||
className="tlui-cmt-header-btn"
|
||||
onClick={() => toggleCommentsHidden(editor)}
|
||||
>
|
||||
{hidden ? <EyeClosedIcon /> : <EyeOpenIcon />}
|
||||
</TldrawUiButton>
|
||||
)
|
||||
}
|
||||
48
packages/commenting/src/canvas/context.ts
Normal file
48
packages/commenting/src/canvas/context.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { type CommentAuthor, type MentionMember } from '@tldraw/mentions'
|
||||
import { type ReactNode } from 'react'
|
||||
import { type TLComment, type TLCommentId, type TLCommentThreadId } from 'tldraw'
|
||||
|
||||
/**
|
||||
* The live, host-supplied half of commenting: who the viewer is, how author ids become names, read
|
||||
* status, and the mention roster. (The static half is {@link CommentingOptions}, configured once on
|
||||
* the tool.)
|
||||
*
|
||||
* Every commenting surface reads the same set, so a host mounting more than one — `CanvasComments`
|
||||
* and `CanvasCommentsSidebar`, say — can build it once and spread it into each rather than
|
||||
* repeating the props:
|
||||
*
|
||||
* ```tsx
|
||||
* const commenting: CommentingContext = { currentUserId, resolveAuthor, isCommentUnread }
|
||||
*
|
||||
* <CanvasComments {...commenting} />
|
||||
* <CanvasCommentsSidebar {...commenting} />
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CommentingContext {
|
||||
/** The signed-in user's id, or null for a read-only viewer. Only a signed-in user composes. */
|
||||
currentUserId: string | null
|
||||
/** Map an author id to their display info, or `undefined` when the id can't be resolved. */
|
||||
resolveAuthor(id: string): CommentAuthor | undefined
|
||||
/** Called after any comment (a new thread's first comment, or a reply) is posted. */
|
||||
onPostComment?(comment: TLComment): void
|
||||
/** Whether a comment is unread for the current user (return true for unread). */
|
||||
isCommentUnread?(commentId: TLCommentId): boolean
|
||||
/**
|
||||
* Called with every unread comment shown to the user in an open thread popover, batched per
|
||||
* report, so hosts can record read receipts without a write per comment. Needs
|
||||
* `isCommentUnread` to know what's unread.
|
||||
*/
|
||||
onCommentsRead?(commentIds: TLCommentId[]): void
|
||||
/** Resolve the members matching an `@`-query in the composers (sync or async). */
|
||||
getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
|
||||
/** Override a mention-picker row's content. */
|
||||
renderMentionSuggestion?(member: MentionMember): ReactNode
|
||||
/**
|
||||
* The host's URL for a thread, so surfaces can link to it — e.g. the sidebar's rows render as
|
||||
* anchors when this is present, letting ctrl/cmd-click and middle-click open the thread in a
|
||||
* new tab (a plain click still selects the thread in place; the href isn't followed).
|
||||
*/
|
||||
getThreadHref?(threadId: TLCommentThreadId): string | undefined
|
||||
}
|
||||
94
packages/commenting/src/canvas/hooks.test.ts
Normal file
94
packages/commenting/src/canvas/hooks.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
commentSchemaRecords,
|
||||
createComment,
|
||||
createCommentThread,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
toRichText,
|
||||
} from 'tldraw'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { putCommentRecords } from './comment-mutations'
|
||||
import { getCommentsByThread } from './hooks'
|
||||
|
||||
let editors: Editor[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const editor of editors) editor.dispose()
|
||||
editors = []
|
||||
})
|
||||
|
||||
function makeEditor(): Editor {
|
||||
const editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: defaultTools,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
editors.push(editor)
|
||||
return editor
|
||||
}
|
||||
|
||||
function makeThreadWithComment(editor: Editor, text: string) {
|
||||
const pageId = editor.getCurrentPageId()
|
||||
const thread = createCommentThread({
|
||||
pageId,
|
||||
anchor: { type: 'point', x: 0, y: 0 },
|
||||
createdBy: 'user1',
|
||||
})
|
||||
const comment = createComment({
|
||||
threadId: thread.id,
|
||||
pageId,
|
||||
authorId: 'user1',
|
||||
body: toRichText(text),
|
||||
})
|
||||
return { thread, comment }
|
||||
}
|
||||
|
||||
describe('getCommentsByThread', () => {
|
||||
it('groups live comments by thread, oldest first', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThreadWithComment(editor, 'first')
|
||||
const reply = createComment({
|
||||
threadId: thread.id,
|
||||
pageId: editor.getCurrentPageId(),
|
||||
authorId: 'user2',
|
||||
body: toRichText('second'),
|
||||
now: comment.createdAt + 1,
|
||||
})
|
||||
// Inserted newest-first to prove the grouping sorts by createdAt rather than store order.
|
||||
putCommentRecords(editor, [thread, reply, comment])
|
||||
expect(getCommentsByThread(editor).get().get(thread.id)).toEqual([comment, reply])
|
||||
})
|
||||
|
||||
it("reuses a thread's array when another thread's comments change", () => {
|
||||
const editor = makeEditor()
|
||||
const a = makeThreadWithComment(editor, 'thread a')
|
||||
const b = makeThreadWithComment(editor, 'thread b')
|
||||
putCommentRecords(editor, [a.thread, a.comment, b.thread, b.comment])
|
||||
|
||||
const byThread = getCommentsByThread(editor)
|
||||
const before = byThread.get()
|
||||
|
||||
const replyToB = createComment({
|
||||
threadId: b.thread.id,
|
||||
pageId: editor.getCurrentPageId(),
|
||||
authorId: 'user2',
|
||||
body: toRichText('reply'),
|
||||
now: b.comment.createdAt + 1,
|
||||
})
|
||||
putCommentRecords(editor, [replyToB])
|
||||
const after = byThread.get()
|
||||
|
||||
expect(after).not.toBe(before)
|
||||
// The unchanged thread keeps its exact array, so its subscribers don't re-render...
|
||||
expect(after.get(a.thread.id)).toBe(before.get(a.thread.id))
|
||||
// ...while the changed thread gets a fresh one with the reply in place.
|
||||
expect(after.get(b.thread.id)).not.toBe(before.get(b.thread.id))
|
||||
expect(after.get(b.thread.id)).toEqual([b.comment, replyToB])
|
||||
})
|
||||
})
|
||||
83
packages/commenting/src/canvas/hooks.ts
Normal file
83
packages/commenting/src/canvas/hooks.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
computed,
|
||||
Computed,
|
||||
Editor,
|
||||
isUninitialized,
|
||||
TLComment,
|
||||
TLCommentThread,
|
||||
TLCommentThreadId,
|
||||
useValue,
|
||||
WeakCache,
|
||||
} from 'tldraw'
|
||||
import { getLiveComments, getLiveCommentThreads } from './comment-store'
|
||||
|
||||
/**
|
||||
* The comment threads that should render (pins, sidebar), reactively — the live set described by
|
||||
* {@link getLiveCommentThreads}. Use `getCommentThreads` for the unfiltered set, including
|
||||
* soft-deleted threads awaiting the server's prune.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useCommentThreads(editor: Editor): TLCommentThread[] {
|
||||
return useValue('comment threads', () => getLiveCommentThreads(editor), [editor])
|
||||
}
|
||||
|
||||
/**
|
||||
* Every live comment grouped by thread, oldest first within each thread. One computed shared by
|
||||
* every mounted thread: each `useThreadComments` would otherwise scan and sort the whole comment
|
||||
* set, so N open threads did N full passes on every comment mutation. Cached per editor so the
|
||||
* grouping survives re-renders and is rebuilt only when the comment set actually changes.
|
||||
*/
|
||||
const commentsByThread = new WeakCache<Editor, Computed<Map<TLCommentThreadId, TLComment[]>>>()
|
||||
|
||||
/** Live comments grouped by thread id, cached per editor. @internal */
|
||||
export function getCommentsByThread(editor: Editor) {
|
||||
return commentsByThread.get(editor, () =>
|
||||
computed('comments by thread', (prev) => {
|
||||
const byThread = new Map<TLCommentThreadId, TLComment[]>()
|
||||
for (const comment of getLiveComments(editor)) {
|
||||
const existing = byThread.get(comment.threadId)
|
||||
if (existing) existing.push(comment)
|
||||
else byThread.set(comment.threadId, [comment])
|
||||
}
|
||||
for (const [threadId, comments] of byThread) {
|
||||
comments.sort((a, b) => a.createdAt - b.createdAt)
|
||||
// Reuse the previous per-thread array when that thread's comment list is unchanged
|
||||
// (record identities are stable while unchanged), so `useThreadComments` subscribers
|
||||
// on other threads don't re-render when one thread gains a reply.
|
||||
if (isUninitialized(prev)) continue
|
||||
const prevComments = prev.get(threadId)
|
||||
if (
|
||||
prevComments &&
|
||||
prevComments.length === comments.length &&
|
||||
comments.every((comment, i) => prevComments[i] === comment)
|
||||
) {
|
||||
byThread.set(threadId, prevComments)
|
||||
}
|
||||
}
|
||||
return byThread
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** A thread's live comments, oldest first, reactively. @public */
|
||||
export function useThreadComments(editor: Editor, threadId: TLCommentThreadId): TLComment[] {
|
||||
return useValue(
|
||||
'thread comments',
|
||||
() => getCommentsByThread(editor).get().get(threadId) ?? EMPTY_COMMENTS,
|
||||
[editor, threadId]
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared empty result so a thread with no comments doesn't churn referential equality. */
|
||||
const EMPTY_COMMENTS: TLComment[] = []
|
||||
|
||||
/** Every live comment in the store ({@link getLiveComments}), oldest first, reactively. Group by
|
||||
* `threadId` for per-thread lists. @public */
|
||||
export function useComments(editor: Editor): TLComment[] {
|
||||
return useValue(
|
||||
'all comments',
|
||||
() => getLiveComments(editor).sort((a, b) => a.createdAt - b.createdAt),
|
||||
[editor]
|
||||
)
|
||||
}
|
||||
17
packages/commenting/src/canvas/license.ts
Normal file
17
packages/commenting/src/canvas/license.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useLicenseFeatureFlag, useMaybeLicenseManager } from 'tldraw'
|
||||
|
||||
/**
|
||||
* Whether commenting is licensed for this editor. Enabled in development; in production it requires
|
||||
* a tldraw license that includes the commenting feature (or the collaboration umbrella that grants
|
||||
* it). Reactive: re-reads when license validation resolves, and returns `false` while validation is
|
||||
* pending, so gated UI stays hidden until the license is confirmed. Works outside `<Tldraw />` too:
|
||||
* UI mounted via `EditorProvider` resolves the license through the editor, and with no editor at
|
||||
* all this is `false`.
|
||||
*
|
||||
* The built-in commenting components (`CanvasComments`, `CanvasCommentsSidebar`, and the comment
|
||||
* tool's Quick Action) gate on this. Use it to gate any custom commenting UI the same way.
|
||||
* @public
|
||||
*/
|
||||
export function useCommentingEnabled(): boolean {
|
||||
return useLicenseFeatureFlag(useMaybeLicenseManager(), 'commenting')
|
||||
}
|
||||
115
packages/commenting/src/canvas/mobile-placement.ts
Normal file
115
packages/commenting/src/canvas/mobile-placement.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { RefObject, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { PORTRAIT_BREAKPOINT, useBreakpoint, useContainer } from 'tldraw'
|
||||
import { getVisibleViewport } from '../ui/visual-viewport'
|
||||
|
||||
/**
|
||||
* Mobile mode for the commenting surfaces — the same breakpoint gate as tldraw's mobile toolbar and
|
||||
* style panel (which also honors `forceMobile` on `<Tldraw>`). The commenting layer can mount
|
||||
* without tldraw's default UI (`hideUi`, custom UI), where the breakpoint provider is absent and
|
||||
* `useBreakpoint` throws; fall back to desktop there, matching the pre-mobile rendering those hosts
|
||||
* always had.
|
||||
*/
|
||||
export function useIsMobileCommenting(): boolean {
|
||||
let breakpoint: number
|
||||
try {
|
||||
// The call is unconditional — the try only guards the provider's absence, not hook order.
|
||||
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
||||
breakpoint = useBreakpoint()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 8
|
||||
|
||||
/**
|
||||
* Keep a canvas-anchored panel — the pending composer or an open thread popover — within the
|
||||
* visible viewport on mobile. The software keyboard shrinks the *visual* viewport while leaving the
|
||||
* layout viewport (and CSS `dvh`) untouched, so a panel placed at a fixed offset from its pin can
|
||||
* end up behind the keyboard. On mobile this clamps the panel's top-left into the visible box so it
|
||||
* stays on-screen above the keyboard. Desktop is untouched: when `enabled` is false the base point
|
||||
* is returned unchanged, tracking the camera exactly as before.
|
||||
*
|
||||
* `base` is the panel's desired top-left in container-relative viewport coordinates (what the call
|
||||
* site would otherwise write straight into `left`/`top`).
|
||||
*/
|
||||
export function useMobilePlacement(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
base: { x: number; y: number },
|
||||
enabled: boolean
|
||||
): { left: number; top: number } {
|
||||
const container = useContainer()
|
||||
// Destructured so the effects below depend on the two numbers rather than the object, which the
|
||||
// call sites build fresh on every render.
|
||||
const { x: baseX, y: baseY } = base
|
||||
const [placed, setPlaced] = useState<{ left: number; top: number }>(() => ({
|
||||
left: baseX,
|
||||
top: baseY,
|
||||
}))
|
||||
// The camera moves `base` every frame while panning. Reading it from a ref keeps `update` stable
|
||||
// across those frames, so the observers below are set up once per panel rather than being torn
|
||||
// down and rebuilt each frame.
|
||||
const baseRef = useRef({ x: baseX, y: baseY })
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (!enabled) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const win = container.ownerDocument.defaultView ?? window
|
||||
const { x, y } = baseRef.current
|
||||
|
||||
// Panel coordinates are container-relative; the visual viewport is window-relative.
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const vp = getVisibleViewport(win)
|
||||
const top = vp.top - cRect.top + VIEWPORT_MARGIN
|
||||
const bottom = vp.bottom - cRect.top - VIEWPORT_MARGIN
|
||||
const left = vp.left - cRect.left + VIEWPORT_MARGIN
|
||||
const right = vp.right - cRect.left - VIEWPORT_MARGIN
|
||||
|
||||
const w = el.offsetWidth
|
||||
const h = el.offsetHeight
|
||||
|
||||
const nextLeft = Math.max(left, Math.min(x, right - w))
|
||||
// The keyboard only ever covers space from the bottom, so it can pull the panel up (when its
|
||||
// bottom would be hidden) but must never push it down below its natural spot. `y` is the
|
||||
// floor: any spurious rise in `top` (e.g. iOS scrolling the page to reveal a focused input)
|
||||
// is capped here, so a panel that already clears the keyboard doesn't move at all.
|
||||
const nextTop = Math.min(y, Math.max(top, bottom - h))
|
||||
setPlaced((prev) =>
|
||||
prev.left === nextLeft && prev.top === nextTop ? prev : { left: nextLeft, top: nextTop }
|
||||
)
|
||||
}, [container, ref, enabled])
|
||||
|
||||
// Re-place as the camera moves the panel's base point.
|
||||
useLayoutEffect(() => {
|
||||
baseRef.current = { x: baseX, y: baseY }
|
||||
update()
|
||||
}, [baseX, baseY, update])
|
||||
|
||||
// Re-place when the panel grows (replies, edits), when the visual viewport changes (keyboard,
|
||||
// pinch-zoom), or when the window resizes.
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const win = container.ownerDocument.defaultView ?? window
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
const vv = win.visualViewport
|
||||
vv?.addEventListener('resize', update)
|
||||
vv?.addEventListener('scroll', update)
|
||||
win.addEventListener('resize', update)
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
vv?.removeEventListener('resize', update)
|
||||
vv?.removeEventListener('scroll', update)
|
||||
win.removeEventListener('resize', update)
|
||||
}
|
||||
}, [container, ref, enabled, update])
|
||||
|
||||
// Desktop keeps its fixed placement, recomputed each render so it tracks the pin as the camera
|
||||
// moves.
|
||||
if (!enabled) return { left: baseX, top: baseY }
|
||||
return placed
|
||||
}
|
||||
299
packages/commenting/src/canvas/options.test.ts
Normal file
299
packages/commenting/src/canvas/options.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { createComment, createCommentThread, toRichText, type Editor, type TLPageId } from 'tldraw'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { commitCommentMutation } from './comment-mutations'
|
||||
import { CommentTool } from './comment-tool'
|
||||
import {
|
||||
defaultCanModifyComment,
|
||||
defaultCommentingOptions,
|
||||
getCanComment,
|
||||
getCanModifyComment,
|
||||
getCommentingOptions,
|
||||
type CommentingOptions,
|
||||
type CommentModificationContext,
|
||||
} from './options'
|
||||
import { openThreadId, pendingComment } from './state'
|
||||
|
||||
// The StateNode constructor doesn't call any editor methods, so a bare stub is enough to
|
||||
// instantiate a configured tool and read its merged options.
|
||||
function optionsOf(Tool: typeof CommentTool): CommentingOptions {
|
||||
return new Tool({} as Editor).options
|
||||
}
|
||||
|
||||
describe('CommentTool.configure', () => {
|
||||
it('returns default options when unconfigured', () => {
|
||||
expect(new CommentTool({} as Editor).options).toEqual(defaultCommentingOptions)
|
||||
})
|
||||
|
||||
it('merges overrides over the defaults', () => {
|
||||
const Tool = CommentTool.configure({ history: 'record', enableClustering: false })
|
||||
expect(optionsOf(Tool)).toEqual({
|
||||
...defaultCommentingOptions,
|
||||
history: 'record',
|
||||
enableClustering: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('layers chained configure calls', () => {
|
||||
const Tool = CommentTool.configure({ history: 'record' }).configure({ enableClustering: false })
|
||||
expect(optionsOf(Tool)).toEqual({
|
||||
...defaultCommentingOptions,
|
||||
history: 'record',
|
||||
enableClustering: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('layers component slots across chained configure calls', () => {
|
||||
const CommentBody = () => null
|
||||
const PinContent = () => null
|
||||
const Tool = CommentTool.configure({ components: { CommentBody } }).configure({
|
||||
components: { PinContent },
|
||||
})
|
||||
// The second call's slot is added without dropping the first call's slot.
|
||||
expect(optionsOf(Tool).components).toEqual({ CommentBody, PinContent })
|
||||
})
|
||||
|
||||
it('does not mutate the base tool or the defaults', () => {
|
||||
CommentTool.configure({ history: 'record' })
|
||||
expect(new CommentTool({} as Editor).options).toEqual(defaultCommentingOptions)
|
||||
expect(defaultCommentingOptions.history).toBe('ignore')
|
||||
})
|
||||
})
|
||||
|
||||
// A minimal editor stub: getCommentingOptions reads the comment tool's `options` off
|
||||
// getStateDescendant, and commitCommentMutation forwards to run().
|
||||
function stubEditor(options: CommentingOptions) {
|
||||
const runCalls: Array<{ history: unknown }> = []
|
||||
const editor = {
|
||||
getStateDescendant: () => ({ options }),
|
||||
run: (fn: () => void, opts: { history: unknown }) => {
|
||||
runCalls.push(opts)
|
||||
fn()
|
||||
return editor
|
||||
},
|
||||
} as unknown as Editor
|
||||
return { editor, runCalls }
|
||||
}
|
||||
|
||||
describe('getCommentingOptions', () => {
|
||||
it('reads the tool options off the editor', () => {
|
||||
const options = { ...defaultCommentingOptions, history: 'record' } as CommentingOptions
|
||||
const { editor } = stubEditor(options)
|
||||
expect(getCommentingOptions(editor)).toBe(options)
|
||||
})
|
||||
|
||||
it('falls back to defaults when the comment tool is absent', () => {
|
||||
const editor = { getStateDescendant: () => undefined } as unknown as Editor
|
||||
expect(getCommentingOptions(editor)).toBe(defaultCommentingOptions)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCanComment', () => {
|
||||
it('defaults to requiring a current user', () => {
|
||||
const { editor } = stubEditor(defaultCommentingOptions)
|
||||
expect(getCanComment(editor, 'alice')).toBe(true)
|
||||
expect(getCanComment(editor, null)).toBe(false)
|
||||
expect(getCanComment(editor, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes an undefined viewer to null for the callback', () => {
|
||||
const calls: Array<string | null> = []
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canComment: (ctx) => {
|
||||
calls.push(ctx.currentUserId)
|
||||
return false
|
||||
},
|
||||
})
|
||||
getCanComment(editor, undefined)
|
||||
expect(calls).toEqual([null])
|
||||
})
|
||||
|
||||
it('delegates to the canComment callback, passing the editor and viewer', () => {
|
||||
const calls: Array<{ editor: Editor; currentUserId: string | null }> = []
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canComment: (ctx) => {
|
||||
calls.push(ctx)
|
||||
return ctx.currentUserId === 'alice'
|
||||
},
|
||||
})
|
||||
expect(getCanComment(editor, 'alice')).toBe(true)
|
||||
expect(getCanComment(editor, 'bob')).toBe(false)
|
||||
expect(calls).toEqual([
|
||||
{ editor, currentUserId: 'alice' },
|
||||
{ editor, currentUserId: 'bob' },
|
||||
])
|
||||
})
|
||||
|
||||
it('lets the callback fully replace the signed-in default', () => {
|
||||
const { editor } = stubEditor({ ...defaultCommentingOptions, canComment: () => true })
|
||||
expect(getCanComment(editor, null)).toBe(true)
|
||||
})
|
||||
|
||||
// This is read during render, so a throwing host rule would take the comments layer with it.
|
||||
it('denies rather than throws when the callback throws', () => {
|
||||
const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canComment: () => {
|
||||
throw new Error('lookup failed')
|
||||
},
|
||||
})
|
||||
expect(getCanComment(editor, 'alice')).toBe(false)
|
||||
expect(onError).toHaveBeenCalled()
|
||||
onError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// Plain records for the permission checks — the factories are pure, so no store is involved.
|
||||
const PAGE_ID = 'page:test' as TLPageId
|
||||
|
||||
function makeThread(createdBy: string) {
|
||||
return createCommentThread({
|
||||
pageId: PAGE_ID,
|
||||
anchor: { type: 'point', x: 0, y: 0 },
|
||||
createdBy,
|
||||
})
|
||||
}
|
||||
|
||||
function makeComment(authorId: string) {
|
||||
return createComment({
|
||||
threadId: makeThread(authorId).id,
|
||||
pageId: PAGE_ID,
|
||||
authorId,
|
||||
body: toRichText('hello'),
|
||||
})
|
||||
}
|
||||
|
||||
describe('getCanModifyComment', () => {
|
||||
it('defaults to the record owner: the comment author edits and deletes', () => {
|
||||
const { editor } = stubEditor(defaultCommentingOptions)
|
||||
const comment = makeComment('alice')
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'delete-comment', comment })).toBe(true)
|
||||
expect(getCanModifyComment(editor, 'bob', { action: 'edit-comment', comment })).toBe(false)
|
||||
expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults to the thread creator for a thread delete', () => {
|
||||
const { editor } = stubEditor(defaultCommentingOptions)
|
||||
const thread = makeThread('alice')
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'delete-thread', thread })).toBe(true)
|
||||
expect(getCanModifyComment(editor, 'bob', { action: 'delete-thread', thread })).toBe(false)
|
||||
})
|
||||
|
||||
it('withholds everything from a viewer with no identity', () => {
|
||||
const { editor } = stubEditor(defaultCommentingOptions)
|
||||
const comment = makeComment('alice')
|
||||
const thread = makeThread('alice')
|
||||
expect(getCanModifyComment(editor, null, { action: 'edit-comment', comment })).toBe(false)
|
||||
expect(getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })).toBe(
|
||||
false
|
||||
)
|
||||
expect(getCanModifyComment(editor, null, { action: 'delete-thread', thread })).toBe(false)
|
||||
})
|
||||
|
||||
it('passes the editor, the viewer, and the targeted write to the callback', () => {
|
||||
const calls: CommentModificationContext[] = []
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canModifyComment: (ctx) => {
|
||||
calls.push(ctx)
|
||||
return true
|
||||
},
|
||||
})
|
||||
const comment = makeComment('alice')
|
||||
getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })
|
||||
// An undefined viewer normalizes to null, as it does for `canComment`.
|
||||
expect(calls).toEqual([{ editor, currentUserId: null, action: 'delete-comment', comment }])
|
||||
})
|
||||
|
||||
it("lets a callback widen the default: a moderator deleting anyone's comment", () => {
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canModifyComment: (ctx) =>
|
||||
(ctx.action !== 'edit-comment' && ctx.currentUserId === 'mod') ||
|
||||
defaultCanModifyComment(ctx),
|
||||
})
|
||||
const comment = makeComment('alice')
|
||||
const thread = makeThread('alice')
|
||||
expect(getCanModifyComment(editor, 'mod', { action: 'delete-comment', comment })).toBe(true)
|
||||
expect(getCanModifyComment(editor, 'mod', { action: 'delete-thread', thread })).toBe(true)
|
||||
// Widening deletion doesn't hand the moderator anyone else's edit affordance.
|
||||
expect(getCanModifyComment(editor, 'mod', { action: 'edit-comment', comment })).toBe(false)
|
||||
// The author keeps their own affordances.
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
|
||||
// Everyone else still gets nothing.
|
||||
expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a callback narrow the default', () => {
|
||||
const { editor } = stubEditor({ ...defaultCommentingOptions, canModifyComment: () => false })
|
||||
const comment = makeComment('alice')
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
|
||||
})
|
||||
|
||||
// A throw withholds the affordance rather than the whole layer, and never offers a write a
|
||||
// server enforcing the same rule would reject anyway.
|
||||
it('denies rather than throws when the callback throws', () => {
|
||||
const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canModifyComment: () => {
|
||||
throw new Error('lookup failed')
|
||||
},
|
||||
})
|
||||
const comment = makeComment('alice')
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
|
||||
expect(onError).toHaveBeenCalled()
|
||||
onError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitCommentMutation', () => {
|
||||
it('uses options.history for a mutation and returns the callback result', () => {
|
||||
const { editor, runCalls } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
history: 'record',
|
||||
} as CommentingOptions)
|
||||
const result = commitCommentMutation(editor, () => 42)
|
||||
expect(result).toBe(42)
|
||||
expect(runCalls).toEqual([{ history: 'record' }])
|
||||
})
|
||||
|
||||
it('uses dragHistory for a drag, falling back to history when unset', () => {
|
||||
const withDrag = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
history: 'ignore',
|
||||
dragHistory: 'record',
|
||||
} as CommentingOptions)
|
||||
commitCommentMutation(withDrag.editor, () => undefined, 'drag')
|
||||
expect(withDrag.runCalls).toEqual([{ history: 'record' }])
|
||||
|
||||
const noDrag = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
history: 'ignore',
|
||||
dragHistory: undefined,
|
||||
} as CommentingOptions)
|
||||
commitCommentMutation(noDrag.editor, () => undefined, 'drag')
|
||||
expect(noDrag.runCalls).toEqual([{ history: 'ignore' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('editor-scoped transient state', () => {
|
||||
it('keeps open-thread state independent per editor (multi-editor guard)', () => {
|
||||
const a = {} as Editor
|
||||
const b = {} as Editor
|
||||
openThreadId.set(a, 'thread:1')
|
||||
expect(openThreadId.get(a)).toBe('thread:1')
|
||||
expect(openThreadId.get(b)).toBe(null)
|
||||
})
|
||||
|
||||
it('keeps pending-comment state independent per editor', () => {
|
||||
const a = {} as Editor
|
||||
const b = {} as Editor
|
||||
pendingComment.set(a, { anchor: { type: 'page' }, point: { x: 0, y: 0 } })
|
||||
expect(pendingComment.get(a)).not.toBe(null)
|
||||
expect(pendingComment.get(b)).toBe(null)
|
||||
})
|
||||
})
|
||||
369
packages/commenting/src/canvas/options.ts
Normal file
369
packages/commenting/src/canvas/options.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useMemo, type ComponentType } from 'react'
|
||||
import {
|
||||
type Editor,
|
||||
type TLComment,
|
||||
type TLCommentThread,
|
||||
type TLHistoryBatchOptions,
|
||||
type TLShapeId,
|
||||
type VecLike,
|
||||
useEditor,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { type CommentListItemRenderProps } from '../ui/comments-list'
|
||||
import { isAllowedReactionEmoji, type EmojiPickerProps } from '../ui/emoji-picker'
|
||||
import { type ReactionTooltipProps } from '../ui/reaction'
|
||||
|
||||
/**
|
||||
* The gesture that's creating a shape anchor, passed to
|
||||
* {@link CommentingOptions.shouldBePrecise}: the target shape, the page point of the release, and
|
||||
* whether Alt was held.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ShapeCommentPrecisionContext {
|
||||
readonly shapeId: TLShapeId
|
||||
readonly point: VecLike
|
||||
readonly altKey: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A commenting write that belongs to someone in particular, and the record it targets — the
|
||||
* argument to {@link CommentingOptions.canModifyComment}.
|
||||
*
|
||||
* Resolving, reopening, reacting, and moving a pin aren't here: none of them is anyone's in
|
||||
* particular, so {@link CommentingOptions.canComment} is the only gate on them.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CommentModification =
|
||||
| { readonly action: 'edit-comment'; readonly comment: TLComment }
|
||||
| { readonly action: 'delete-comment'; readonly comment: TLComment }
|
||||
| { readonly action: 'delete-thread'; readonly thread: TLCommentThread }
|
||||
|
||||
/**
|
||||
* The argument to {@link CommentingOptions.canModifyComment}: which write, against which record,
|
||||
* and by whom.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CommentModificationContext = {
|
||||
readonly editor: Editor
|
||||
readonly currentUserId: string | null
|
||||
} & CommentModification
|
||||
|
||||
/**
|
||||
* Component overrides for the batteries-included comments layer. Each slot replaces a built-in
|
||||
* piece; leave a slot unset to keep its default.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CommentingComponents {
|
||||
/** A comment's body. Replaces the default rich-text `<CommentBody>`. */
|
||||
CommentBody?: ComponentType<{ comment: TLComment }>
|
||||
/** A pin's inner content. Replaces the author-initial default. */
|
||||
PinContent?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
|
||||
/** A sidebar row's preview. Replaces the plaintext default. */
|
||||
ThreadPreview?: ComponentType<{ comment: TLComment }>
|
||||
/**
|
||||
* A whole sidebar row. Replaces the default `<CommentListItem>`, which is exported — so a row
|
||||
* that only adds an unread dot or a status chip can spread these props into it. Use
|
||||
* `ThreadPreview` instead when only the preview text is changing.
|
||||
*/
|
||||
ThreadRow?: ComponentType<CommentListItemRenderProps & { thread: TLCommentThread }>
|
||||
/**
|
||||
* Extra controls in an open thread's header, added ahead of the built-in resolve and dismiss
|
||||
* buttons rather than replacing them. "Copy link" is already built in whenever the host supplies
|
||||
* `getThreadHref`.
|
||||
*/
|
||||
ThreadActions?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
|
||||
/**
|
||||
* A reaction's visual, given its token. The default renders the token string for the OS emoji
|
||||
* font. Override to draw a custom palette — an `<img>`, an SVG, anything. The token is what gets
|
||||
* stored and synced; this only controls how it's drawn.
|
||||
*/
|
||||
ReactionContent?: ComponentType<{ token: string }>
|
||||
/**
|
||||
* What the add-reaction button opens. Replaces the default `<EmojiPicker>` grid. Pairs with
|
||||
* `ReactionContent` (which draws the tokens this emits) and `isAllowedReaction` (which must
|
||||
* accept them).
|
||||
*/
|
||||
ReactionPalette?: ComponentType<EmojiPickerProps>
|
||||
/**
|
||||
* The hover affordance naming who reacted with an emoji. Receives the reactors and the pill (as
|
||||
* `children`) and owns the whole thing — box, size, shape, position. For a wording change,
|
||||
* translate the `comments.reacted-*` strings instead.
|
||||
*/
|
||||
ReactionTooltip?: ComponentType<ReactionTooltipProps>
|
||||
/**
|
||||
* Shown where a composer would sit when the viewer can't compose (see
|
||||
* {@link CommentingOptions.canComment}). `context` is the surface rendering it: an open thread
|
||||
* popover (`'thread'`) or the comment tool's placement popover (`'pending'`). Unset, those
|
||||
* surfaces render nothing.
|
||||
*/
|
||||
ComposerFallback?: ComponentType<{ context: 'pending' | 'thread' }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the commenting layer. Static config only — pass it once via
|
||||
* `CommentTool.configure({ ... })`, mirroring `ShapeUtil.configure`. Live, reactive values
|
||||
* (`currentUserId`, author resolution, read-status callbacks) are the `CommentingContext`, passed as
|
||||
* props to each commenting surface.
|
||||
*
|
||||
* For defaults, see {@link defaultCommentingOptions}.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Tldraw tools={[CommentTool.configure({ history: 'ignore', enableClustering: false })]} />
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CommentingOptions {
|
||||
// History / undo
|
||||
/**
|
||||
* How comment mutations interact with the editor undo stack. Defaults to `'ignore'` — comments
|
||||
* are deliberately not undoable (see `TLComment`). `'record'` is a multiplayer footgun: undoing
|
||||
* a delete resurrects a thread a collaborator already removed. Safe only single-player.
|
||||
*/
|
||||
readonly history: TLHistoryBatchOptions['history']
|
||||
/**
|
||||
* History mode for the pin drag-to-move re-anchor specifically. Unlike posts/edits this is a
|
||||
* spatial edit that may reasonably be undoable alongside a shape move. Defaults to `history`.
|
||||
*/
|
||||
readonly dragHistory: TLHistoryBatchOptions['history'] | undefined
|
||||
|
||||
// Feature toggles
|
||||
/** Fold nearby pins into count badges as the camera zooms out. */
|
||||
readonly enableClustering: boolean
|
||||
/**
|
||||
* Whether a user may hold several emoji reactions on one comment. `true` (the default) is the
|
||||
* Slack model: each emoji toggles independently. `false` is single-select: picking a new emoji
|
||||
* replaces the user's existing reaction. Note this is enforced client-side; the server accepts
|
||||
* per-emoji records either way.
|
||||
*/
|
||||
readonly allowMultipleReactions: boolean
|
||||
/**
|
||||
* Whether a token may be added as a reaction. Defaults to {@link isAllowedReactionEmoji}, which
|
||||
* keeps a scripted client from writing junk values the picker would never offer. Override
|
||||
* alongside a custom `ReactionPalette`. Removals aren't checked — an off-palette reaction must
|
||||
* still be clearable.
|
||||
*/
|
||||
isAllowedReaction(token: string): boolean
|
||||
/**
|
||||
* Whether dragging the comment tool out creates a region anchor — a comment attached to a
|
||||
* rectangular area, drawn as a dashed box with the pin on the corner the drag released on. Off
|
||||
* by default, where comments attach to points and shapes only and a drag trails the composer.
|
||||
*/
|
||||
readonly enableRegions: boolean
|
||||
|
||||
// Permissions
|
||||
/**
|
||||
* Whether the viewer may participate in commenting: composing, editing, deleting, resolving, and
|
||||
* moving pins. When false, {@link CommentingComponents.ComposerFallback} renders in the
|
||||
* composer's place and action affordances are hidden. Unset, participation is allowed exactly
|
||||
* when `currentUserId` is set.
|
||||
*
|
||||
* Called during render via {@link useCanComment}, so signal reads are tracked. Posting still
|
||||
* needs a `currentUserId`, so returning true for a signed-out viewer yields a composer whose
|
||||
* send button stays disabled. A callback that throws is logged and read as false, rather than
|
||||
* taking the comments layer down with it.
|
||||
*/
|
||||
readonly canComment:
|
||||
| ((ctx: { editor: Editor; currentUserId: string | null }) => boolean)
|
||||
| undefined
|
||||
/**
|
||||
* Whether the viewer may make a particular write against a particular record: editing or
|
||||
* deleting a comment, or deleting a thread. Unset, each is its record's owner's to make
|
||||
* ({@link defaultCanModifyComment}) — you edit and delete your own comments, and delete threads
|
||||
* you started. Override it to widen that (a workspace admin or moderator who may remove
|
||||
* anyone's comment) or to narrow it (no edits after an hour). Where it returns false the
|
||||
* affordance isn't rendered.
|
||||
*
|
||||
* Checked after {@link CommentingOptions.canComment}, which gates commenting as a whole: a
|
||||
* viewer who may not participate gets no action affordances at all, whatever this returns.
|
||||
*
|
||||
* Called during render via {@link useCanModifyComment}, so reactive reads (signals) are tracked.
|
||||
* A callback that throws is logged and read as false: an affordance is withheld rather than the
|
||||
* comments layer lost, and a denial is what a server enforcing the same rule would have said.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* CommentTool.configure({
|
||||
* canModifyComment: (ctx) =>
|
||||
* // Moderators may delete anything; everything else stays the owner's to do.
|
||||
* (ctx.action !== 'edit-comment' && isModerator(ctx.currentUserId)) ||
|
||||
* defaultCanModifyComment(ctx),
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
readonly canModifyComment: ((ctx: CommentModificationContext) => boolean) | undefined
|
||||
|
||||
// Anchoring
|
||||
/** Normalized (0–1) spot within a shape where imprecise shape pins sit. Default top-right. */
|
||||
readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
|
||||
/**
|
||||
* Whether a comment landing on a shape pins to the exact clicked spot, or to the shape as a
|
||||
* whole (rendered at `impreciseShapeAnchor`). Always precise by default; return `false`, or
|
||||
* decide from the context. Governs new placements only — existing anchors render as stored.
|
||||
*/
|
||||
shouldBePrecise(editor: Editor, context: ShapeCommentPrecisionContext): boolean
|
||||
|
||||
// Components
|
||||
/** Component overrides. See {@link CommentingComponents}. */
|
||||
readonly components: CommentingComponents
|
||||
}
|
||||
|
||||
/**
|
||||
* The default {@link CommentingOptions}. Override via `CommentTool.configure({ ... })`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const defaultCommentingOptions = {
|
||||
history: 'ignore',
|
||||
dragHistory: undefined,
|
||||
enableClustering: true,
|
||||
allowMultipleReactions: true,
|
||||
isAllowedReaction: isAllowedReactionEmoji,
|
||||
enableRegions: false,
|
||||
canComment: undefined,
|
||||
canModifyComment: undefined,
|
||||
impreciseShapeAnchor: { x: 1, y: 0 },
|
||||
shouldBePrecise: () => true,
|
||||
components: {},
|
||||
} as const satisfies CommentingOptions
|
||||
|
||||
/**
|
||||
* The merged {@link CommentingOptions} for an editor, read off the registered comment tool (which
|
||||
* carries them via `CommentTool.configure`). Falls back to {@link defaultCommentingOptions} when
|
||||
* the comment tool isn't registered. Usable from anywhere with an `Editor` — including the tool's
|
||||
* own state, which has no React context.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function getCommentingOptions(editor: Editor): CommentingOptions {
|
||||
const tool = editor.getStateDescendant('comment') as { options?: CommentingOptions } | undefined
|
||||
return tool?.options ?? defaultCommentingOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for {@link getCommentingOptions}. Options are fixed per editor (set at tool
|
||||
* registration), so this doesn't need to be reactive.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useCommentingOptions(): CommentingOptions {
|
||||
const editor = useEditor()
|
||||
return useMemo(() => getCommentingOptions(editor), [editor])
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a host's permission callback, denying the write if it throws.
|
||||
*
|
||||
* These are called during render, so an exception in one would take the comments layer down with
|
||||
* the answer. Denying costs an affordance, which is what a `false` would have cost anyway, and it
|
||||
* can't offer a write a server enforcing the same rule would then reject.
|
||||
*/
|
||||
function permits(option: string, check: () => boolean): boolean {
|
||||
try {
|
||||
return check()
|
||||
} catch (error) {
|
||||
console.error(`[tldraw] \`${option}\` threw, so the write is denied:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the viewer may participate in commenting, per {@link CommentingOptions.canComment}
|
||||
* (defaulting to `currentUserId != null` when unset). A callback that throws denies.
|
||||
*
|
||||
* This is a plain, untracked read — in React, use {@link useCanComment} instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function getCanComment(editor: Editor, currentUserId: string | null | undefined): boolean {
|
||||
const { canComment } = getCommentingOptions(editor)
|
||||
if (!canComment) return currentUserId != null
|
||||
return permits('canComment', () => canComment({ editor, currentUserId: currentUserId ?? null }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive React hook for {@link getCanComment}: a `canComment` callback that reads signals
|
||||
* re-evaluates when they change.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useCanComment(currentUserId: string | null | undefined): boolean {
|
||||
const editor = useEditor()
|
||||
return useValue('can comment', () => getCanComment(editor, currentUserId), [
|
||||
editor,
|
||||
currentUserId,
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* The default {@link CommentingOptions.canModifyComment}: a write is its record's owner's to make —
|
||||
* a comment's author edits and deletes it, a thread's creator deletes the thread — and a viewer
|
||||
* with no identity may make none of them.
|
||||
*
|
||||
* Exported so a callback can widen the default rather than restate it:
|
||||
* `(ctx) => isModerator(ctx.currentUserId) || defaultCanModifyComment(ctx)`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function defaultCanModifyComment(ctx: CommentModificationContext): boolean {
|
||||
const { currentUserId } = ctx
|
||||
if (!currentUserId) return false
|
||||
const owner = ctx.action === 'delete-thread' ? ctx.thread.createdBy : ctx.comment.authorId
|
||||
return owner === currentUserId
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the viewer may make a given write against a given record, per
|
||||
* {@link CommentingOptions.canModifyComment} (defaulting to {@link defaultCanModifyComment} when
|
||||
* unset). Where this is false the affordance isn't rendered.
|
||||
*
|
||||
* This is the per-record rule alone: the built-in UI additionally requires
|
||||
* {@link CommentingOptions.canComment}, since a viewer who may not participate gets no action
|
||||
* affordances at all.
|
||||
*
|
||||
* A plain, untracked read — a `canModifyComment` callback that reads signals is not observed. In
|
||||
* React, use {@link useCanModifyComment} instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function getCanModifyComment(
|
||||
editor: Editor,
|
||||
currentUserId: string | null | undefined,
|
||||
modification: CommentModification
|
||||
): boolean {
|
||||
const { canModifyComment } = getCommentingOptions(editor)
|
||||
const ctx: CommentModificationContext = {
|
||||
editor,
|
||||
currentUserId: currentUserId ?? null,
|
||||
...modification,
|
||||
}
|
||||
if (!canModifyComment) return defaultCanModifyComment(ctx)
|
||||
return permits('canModifyComment', () => canModifyComment(ctx))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive React hook for {@link getCanModifyComment}: a `canModifyComment` callback that reads
|
||||
* signals re-evaluates when they change.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useCanModifyComment(
|
||||
currentUserId: string | null | undefined,
|
||||
modification: CommentModification
|
||||
): boolean {
|
||||
const editor = useEditor()
|
||||
// Comment records are immutable, so the record itself is what changes when the thing being
|
||||
// checked changes — `modification` is a fresh object on every render and can't be a dep.
|
||||
const record =
|
||||
modification.action === 'delete-thread' ? modification.thread : modification.comment
|
||||
return useValue(
|
||||
'can modify comment',
|
||||
() => getCanModifyComment(editor, currentUserId, modification),
|
||||
[editor, currentUserId, modification.action, record]
|
||||
)
|
||||
}
|
||||
161
packages/commenting/src/canvas/pending-composer.tsx
Normal file
161
packages/commenting/src/canvas/pending-composer.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Avatar, isMentionPickerOpen } from '@tldraw/mentions'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
createComment,
|
||||
createCommentThread,
|
||||
Editor,
|
||||
EditorPortal,
|
||||
TLRichText,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { CommentComposer } from '../ui/comment-composer'
|
||||
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
||||
import { CommentPin } from '../ui/comment-pin'
|
||||
import {
|
||||
clearCommentDraft,
|
||||
getCommentDraft,
|
||||
NEW_COMMENT_DRAFT,
|
||||
saveCommentDraft,
|
||||
} from './comment-drafts'
|
||||
import { commitCommentMutation } from './comment-mutations'
|
||||
import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
||||
import { PendingComment } from './comment-tool'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useIsMobileCommenting, useMobilePlacement } from './mobile-placement'
|
||||
import { useCanComment, useCommentingOptions } from './options'
|
||||
import { pendingComment } from './state'
|
||||
|
||||
const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
|
||||
|
||||
/**
|
||||
* The composer for a thread that doesn't exist yet: the comment tool has placed a point (or
|
||||
* region) and is waiting on a first comment. Submitting creates the thread and its comment
|
||||
* together; clicking away keeps the draft for the next placement.
|
||||
*/
|
||||
export function PendingComposer({
|
||||
editor,
|
||||
pending,
|
||||
currentUserId,
|
||||
resolveAuthor,
|
||||
onPostComment,
|
||||
getMentionSuggestions,
|
||||
renderMentionSuggestion,
|
||||
}: CommentingContext & { editor: Editor; pending: PendingComment }) {
|
||||
const ComposerFallback = useCommentingOptions().components.ComposerFallback
|
||||
const canComment = useCanComment(currentUserId)
|
||||
const me = currentUserId ? resolveAuthor(currentUserId) : undefined
|
||||
// The leading pin previews the pin this draft becomes: a white pin holding the author's avatar.
|
||||
const draftAvatar = (
|
||||
<CommentPin>
|
||||
<Avatar author={me ?? UNKNOWN_COMMENT_AUTHOR} />
|
||||
</CommentPin>
|
||||
)
|
||||
// Click-away keeps the draft (saved on every change) and the next placement composer
|
||||
// restores it — the flip side of dismissing without a discard warning.
|
||||
const [text, setText] = useState<TLRichText>(
|
||||
() => getCommentDraft(NEW_COMMENT_DRAFT) ?? EMPTY_COMMENT
|
||||
)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const msg = useTranslation()
|
||||
// Over this floating panel, a scroll reaches the canvas (except where it scrolls itself).
|
||||
usePassThroughWheelEvents(ref)
|
||||
|
||||
const point = useValue('composer point', () => editor.pageToViewport(pending.point), [
|
||||
editor,
|
||||
pending.point,
|
||||
])
|
||||
// On mobile the composer floats free of the pin so it can clear the software keyboard; desktop
|
||||
// keeps it pinned to the point.
|
||||
const isMobile = useIsMobileCommenting()
|
||||
const placed = useMobilePlacement(ref, point, isMobile)
|
||||
|
||||
// Dismiss on a click anywhere outside the composer (capture-phase, ahead of stopPropagation).
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const el = ref.current
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!el || !target) return
|
||||
// A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
|
||||
// not "outside" — keep the draft open so the pick can insert.
|
||||
if (el.contains(target) || target.closest('.tlui-cmt-mention-popup')) return
|
||||
pendingComment.set(editor, null)
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
}, [editor])
|
||||
|
||||
const submit = () => {
|
||||
if (isCommentEmpty(text) || !currentUserId) return
|
||||
const comment = commitCommentMutation(editor, ({ put }) => {
|
||||
const pageId = editor.getCurrentPageId()
|
||||
const thread = createCommentThread({
|
||||
pageId,
|
||||
anchor: pending.anchor,
|
||||
createdBy: currentUserId,
|
||||
})
|
||||
const comment = createComment({
|
||||
threadId: thread.id,
|
||||
pageId,
|
||||
authorId: currentUserId,
|
||||
body: text,
|
||||
})
|
||||
put([thread, comment])
|
||||
return comment
|
||||
})
|
||||
setText(EMPTY_COMMENT)
|
||||
clearCommentDraft(NEW_COMMENT_DRAFT)
|
||||
pendingComment.set(editor, null)
|
||||
// Posting ends the placement interaction — hand the comment tool back to select. Only from
|
||||
// the settled idle state: Enter can land while a fresh press is mid-gesture (the composer
|
||||
// trails the pointer), and switching tools under a held pointer would strand the gesture.
|
||||
if (editor.isIn('comment.idle')) editor.setCurrentTool('select')
|
||||
// The host's callback is its own operation, not part of the post's history scope. It runs
|
||||
// last so a throwing host can't strand the composer holding a draft of a posted comment.
|
||||
onPostComment?.(comment)
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorPortal>
|
||||
<div
|
||||
ref={ref}
|
||||
className={[
|
||||
'tlui-cmt-canvas-composer',
|
||||
pending.anchor.type === 'region' && 'tlui-cmt-canvas-composer--region',
|
||||
!canComment && 'tlui-cmt-canvas-composer--fallback',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ left: placed.left, top: placed.top }}
|
||||
onPointerDown={stop}
|
||||
onContextMenu={stop}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
|
||||
}}
|
||||
>
|
||||
{canComment ? (
|
||||
<CommentComposer
|
||||
author={me ?? UNKNOWN_COMMENT_AUTHOR}
|
||||
placeholder={msg('comments.add-placeholder')}
|
||||
sendLabel={msg('comments.send')}
|
||||
value={text}
|
||||
onChange={(value) => {
|
||||
setText(value)
|
||||
saveCommentDraft(NEW_COMMENT_DRAFT, value)
|
||||
}}
|
||||
onSubmit={submit}
|
||||
// No user, no author for the record — dead send button.
|
||||
disabled={isCommentEmpty(text) || !currentUserId}
|
||||
getMentionSuggestions={getMentionSuggestions}
|
||||
renderMentionSuggestion={renderMentionSuggestion}
|
||||
autoFocus
|
||||
leading={draftAvatar}
|
||||
/>
|
||||
) : (
|
||||
ComposerFallback && <ComposerFallback context="pending" />
|
||||
)}
|
||||
</div>
|
||||
</EditorPortal>
|
||||
)
|
||||
}
|
||||
224
packages/commenting/src/canvas/pin-stacking.test.ts
Normal file
224
packages/commenting/src/canvas/pin-stacking.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Box, Mat, type Editor, type TLCommentAnchor, type TLCommentThread } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computePinStacks, isOpenStackKeyLive, pinStackKey, pinStacksEqual } from './pin-stacking'
|
||||
import { anchorPagePoint } from './thread-state'
|
||||
|
||||
const CURRENT_PAGE = 'page:one'
|
||||
const OTHER_PAGE = 'page:two'
|
||||
|
||||
function thread(
|
||||
id: string,
|
||||
anchor: TLCommentAnchor,
|
||||
opts: { pageId?: string; createdAt?: number } = {}
|
||||
): TLCommentThread {
|
||||
return {
|
||||
id,
|
||||
typeName: 'comment-thread',
|
||||
pageId: opts.pageId ?? CURRENT_PAGE,
|
||||
anchor,
|
||||
createdBy: 'user:1',
|
||||
createdAt: opts.createdAt ?? 0,
|
||||
resolved: null,
|
||||
meta: {},
|
||||
} as unknown as TLCommentThread
|
||||
}
|
||||
|
||||
/**
|
||||
* Unrotated shapes: local geometry sized by the box, placed by a translate-only page transform.
|
||||
* No comment tool registered, so the commenting options `anchorPagePoint` reads are the defaults.
|
||||
*/
|
||||
function stubEditor(
|
||||
shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {}
|
||||
): Editor {
|
||||
const shapeId = (shape: string | { id: string }) => (typeof shape === 'string' ? shape : shape.id)
|
||||
return {
|
||||
getCurrentPageId: () => CURRENT_PAGE,
|
||||
getStateDescendant: () => undefined,
|
||||
getShape: (id: string) => (shapes[id] ? { id } : undefined),
|
||||
getShapeGeometry: (shape: string | { id: string }) => {
|
||||
const bounds = shapes[shapeId(shape)]
|
||||
return { bounds: new Box(0, 0, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) }
|
||||
},
|
||||
getShapePageTransform: (shape: string | { id: string }) => {
|
||||
const bounds = shapes[shapeId(shape)]
|
||||
return bounds ? Mat.Translate(bounds.minX, bounds.minY) : undefined
|
||||
},
|
||||
} as unknown as Editor
|
||||
}
|
||||
|
||||
const SHAPE = { 'shape:a': { minX: 0, minY: 0, maxX: 200, maxY: 100 } }
|
||||
/** The same shape after someone moved it — same size, different page position. */
|
||||
const SHAPE_MOVED = { 'shape:a': { minX: 500, minY: 300, maxX: 700, maxY: 400 } }
|
||||
|
||||
function impreciseAnchor(shapeId: string): TLCommentAnchor {
|
||||
return { type: 'shape', shapeId, x: 0.2, y: 0.9, isPrecise: false } as TLCommentAnchor
|
||||
}
|
||||
|
||||
/** The stack key the pins on `shape:a` coincide at, for the given shape placement. */
|
||||
function stackKeyOn(shapes: typeof SHAPE): string {
|
||||
return pinStackKey(anchorPagePoint(stubEditor(shapes), impreciseAnchor('shape:a'))!)
|
||||
}
|
||||
|
||||
describe('computePinStacks', () => {
|
||||
it('groups coincident imprecise pins on one shape, oldest first', () => {
|
||||
const stacks = computePinStacks(stubEditor(SHAPE), [
|
||||
thread('t2', impreciseAnchor('shape:a'), { createdAt: 20 }),
|
||||
thread('t1', impreciseAnchor('shape:a'), { createdAt: 10 }),
|
||||
])
|
||||
expect(stacks.get('t1')).toEqual(['t1', 't2'])
|
||||
expect(stacks.get('t2')).toEqual(['t1', 't2'])
|
||||
})
|
||||
|
||||
it('leaves separated pins ungrouped', () => {
|
||||
const stacks = computePinStacks(stubEditor(), [
|
||||
thread('t1', { type: 'point', x: 0, y: 0 }),
|
||||
thread('t2', { type: 'point', x: 50, y: 0 }),
|
||||
])
|
||||
expect(stacks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('groups coincident point anchors', () => {
|
||||
const stacks = computePinStacks(stubEditor(), [
|
||||
thread('t1', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
|
||||
thread('t2', { type: 'point', x: 5, y: 5 }, { createdAt: 2 }),
|
||||
thread('t3', { type: 'point', x: 5, y: 5 }, { createdAt: 3 }),
|
||||
])
|
||||
expect(stacks.get('t2')).toEqual(['t1', 't2', 't3'])
|
||||
})
|
||||
|
||||
it('breaks creation-time ties by id so ordering is deterministic', () => {
|
||||
const stacks = computePinStacks(stubEditor(), [
|
||||
thread('t2', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
|
||||
thread('t1', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
|
||||
])
|
||||
expect(stacks.get('t1')).toEqual(['t1', 't2'])
|
||||
})
|
||||
|
||||
it('ignores threads on other pages and unresolvable anchors', () => {
|
||||
const stacks = computePinStacks(stubEditor(SHAPE), [
|
||||
thread('t1', impreciseAnchor('shape:a')),
|
||||
thread('t2', impreciseAnchor('shape:a'), { pageId: OTHER_PAGE }),
|
||||
thread('t3', impreciseAnchor('shape:gone')),
|
||||
])
|
||||
expect(stacks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('does not group a precise pin sitting away from a coincident pair', () => {
|
||||
const precise: TLCommentAnchor = {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:a',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
isPrecise: true,
|
||||
} as TLCommentAnchor
|
||||
const stacks = computePinStacks(stubEditor(SHAPE), [
|
||||
thread('t1', impreciseAnchor('shape:a'), { createdAt: 1 }),
|
||||
thread('t2', impreciseAnchor('shape:a'), { createdAt: 2 }),
|
||||
thread('t3', precise),
|
||||
])
|
||||
expect(stacks.get('t1')).toEqual(['t1', 't2'])
|
||||
expect(stacks.has('t3')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pinStacksEqual', () => {
|
||||
const pair = () =>
|
||||
new Map<string, readonly string[]>([
|
||||
['t1', ['t1', 't2']],
|
||||
['t2', ['t1', 't2']],
|
||||
])
|
||||
|
||||
it('accepts equal groupings whatever the map or group identity', () => {
|
||||
const a = pair()
|
||||
expect(pinStacksEqual(a, a)).toBe(true)
|
||||
expect(pinStacksEqual(a, pair())).toBe(true)
|
||||
expect(pinStacksEqual(new Map(), new Map())).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a changed member set, a changed group, and a changed order', () => {
|
||||
// the stack broke up
|
||||
expect(pinStacksEqual(pair(), new Map())).toBe(false)
|
||||
// a third pin joined
|
||||
expect(
|
||||
pinStacksEqual(
|
||||
pair(),
|
||||
new Map<string, readonly string[]>([
|
||||
['t1', ['t1', 't2', 't3']],
|
||||
['t2', ['t1', 't2', 't3']],
|
||||
['t3', ['t1', 't2', 't3']],
|
||||
])
|
||||
)
|
||||
).toBe(false)
|
||||
// same size, different members
|
||||
expect(
|
||||
pinStacksEqual(
|
||||
pair(),
|
||||
new Map<string, readonly string[]>([
|
||||
['t1', ['t1', 't3']],
|
||||
['t3', ['t1', 't3']],
|
||||
])
|
||||
)
|
||||
).toBe(false)
|
||||
// same members, reordered within the group (the list renders in this order)
|
||||
expect(
|
||||
pinStacksEqual(
|
||||
pair(),
|
||||
new Map<string, readonly string[]>([
|
||||
['t1', ['t2', 't1']],
|
||||
['t2', ['t2', 't1']],
|
||||
])
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('is blind to position — equal groupings can sit at different page points', () => {
|
||||
// The overlay holds the map's identity while this is true, which is why nothing keyed on a
|
||||
// stack's *point* may key off it. See isOpenStackKeyLive.
|
||||
const threads = [
|
||||
thread('t1', impreciseAnchor('shape:a'), { createdAt: 1 }),
|
||||
thread('t2', impreciseAnchor('shape:a'), { createdAt: 2 }),
|
||||
]
|
||||
const here = computePinStacks(stubEditor(SHAPE), threads)
|
||||
const moved = computePinStacks(stubEditor(SHAPE_MOVED), threads)
|
||||
expect(pinStacksEqual(here, moved)).toBe(true)
|
||||
expect(stackKeyOn(SHAPE)).not.toBe(stackKeyOn(SHAPE_MOVED))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOpenStackKeyLive', () => {
|
||||
const threads = [
|
||||
thread('t1', impreciseAnchor('shape:a'), { createdAt: 1 }),
|
||||
thread('t2', impreciseAnchor('shape:a'), { createdAt: 2 }),
|
||||
]
|
||||
const byId = new Map(threads.map((t) => [t.id, t]))
|
||||
|
||||
it('is live while a stack still sits at the key', () => {
|
||||
const editor = stubEditor(SHAPE)
|
||||
expect(
|
||||
isOpenStackKeyLive(editor, stackKeyOn(SHAPE), computePinStacks(editor, threads), byId)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('goes stale when the stack moves — the case pinStacksEqual cannot see', () => {
|
||||
// The anchor shape moved, so the point key changed while the membership didn't. Staying live
|
||||
// here strands the old key in `openStackId`, killing every hover preview on the layer.
|
||||
const editor = stubEditor(SHAPE_MOVED)
|
||||
expect(
|
||||
isOpenStackKeyLive(editor, stackKeyOn(SHAPE), computePinStacks(editor, threads), byId)
|
||||
).toBe(false)
|
||||
// and the key the stack moved *to* is live, so the caller clears rather than thrashing
|
||||
expect(
|
||||
isOpenStackKeyLive(editor, stackKeyOn(SHAPE_MOVED), computePinStacks(editor, threads), byId)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('goes stale once nothing stacks there, or the threads are gone', () => {
|
||||
const editor = stubEditor(SHAPE)
|
||||
// the stack collapsed to a single pin: no entries at all
|
||||
expect(isOpenStackKeyLive(editor, stackKeyOn(SHAPE), new Map(), byId)).toBe(false)
|
||||
// entries for threads that have left the input
|
||||
expect(
|
||||
isOpenStackKeyLive(editor, stackKeyOn(SHAPE), computePinStacks(editor, threads), new Map())
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
103
packages/commenting/src/canvas/pin-stacking.ts
Normal file
103
packages/commenting/src/canvas/pin-stacking.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Editor, TLCommentThread } from 'tldraw'
|
||||
import { anchorPagePoint } from './thread-state'
|
||||
|
||||
/** Two anchors within this page-space distance (per axis) share a stack. Identical imprecise
|
||||
* anchors on one shape resolve to the same point exactly; the tolerance only absorbs float noise. */
|
||||
const PIN_STACK_QUANTUM = 0.1
|
||||
|
||||
/**
|
||||
* The key a page point stacks under. Threads sharing a key are coincident. Stable identity for a
|
||||
* stack itself: it survives losing any member (the survivors keep the same key), unlike keying by
|
||||
* a particular thread id, so open-stack state stays put when the stack's oldest thread is deleted.
|
||||
*/
|
||||
export function pinStackKey(point: { x: number; y: number }): string {
|
||||
return `${Math.round(point.x / PIN_STACK_QUANTUM)}:${Math.round(point.y / PIN_STACK_QUANTUM)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Group threads whose pins land on the same page point — coincident pins (typically several
|
||||
* imprecise comments on one shape) that zooming can never separate. The overlay renders each
|
||||
* group as a single count-badge pin that opens the threads as a list. Every member id maps to
|
||||
* its group's ordered member ids (oldest first); threads without an entry pin individually.
|
||||
*
|
||||
* Keyed by page-space anchor point, not screen position, so the result only changes when threads
|
||||
* or their anchors move — never on camera moves.
|
||||
* @internal
|
||||
*/
|
||||
export function computePinStacks(
|
||||
editor: Editor,
|
||||
threads: readonly TLCommentThread[]
|
||||
): Map<string, readonly string[]> {
|
||||
const pageId = editor.getCurrentPageId()
|
||||
const groups = new Map<string, TLCommentThread[]>()
|
||||
|
||||
for (const thread of threads) {
|
||||
if (thread.pageId !== pageId) continue
|
||||
const point = anchorPagePoint(editor, thread.anchor)
|
||||
if (!point) continue
|
||||
const key = pinStackKey(point)
|
||||
const group = groups.get(key)
|
||||
if (group) {
|
||||
group.push(thread)
|
||||
} else {
|
||||
groups.set(key, [thread])
|
||||
}
|
||||
}
|
||||
|
||||
const stacks = new Map<string, readonly string[]>()
|
||||
for (const group of groups.values()) {
|
||||
if (group.length < 2) continue
|
||||
group.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1))
|
||||
const ids = group.map((thread) => thread.id)
|
||||
for (const id of ids) {
|
||||
stacks.set(id, ids)
|
||||
}
|
||||
}
|
||||
return stacks
|
||||
}
|
||||
|
||||
/**
|
||||
* Value equality for pin-stack maps, so the overlay can hold the map's identity across a recompute
|
||||
* that changed no grouping. Membership only: two equal maps can describe stacks at different page
|
||||
* points, so anything keyed on a stack's *point* must read the anchors itself, as
|
||||
* {@link isOpenStackKeyLive} does.
|
||||
* @internal
|
||||
*/
|
||||
export function pinStacksEqual(
|
||||
a: ReadonlyMap<string, readonly string[]>,
|
||||
b: ReadonlyMap<string, readonly string[]>
|
||||
): boolean {
|
||||
if (a === b) return true
|
||||
if (a.size !== b.size) return false
|
||||
for (const [id, group] of b) {
|
||||
const prevGroup = a.get(id)
|
||||
if (!prevGroup) return false
|
||||
if (prevGroup === group) continue
|
||||
if (prevGroup.length !== group.length) return false
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
if (prevGroup[i] !== group[i]) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an open-stack key still names a stack on the canvas — false once that stack is emptied,
|
||||
* moved, or off the page, at which point the caller must clear the key. Reads the anchors, so a
|
||||
* reactive caller re-evaluates on a move that {@link pinStacksEqual} can't see.
|
||||
* @internal
|
||||
*/
|
||||
export function isOpenStackKeyLive(
|
||||
editor: Editor,
|
||||
key: string,
|
||||
stacks: ReadonlyMap<string, readonly string[]>,
|
||||
threadsById: ReadonlyMap<string, TLCommentThread>
|
||||
): boolean {
|
||||
for (const id of stacks.keys()) {
|
||||
const thread = threadsById.get(id)
|
||||
if (!thread) continue
|
||||
const point = anchorPagePoint(editor, thread.anchor)
|
||||
if (point && pinStackKey(point) === key) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
124
packages/commenting/src/canvas/region-box.tsx
Normal file
124
packages/commenting/src/canvas/region-box.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { type PointerEvent as ReactPointerEvent, useRef } from 'react'
|
||||
import { type BoxModel, Editor, useValue, VecLike } from 'tldraw'
|
||||
import { regionDraft } from './state'
|
||||
|
||||
/** A region's dashed box. Purely visual — a region moves by its pin and resizes from its corner
|
||||
* handles, so the box itself takes no pointer events. Positioned in viewport space, as a sibling
|
||||
* of the pins in the canvas layer. */
|
||||
export function RegionBox({ editor, box }: { editor: Editor; box: BoxModel }) {
|
||||
const rect = useValue(
|
||||
'region rect',
|
||||
() => {
|
||||
// Position from the page→viewport top-left; screen size scales with zoom, page size doesn't.
|
||||
const topLeft = editor.pageToViewport({ x: box.x, y: box.y })
|
||||
const zoom = editor.getZoomLevel()
|
||||
return { left: topLeft.x, top: topLeft.y, width: box.w * zoom, height: box.h * zoom }
|
||||
},
|
||||
[editor, box.x, box.y, box.w, box.h]
|
||||
)
|
||||
return <div className="tlui-cmt-canvas-region" style={rect} />
|
||||
}
|
||||
|
||||
/** The live region being dragged out by the comment tool, or nothing when not dragging. */
|
||||
export function RegionDraftBox({ editor }: { editor: Editor }) {
|
||||
const box = useValue('region draft', () => regionDraft.get(editor), [editor])
|
||||
if (!box) return null
|
||||
return <RegionBox editor={editor} box={box} />
|
||||
}
|
||||
|
||||
// A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 is *not*
|
||||
// controlled by that handle — the resize math reads the spot rather than special-casing corners.
|
||||
export interface RegionHandle {
|
||||
x: number
|
||||
y: number
|
||||
cursor: string
|
||||
}
|
||||
|
||||
// The four corners, each resizing both axes.
|
||||
export const REGION_CORNERS: readonly RegionHandle[] = [
|
||||
{ x: 0, y: 0, cursor: 'nwse-resize' },
|
||||
{ x: 1, y: 0, cursor: 'nesw-resize' },
|
||||
{ x: 0, y: 1, cursor: 'nesw-resize' },
|
||||
{ x: 1, y: 1, cursor: 'nwse-resize' },
|
||||
]
|
||||
|
||||
// Screen-space slack around a region's bounds within which its box and handles stay revealed, so
|
||||
// the handles (which sit on the edge) are comfortably reachable.
|
||||
export const REGION_HANDLE_MARGIN_PX = 12
|
||||
|
||||
/** Resize `box` by dragging `handle` to `cursor` (page coords). Each controlled axis spans from the
|
||||
* handle's fixed opposite edge to the cursor (normalized, so dragging past it flips); an axis the
|
||||
* handle doesn't control (a midpoint, at 0.5) keeps its original position and size. */
|
||||
function resizeRegion(box: BoxModel, handle: RegionHandle, cursor: VecLike): BoxModel {
|
||||
const controlsX = handle.x !== 0.5
|
||||
const controlsY = handle.y !== 0.5
|
||||
const fixedX = box.x + (1 - handle.x) * box.w
|
||||
const fixedY = box.y + (1 - handle.y) * box.h
|
||||
return {
|
||||
x: controlsX ? Math.min(fixedX, cursor.x) : box.x,
|
||||
y: controlsY ? Math.min(fixedY, cursor.y) : box.y,
|
||||
w: controlsX ? Math.abs(cursor.x - fixedX) : box.w,
|
||||
h: controlsY ? Math.abs(cursor.y - fixedY) : box.h,
|
||||
}
|
||||
}
|
||||
|
||||
/** Draggable handles that resize a region — corners (both axes) or edges (one axis), per the resize
|
||||
* option. Previews live, commits on release. */
|
||||
export function RegionResizeHandles({
|
||||
editor,
|
||||
box,
|
||||
handles,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
editor: Editor
|
||||
box: BoxModel
|
||||
handles: readonly RegionHandle[]
|
||||
onPreview(bounds: BoxModel | null): void
|
||||
onCommit(bounds: BoxModel): void
|
||||
}) {
|
||||
// The box at pointer-down, captured so the box prop reflowing under the live preview doesn't move
|
||||
// the fixed edges mid-drag.
|
||||
const boxRef = useRef<BoxModel | null>(null)
|
||||
const points = useValue(
|
||||
'region handle points',
|
||||
() =>
|
||||
handles.map((h) => {
|
||||
const p = editor.pageToViewport({ x: box.x + h.x * box.w, y: box.y + h.y * box.h })
|
||||
return { ...h, key: `${h.x}-${h.y}`, left: p.x, top: p.y }
|
||||
}),
|
||||
[editor, box.x, box.y, box.w, box.h, handles]
|
||||
)
|
||||
const startResize = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
boxRef.current = box
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}
|
||||
const resizedTo = (h: RegionHandle, e: ReactPointerEvent<HTMLDivElement>): BoxModel =>
|
||||
resizeRegion(boxRef.current!, h, editor.screenToPage({ x: e.clientX, y: e.clientY }))
|
||||
const onResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (boxRef.current) onPreview(resizedTo(h, e))
|
||||
}
|
||||
const endResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!boxRef.current) return
|
||||
const bounds = resizedTo(h, e)
|
||||
boxRef.current = null
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
onCommit(bounds)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{points.map((h) => (
|
||||
<div
|
||||
key={h.key}
|
||||
className="tlui-cmt-canvas-region-handle"
|
||||
style={{ left: h.left, top: h.top, cursor: h.cursor }}
|
||||
onPointerDown={startResize}
|
||||
onPointerMove={onResize(h)}
|
||||
onPointerUp={endResize(h)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
15
packages/commenting/src/canvas/rich-text.ts
Normal file
15
packages/commenting/src/canvas/rich-text.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { TLRichText } from 'tldraw'
|
||||
import { renderCommentPlaintext } from './comment-render'
|
||||
|
||||
/**
|
||||
* Flatten a rich-text comment body to plaintext through the limited comment extension set,
|
||||
* separating paragraphs and list items with newlines. A convenience for consumers rendering bodies
|
||||
* as plain text (e.g. the sidebar preview); richer rendering can read the `TLRichText` directly.
|
||||
* @public
|
||||
*/
|
||||
export function richTextToPlaintext(
|
||||
body: TLRichText,
|
||||
resolveName?: (id: string) => string | undefined
|
||||
): string {
|
||||
return renderCommentPlaintext(body, resolveName)
|
||||
}
|
||||
26
packages/commenting/src/canvas/sidebar-filters.ts
Normal file
26
packages/commenting/src/canvas/sidebar-filters.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Which threads the comments sidebar shows. Held as an editor-scoped signal (the `sidebarFilters`
|
||||
* `EditorAtom` in `./state`) rather than component state so it survives the sidebar
|
||||
* unmounting when the comment tool deactivates — a user's "hide resolved" choice shouldn't reset
|
||||
* every time they leave the tool.
|
||||
* @public
|
||||
*/
|
||||
export interface SidebarFilters {
|
||||
/** Include resolved threads. */
|
||||
showResolved: boolean
|
||||
/** Only threads the current user started. Ignored when there's no current user. */
|
||||
onlyMine: boolean
|
||||
/** Only threads with unread comments. Ignored when the host provides no read status. */
|
||||
onlyUnread: boolean
|
||||
/** Only threads on the current page. Off = every page's threads, each labelled. */
|
||||
onlyCurrentPage: boolean
|
||||
}
|
||||
|
||||
/** The out-of-the-box view: every page's threads, resolved ones hidden until asked for.
|
||||
* @public */
|
||||
export const DEFAULT_SIDEBAR_FILTERS: SidebarFilters = {
|
||||
showResolved: false,
|
||||
onlyMine: false,
|
||||
onlyUnread: false,
|
||||
onlyCurrentPage: false,
|
||||
}
|
||||
151
packages/commenting/src/canvas/state.ts
Normal file
151
packages/commenting/src/canvas/state.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { EditorAtom, type BoxModel, type Editor, useEditor, useValue } from 'tldraw'
|
||||
import type { PendingComment } from './comment-tool'
|
||||
import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
|
||||
|
||||
/**
|
||||
* Transient commenting UI state, scoped per editor via {@link EditorAtom} so two editors on one
|
||||
* page don't share open-thread, visibility, and filter state. Reachable from both React
|
||||
* (`useEditor()`) and the comment tool (`this.editor`).
|
||||
*/
|
||||
|
||||
/** The id of the one open thread (only one popover is open at a time), or null when all closed.
|
||||
* @public */
|
||||
export const openThreadId = new EditorAtom<string | null>('openThreadId', () => null)
|
||||
|
||||
/** The coincident-pin stack whose thread list is showing (keyed by its oldest member's thread id),
|
||||
* or null. Editor state rather than component state: the stack pin remounts when its owning render
|
||||
* path changes, and the open list must survive that.
|
||||
* @internal */
|
||||
export const openStackId = new EditorAtom<string | null>('openStackId', () => null)
|
||||
|
||||
/** The comment currently being placed (composer open, not yet posted), or null.
|
||||
* @internal */
|
||||
export const pendingComment = new EditorAtom<PendingComment | null>('pendingComment', () => null)
|
||||
|
||||
/**
|
||||
* A pending request to reveal a thread: a thread or comment id to open and bring into view, or
|
||||
* null when none is pending. Written by {@link revealThread}; served and cleared by
|
||||
* `CanvasComments`, which owns the wait for the records to sync in and the cluster-aware reveal.
|
||||
* @internal
|
||||
*/
|
||||
export const revealThreadRequest = new EditorAtom<string | null>('revealThreadRequest', () => null)
|
||||
|
||||
/**
|
||||
* Open a thread and bring it into view, given a thread id or the id of any comment in it. Use it to
|
||||
* jump to a thread from outside the canvas — a notification, a deep link, your own list.
|
||||
*
|
||||
* The request is served by `CanvasComments`, so it works before the records have arrived: the layer
|
||||
* waits for them, switches pages, unhides pins, and zooms far enough to split the thread out of any
|
||||
* cluster. That also means nothing happens if `CanvasComments` isn't mounted.
|
||||
*
|
||||
* To open a thread you already hold and skip the wait, see {@link focusThread}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* revealThread(editor, new URLSearchParams(location.search).get('comment')!)
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function revealThread(editor: Editor, threadOrCommentId: string): void {
|
||||
revealThreadRequest.set(editor, threadOrCommentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The id passed to the most recent {@link revealThread} call that `CanvasComments` hasn't served
|
||||
* yet, or null. A request also clears when `CanvasComments` unmounts.
|
||||
*
|
||||
* This is a plain, untracked read — in React, use {@link useRevealThreadPending}, unless you need
|
||||
* the value as of *now* rather than as of the render you closed over.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function getRevealThreadPending(editor: Editor): string | null {
|
||||
return revealThreadRequest.get(editor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive React hook for {@link getRevealThreadPending}.
|
||||
*
|
||||
* Use it to notice a reveal that never lands — usually a deep link to a deleted comment. Give it a
|
||||
* grace period first, since a request also sits here while its records sync in, and re-check with
|
||||
* {@link getRevealThreadPending} when it elapses.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useRevealThreadPending(): string | null {
|
||||
const editor = useEditor()
|
||||
return useValue('pending reveal thread', () => getRevealThreadPending(editor), [editor])
|
||||
}
|
||||
|
||||
/** The region rectangle being dragged out right now (page coords), or null when not dragging. The
|
||||
* comment tool writes it on each move; the overlay reads it to draw the live dashed box. */
|
||||
export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () => null)
|
||||
|
||||
/**
|
||||
* Whether comment pins are hidden on the canvas. Governs the on-canvas layer (pins + open popover)
|
||||
* only — the sidebar is unaffected.
|
||||
* @public
|
||||
*/
|
||||
export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
|
||||
|
||||
/**
|
||||
* Whether the comments sidebar (the thread list) is open. Driven by an explicit control rather than
|
||||
* by which tool is active, so browsing threads is separate from placing them.
|
||||
* @public
|
||||
*/
|
||||
export const commentsSidebarOpen = new EditorAtom<boolean>('commentsSidebarOpen', () => false)
|
||||
|
||||
/** Which threads the comments sidebar shows.
|
||||
* @public */
|
||||
export const sidebarFilters = new EditorAtom<SidebarFilters>(
|
||||
'sidebarFilters',
|
||||
() => DEFAULT_SIDEBAR_FILTERS
|
||||
)
|
||||
|
||||
/** Toggle comment-pin visibility for an editor.
|
||||
* @public */
|
||||
export function toggleCommentsHidden(editor: Editor): void {
|
||||
commentsHidden.update(editor, (hidden) => !hidden)
|
||||
}
|
||||
|
||||
/** Open or close the comments sidebar for an editor.
|
||||
* @public */
|
||||
export function toggleCommentsSidebar(editor: Editor): void {
|
||||
commentsSidebarOpen.update(editor, (open) => !open)
|
||||
}
|
||||
|
||||
/** React hook for the open thread id.
|
||||
* @public */
|
||||
export function useOpenThreadId(): string | null {
|
||||
const editor = useEditor()
|
||||
return useValue('open thread id', () => openThreadId.get(editor), [editor])
|
||||
}
|
||||
|
||||
/** React hook for the pending (being-placed) comment.
|
||||
* @internal */
|
||||
export function usePendingComment(): PendingComment | null {
|
||||
const editor = useEditor()
|
||||
return useValue('pending comment', () => pendingComment.get(editor), [editor])
|
||||
}
|
||||
|
||||
/** React hook for whether comment pins are hidden.
|
||||
* @public */
|
||||
export function useCommentsHidden(): boolean {
|
||||
const editor = useEditor()
|
||||
return useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
||||
}
|
||||
|
||||
/** React hook for whether the comments sidebar is open.
|
||||
* @public */
|
||||
export function useCommentsSidebarOpen(): boolean {
|
||||
const editor = useEditor()
|
||||
return useValue('comments sidebar open', () => commentsSidebarOpen.get(editor), [editor])
|
||||
}
|
||||
|
||||
/** React hook for the current sidebar filters.
|
||||
* @public */
|
||||
export function useSidebarFilters(): SidebarFilters {
|
||||
const editor = useEditor()
|
||||
return useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
||||
}
|
||||
409
packages/commenting/src/canvas/thread-pin.tsx
Normal file
409
packages/commenting/src/canvas/thread-pin.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { Avatar } from '@tldraw/mentions'
|
||||
import {
|
||||
memo,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
type BoxModel,
|
||||
Editor,
|
||||
TLCommentThread,
|
||||
useContainer,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { CommentPin } from '../ui/comment-pin'
|
||||
import { forwardPointerEventToCanvas, isCanvasPanGesture } from './canvas-events'
|
||||
import { commitCommentMutation } from './comment-mutations'
|
||||
import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useThreadComments } from './hooks'
|
||||
import { getCommentingOptions, useCanComment, useCommentingOptions } from './options'
|
||||
import {
|
||||
REGION_CORNERS,
|
||||
REGION_HANDLE_MARGIN_PX,
|
||||
RegionBox,
|
||||
RegionResizeHandles,
|
||||
} from './region-box'
|
||||
import { openThreadId } from './state'
|
||||
import { ThreadPreview, useMarkerPreview } from './thread-preview'
|
||||
import {
|
||||
anchorPagePoint,
|
||||
anchorViewportPoint,
|
||||
commentTargetShapeAt,
|
||||
impreciseShapePinInset,
|
||||
isBoxInInflatedViewport,
|
||||
isInInflatedViewport,
|
||||
REGION_PIN_CORNER,
|
||||
regionAnchorPinCorner,
|
||||
regionPinPoint,
|
||||
regionWithPinAt,
|
||||
shapeAnchorAt,
|
||||
} from './thread-state'
|
||||
import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
|
||||
|
||||
/** The opened popover has a header row the hover preview lacks, so it opens this much higher — the
|
||||
* first comment then lands where the preview's sat. Re-measure if the header height or the preview
|
||||
* panel padding changes. */
|
||||
const THREAD_HEADER_BLOCK = 36
|
||||
|
||||
/**
|
||||
* A single thread's pin: the marker at its anchor, the thread popover when it's open, the hover
|
||||
* preview, and — for a region anchor — the dashed box and its resize handles. Dragging the marker
|
||||
* re-anchors the thread.
|
||||
*
|
||||
* Memoized so a pin skips re-rendering when the layer re-renders for unrelated reasons; camera
|
||||
* tracking still works, since the pin subscribes to its own viewport position via signals.
|
||||
*/
|
||||
export const ThreadPin = memo(function ThreadPin({
|
||||
editor,
|
||||
thread,
|
||||
...props
|
||||
}: CommentingContext & {
|
||||
editor: Editor
|
||||
thread: TLCommentThread
|
||||
}) {
|
||||
const { resolveAuthor } = props
|
||||
const options = useCommentingOptions()
|
||||
const canComment = useCanComment(props.currentUserId)
|
||||
const container = useContainer()
|
||||
const msg = useTranslation()
|
||||
const comments = useThreadComments(editor, thread.id)
|
||||
// Only one thread's popover is open at a time — shared across pins via the atom.
|
||||
const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
|
||||
editor,
|
||||
thread.id,
|
||||
])
|
||||
// While dragging the marker, its page point overrides the anchor's; committed on drop.
|
||||
const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
|
||||
const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
|
||||
// Hovering the marker previews the thread's opening comment, on the delay every marker uses.
|
||||
const { previewShown, previewHandlers } = useMarkerPreview(editor, `pin:${thread.id}`)
|
||||
const previewThreads = useMemo(() => [thread], [thread])
|
||||
// The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
|
||||
// Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
|
||||
// handle never loses the affordance — the box stays `pointer-events: none`.
|
||||
const pointerInRegion = useValue(
|
||||
'pointer in region',
|
||||
() => {
|
||||
if (thread.anchor.type !== 'region' || thread.pageId !== editor.getCurrentPageId())
|
||||
return false
|
||||
const m = REGION_HANDLE_MARGIN_PX / editor.getZoomLevel()
|
||||
const p = editor.inputs.getCurrentPagePoint()
|
||||
const a = thread.anchor
|
||||
return p.x >= a.x - m && p.x <= a.x + a.w + m && p.y >= a.y - m && p.y <= a.y + a.h + m
|
||||
},
|
||||
[editor, thread.anchor, thread.pageId]
|
||||
)
|
||||
// A region's box and handles are revealed while open, mid-resize, or while the pointer is
|
||||
// within the region.
|
||||
const revealed = open || resizeBounds != null || pointerInRegion
|
||||
// A region thread's pin corner is its own (the corner its creating drag released on), with
|
||||
// the default as the fallback for older records.
|
||||
const pinCorner =
|
||||
thread.anchor.type === 'region' ? regionAnchorPinCorner(thread.anchor) : REGION_PIN_CORNER
|
||||
// A region resizes from its corners — every corner but the pin's own, which the pin owns.
|
||||
const resizeHandles = useMemo(
|
||||
() => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
|
||||
[pinCorner.x, pinCorner.y]
|
||||
)
|
||||
const dragRef = useRef<{
|
||||
startX: number
|
||||
startY: number
|
||||
moved: boolean
|
||||
// The anchor's page-space offset from the grab point, so a drag translates the pin by the
|
||||
// cursor's delta (like RegionBox's move) instead of snapping the anchor to the cursor.
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
} | null>(null)
|
||||
const markerRef = useRef<HTMLButtonElement>(null)
|
||||
// Wheel pass-through sits on the marker (which is never scrollable), not the layer root —
|
||||
// see the note on the layer.
|
||||
usePassThroughWheelEvents(markerRef)
|
||||
|
||||
// The drop-target hint is editor-global state with no automatic reset. If the pin unmounts
|
||||
// mid-drag (e.g. Shift+C hides comments), no pointer event will ever reach the drag handlers —
|
||||
// clear the hint here or it stays on the shape indefinitely.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dragRef.current) editor.setHintingShapes([])
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Clicking outside the open popover closes the thread. Capture phase + a class check rather than
|
||||
// stopPropagation, since the popover portals elsewhere in the DOM. The marker is excluded so its own
|
||||
// click-to-toggle handles it instead of this closing and the toggle reopening.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target) return
|
||||
if (target.closest('.tlui-cmt-canvas-popover')) return
|
||||
const marker = markerRef.current
|
||||
if (marker && marker.contains(target)) return
|
||||
// A press on a region's resize handle edits this thread — don't dismiss it.
|
||||
if (target.closest('.tlui-cmt-canvas-region-handle')) return
|
||||
// A click inside a menu/popover layered above us (the sidebar's filter or overflow
|
||||
// dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
|
||||
// layer; defer to its own dismissal instead of closing the thread out from under it.
|
||||
if (
|
||||
target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
|
||||
)
|
||||
return
|
||||
openThreadId.set(editor, null)
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
}, [open, editor])
|
||||
|
||||
// The pin must not unmount mid-interaction: an open thread's popover hangs off it, and a drag
|
||||
// or resize holds pointer capture on it — so those states are exempt from the viewport cull.
|
||||
const exemptFromCull = open || dragPagePoint != null || resizeBounds != null
|
||||
const point = useValue(
|
||||
'pin point',
|
||||
() => {
|
||||
if (thread.pageId !== editor.getCurrentPageId()) return null
|
||||
const point = anchorViewportPoint(editor, thread.anchor)
|
||||
if (!point) return null
|
||||
// Off-screen pins (plus a pre-mount margin) unmount rather than re-render every camera frame. A
|
||||
// region thread stays mounted while any part of its box is on screen, since the box renders here too.
|
||||
if (!exemptFromCull) {
|
||||
const visible =
|
||||
thread.anchor.type === 'region'
|
||||
? isBoxInInflatedViewport(editor, thread.anchor)
|
||||
: isInInflatedViewport(editor, point)
|
||||
if (!visible) return null
|
||||
}
|
||||
return point
|
||||
},
|
||||
[editor, thread.anchor, thread.pageId, exemptFromCull]
|
||||
)
|
||||
if (!point) return null
|
||||
|
||||
const toggleOpen = () =>
|
||||
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
||||
|
||||
const PinContent = options.components.PinContent
|
||||
const threadAuthor = resolveAuthor(thread.createdBy)
|
||||
const pinContent = PinContent ? (
|
||||
<PinContent thread={thread} comments={comments} />
|
||||
) : (
|
||||
<Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
|
||||
)
|
||||
const pinLabel = msg(
|
||||
thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
|
||||
).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
|
||||
|
||||
// Drag the marker to move the thread: position is overridden locally while dragging, then re-anchored
|
||||
// on drop. A region translates keeping its size; a barely-moved pointer is a click.
|
||||
const isRegion = thread.anchor.type === 'region'
|
||||
// The marker is a button (so it's keyboard-reachable), so the drag handlers are typed to it.
|
||||
const startDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
// A middle/right-button or space-held press over a pin is a camera pan, not a pin drag —
|
||||
// hand it to the canvas untouched.
|
||||
if (isCanvasPanGesture(editor, e)) {
|
||||
forwardPointerEventToCanvas(container, e)
|
||||
return
|
||||
}
|
||||
e.stopPropagation()
|
||||
const grabPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
||||
const anchorPage = anchorPagePoint(editor, thread.anchor)
|
||||
// The drag delta is taken from where the pin is drawn, which for an imprecise shape pin
|
||||
// is inset from its anchor point — without this the pin jumps by the inset on drag start.
|
||||
const inset = impreciseShapePinInset(editor, thread.anchor)
|
||||
if (anchorPage && inset) {
|
||||
const zoom = editor.getZoomLevel()
|
||||
anchorPage.x += inset.x / zoom
|
||||
anchorPage.y += inset.y / zoom
|
||||
}
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
moved: false,
|
||||
offsetX: anchorPage ? anchorPage.x - grabPage.x : 0,
|
||||
offsetY: anchorPage ? anchorPage.y - grabPage.y : 0,
|
||||
}
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}
|
||||
const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
// Moving a pin re-anchors the thread record — a commenting write. Without the permission the
|
||||
// press stays a click (`moved` never sets, so release toggles the popover and never commits).
|
||||
if (!canComment) return
|
||||
if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
|
||||
drag.moved = true
|
||||
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
||||
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
||||
setDragPagePoint(pagePoint)
|
||||
// Hint the shape the pin would re-anchor to on drop — the same hit-test endDrag resolves
|
||||
// with. Regions translate rather than re-anchor, so they never hint.
|
||||
if (!isRegion) {
|
||||
const hit = commentTargetShapeAt(editor, pagePoint)
|
||||
editor.setHintingShapes(hit ? [hit.id] : [])
|
||||
}
|
||||
}
|
||||
// Release capture and take the drag record; null when no drag was in progress.
|
||||
const takeDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
const drag = dragRef.current
|
||||
dragRef.current = null
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
return drag
|
||||
}
|
||||
// A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
|
||||
// no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
|
||||
const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (!takeDrag(e)) return
|
||||
setDragPagePoint(null)
|
||||
editor.setHintingShapes([])
|
||||
}
|
||||
const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
const drag = takeDrag(e)
|
||||
if (!drag) return
|
||||
editor.setHintingShapes([])
|
||||
if (!drag.moved) {
|
||||
toggleOpen()
|
||||
return
|
||||
}
|
||||
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
||||
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
||||
setDragPagePoint(null)
|
||||
let anchor: TLCommentThread['anchor']
|
||||
if (thread.anchor.type === 'region') {
|
||||
anchor = regionWithPinAt(thread.anchor, pinCorner, pagePoint)
|
||||
} else {
|
||||
const hit = commentTargetShapeAt(editor, pagePoint)
|
||||
anchor = hit
|
||||
? shapeAnchorAt(
|
||||
editor,
|
||||
hit.id,
|
||||
pagePoint,
|
||||
getCommentingOptions(editor).shouldBePrecise(editor, {
|
||||
shapeId: hit.id,
|
||||
point: pagePoint,
|
||||
altKey: e.altKey,
|
||||
})
|
||||
)
|
||||
: { type: 'point', x: pagePoint.x, y: pagePoint.y }
|
||||
}
|
||||
commitCommentMutation(editor, ({ put }) => put([{ ...thread, anchor }]), 'drag')
|
||||
}
|
||||
|
||||
// The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
|
||||
// move to the drag point; otherwise it sits at the stored anchor's viewport point.
|
||||
const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
|
||||
const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
|
||||
// A region's pin centres on its corner — overlapping the box — rather than hanging off it.
|
||||
// The marker anchors bottom-left, so step half its 28px size (--tlui-cmt-pin-size) left and
|
||||
// down (screen px), matching the draft composer's --region centring on the same corner.
|
||||
const renderPoint = isRegion
|
||||
? { x: renderPointBase.x - 14, y: renderPointBase.y + 14 }
|
||||
: renderPointBase
|
||||
|
||||
// A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
|
||||
// corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
|
||||
const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
|
||||
const movedRegion =
|
||||
regionAnchor && dragPagePoint
|
||||
? regionWithPinAt(regionAnchor, pinCorner, dragPagePoint)
|
||||
: regionAnchor
|
||||
const regionBoxBounds = resizeBounds ?? movedRegion
|
||||
const commitResize = (bounds: BoxModel) => {
|
||||
setResizeBounds(null)
|
||||
if (!canComment) return
|
||||
// Same commit path as a pin drag, so the configured `dragHistory` governs both — going
|
||||
// straight to `editor.run` here would make region resizes silently ignore the option.
|
||||
commitCommentMutation(
|
||||
editor,
|
||||
// Spread the existing anchor first so the region's pin corner survives a resize.
|
||||
({ put }) => put([{ ...thread, anchor: { ...regionAnchor!, ...bounds } }]),
|
||||
'drag'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{regionBoxBounds && (dragPagePoint || revealed) && (
|
||||
<RegionBox editor={editor} box={regionBoxBounds} />
|
||||
)}
|
||||
{regionBoxBounds && revealed && !dragPagePoint && canComment && (
|
||||
<RegionResizeHandles
|
||||
editor={editor}
|
||||
box={regionBoxBounds}
|
||||
handles={resizeHandles}
|
||||
onPreview={setResizeBounds}
|
||||
onCommit={commitResize}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={[
|
||||
'tlui-cmt-canvas-pin',
|
||||
open && 'tlui-cmt-canvas-pin--open',
|
||||
dragPagePoint && 'tlui-cmt-canvas-pin--dragging',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ left: renderPoint.x, top: renderPoint.y }}
|
||||
>
|
||||
<button
|
||||
ref={markerRef}
|
||||
type="button"
|
||||
className="tlui-cmt-button tlui-cmt-canvas-pin__marker"
|
||||
aria-label={pinLabel}
|
||||
aria-expanded={open}
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={onDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={cancelDrag}
|
||||
// Pointer activation is already handled by endDrag (which distinguishes a click
|
||||
// from a drag), so only take keyboard-synthesised clicks here — those report
|
||||
// `detail === 0` — or the thread would toggle twice per mouse click.
|
||||
onClick={(e) => {
|
||||
if (e.detail === 0) toggleOpen()
|
||||
}}
|
||||
onPointerEnter={previewHandlers.onPointerEnter}
|
||||
onPointerLeave={previewHandlers.onPointerLeave}
|
||||
// Focus stands in for hover, so tabbing to a marker gets the same preview.
|
||||
onFocus={previewHandlers.onPointerEnter}
|
||||
onBlur={previewHandlers.onPointerLeave}
|
||||
>
|
||||
<CommentPin resolved={thread.resolved != null} open={open}>
|
||||
{pinContent}
|
||||
</CommentPin>
|
||||
</button>
|
||||
{/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
|
||||
the pin itself stays in the canvas-in-front layer, beneath the UI. */}
|
||||
{open && (
|
||||
<ThreadPopover
|
||||
base={{
|
||||
x: renderPoint.x + POPOVER_OFFSET.thread.x,
|
||||
y: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
|
||||
}}
|
||||
>
|
||||
<ThreadView editor={editor} thread={thread} {...props} />
|
||||
</ThreadPopover>
|
||||
)}
|
||||
{/* Not while dragging: the pin is being moved, not read, and a panel trailing the
|
||||
cursor would obscure the drop target. */}
|
||||
{previewShown && !dragPagePoint && (
|
||||
<ThreadPreview
|
||||
editor={editor}
|
||||
threads={previewThreads}
|
||||
variant="thread"
|
||||
point={renderPoint}
|
||||
onSelectThread={() => openThreadId.set(editor, thread.id)}
|
||||
{...previewHandlers}
|
||||
currentUserId={props.currentUserId}
|
||||
resolveAuthor={resolveAuthor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
91
packages/commenting/src/canvas/thread-preview.test.ts
Normal file
91
packages/commenting/src/canvas/thread-preview.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { TLComment, TLCommentThread } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectPreviewCards, sortThreadsForPreview } from './thread-preview'
|
||||
|
||||
function thread(id: string, createdAt: number): TLCommentThread {
|
||||
return {
|
||||
id,
|
||||
typeName: 'comment-thread',
|
||||
pageId: 'page:one',
|
||||
anchor: { type: 'point', x: 0, y: 0 },
|
||||
createdBy: 'user:1',
|
||||
createdAt,
|
||||
resolved: null,
|
||||
meta: {},
|
||||
} as unknown as TLCommentThread
|
||||
}
|
||||
|
||||
function comment(threadId: string): TLComment {
|
||||
return { id: `comment:${threadId}`, typeName: 'comment', threadId } as unknown as TLComment
|
||||
}
|
||||
|
||||
/** Stands in for the store lookup: only the named threads have their opening comment yet. */
|
||||
function arrived(...threadIds: string[]) {
|
||||
return (t: TLCommentThread) => (threadIds.includes(t.id) ? comment(t.id) : undefined)
|
||||
}
|
||||
|
||||
describe('sortThreadsForPreview', () => {
|
||||
it('orders threads oldest first', () => {
|
||||
const sorted = sortThreadsForPreview([thread('t2', 20), thread('t1', 10), thread('t3', 30)])
|
||||
expect(sorted.map((t) => t.id)).toEqual(['t1', 't2', 't3'])
|
||||
})
|
||||
|
||||
it('breaks creation-time ties by id, matching the stack ordering', () => {
|
||||
const sorted = sortThreadsForPreview([thread('t3', 5), thread('t1', 5), thread('t2', 5)])
|
||||
expect(sorted.map((t) => t.id)).toEqual(['t1', 't2', 't3'])
|
||||
})
|
||||
|
||||
// A cluster hands over `node.members`, which the clustering table sorts by id — the preview has
|
||||
// to re-sort rather than inherit that order, or a cluster would read newest-first at random.
|
||||
it('reorders an id-sorted cluster membership by creation time', () => {
|
||||
const sorted = sortThreadsForPreview([thread('t1', 300), thread('t2', 200), thread('t3', 100)])
|
||||
expect(sorted.map((t) => t.id)).toEqual(['t3', 't2', 't1'])
|
||||
})
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const input = [thread('t2', 20), thread('t1', 10)]
|
||||
sortThreadsForPreview(input)
|
||||
expect(input.map((t) => t.id)).toEqual(['t2', 't1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectPreviewCards', () => {
|
||||
it('pairs each thread with its opening comment', () => {
|
||||
const threads = [thread('t1', 10), thread('t2', 20)]
|
||||
const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2'))
|
||||
expect(cards.map((c) => [c.thread.id, c.first.id])).toEqual([
|
||||
['t1', 'comment:t1'],
|
||||
['t2', 'comment:t2'],
|
||||
])
|
||||
expect(overflow).toBe(0)
|
||||
})
|
||||
|
||||
// A thread record can sync ahead of its first comment. It has nothing to show, and rendering it
|
||||
// anyway would paint a blank card — or, for a single-pin preview, a blank panel.
|
||||
it('drops threads whose opening comment has not arrived', () => {
|
||||
const threads = [thread('t1', 10), thread('t2', 20), thread('t3', 30)]
|
||||
const { cards } = selectPreviewCards(threads, arrived('t1', 't3'))
|
||||
expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't3'])
|
||||
})
|
||||
|
||||
it('reports nothing to show when no comment has arrived', () => {
|
||||
const { cards, overflow } = selectPreviewCards([thread('t1', 10)], arrived())
|
||||
expect(cards).toEqual([])
|
||||
expect(overflow).toBe(0)
|
||||
})
|
||||
|
||||
it('caps the cards and counts the rest as overflow', () => {
|
||||
const threads = ['t1', 't2', 't3', 't4'].map((id, i) => thread(id, i))
|
||||
const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2', 't3', 't4'), 2)
|
||||
expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't2'])
|
||||
expect(overflow).toBe(2)
|
||||
})
|
||||
|
||||
// "+N more" should promise N readable threads, so unarrived ones can't pad the count.
|
||||
it('excludes unarrived threads from the overflow tally', () => {
|
||||
const threads = ['t1', 't2', 't3', 't4'].map((id, i) => thread(id, i))
|
||||
const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2', 't3'), 2)
|
||||
expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't2'])
|
||||
expect(overflow).toBe(1)
|
||||
})
|
||||
})
|
||||
294
packages/commenting/src/canvas/thread-preview.tsx
Normal file
294
packages/commenting/src/canvas/thread-preview.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useMemo, useRef, type CSSProperties } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
EditorAtom,
|
||||
EditorPortal,
|
||||
TLComment,
|
||||
TLCommentThread,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { CommentCard } from '../ui/comment-card'
|
||||
import { replyCountLabel } from '../ui/reply-count'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useComments } from './hooks'
|
||||
import { useCommentingOptions } from './options'
|
||||
import { openStackId, openThreadId } from './state'
|
||||
import { POPOVER_OFFSET, toCardProps, useResolveName } from './thread-view'
|
||||
|
||||
/**
|
||||
* Hover previews for every canvas marker — a single pin, a coincident stack, or a cluster badge.
|
||||
* Hovering shows the thread(s) behind the marker as cards; the marker's own click is unchanged.
|
||||
*
|
||||
* The panel is live, not a passive tooltip: the pointer can travel into it and click a card to open
|
||||
* that thread. Two pieces make that work — the close delay survives the trip across the gap, and
|
||||
* the panel carries an invisible bridge over it (see `.tlui-cmt-canvas-preview::before`).
|
||||
*
|
||||
* Opening a thread from a card needs nothing more than setting `openThreadId`, even for one folded
|
||||
* inside a badge: `collectClusterLeaves` skips the open thread, so it drops out and renders itself.
|
||||
*/
|
||||
|
||||
/** How long the pointer must rest on a marker before its preview appears. */
|
||||
const PREVIEW_OPEN_DELAY_MS = 180
|
||||
/**
|
||||
* Grace period after the pointer leaves the marker *or* the panel — long enough to cross the gap
|
||||
* between them, which the bridge element covers geometrically.
|
||||
*/
|
||||
const PREVIEW_CLOSE_DELAY_MS = 220
|
||||
/** Cards shown before the panel falls back to a "+N more" line. */
|
||||
const PREVIEW_MAX_THREADS = 5
|
||||
|
||||
/** A thread paired with the comment that opens it — one card of a preview. */
|
||||
export interface ThreadPreviewCard {
|
||||
thread: TLCommentThread
|
||||
first: TLComment
|
||||
}
|
||||
|
||||
/**
|
||||
* The cards a marker's preview will show, and how many threads it will summarise as "+N more".
|
||||
*
|
||||
* A thread can exist before its opening comment does — a collaborator's, mid-sync. Those are
|
||||
* dropped rather than rendered blank, and don't count toward the overflow tally.
|
||||
*/
|
||||
export function selectPreviewCards(
|
||||
threads: readonly TLCommentThread[],
|
||||
firstCommentOf: (thread: TLCommentThread) => TLComment | undefined,
|
||||
max = PREVIEW_MAX_THREADS
|
||||
): { cards: ThreadPreviewCard[]; overflow: number } {
|
||||
const readable: ThreadPreviewCard[] = []
|
||||
for (const thread of threads) {
|
||||
const first = firstCommentOf(thread)
|
||||
if (first) readable.push({ thread, first })
|
||||
}
|
||||
return { cards: readable.slice(0, max), overflow: Math.max(0, readable.length - max) }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a marker's click opens, which is what its preview imitates — the panel's surface, its
|
||||
* width, and how it lays a comment out.
|
||||
*
|
||||
* `'thread'` previews a single pin: a thread panel without the header. `'list'` previews a
|
||||
* coincident stack or a cluster: one card per thread, like the stack list.
|
||||
*/
|
||||
export type ThreadPreviewVariant = 'thread' | 'list'
|
||||
|
||||
/**
|
||||
* Which marker's preview is showing, or null. One atom for the whole layer, so previews are
|
||||
* mutually exclusive: the close delay can leave an outgoing marker's timer pending when the next
|
||||
* opens, and per-component state would briefly show both.
|
||||
*/
|
||||
const hoveredMarkerId = new EditorAtom<string | null>('commentHoveredMarkerId', () => null)
|
||||
|
||||
/**
|
||||
* Hover state for one marker. Returns whether its preview should render, plus the pointer handlers
|
||||
* to spread onto the marker element. `markerId` must be stable and unique per marker across the
|
||||
* layer — prefix by kind, or a stack collides with its oldest member's own pin.
|
||||
*/
|
||||
export function useMarkerPreview(editor: Editor, markerId: string) {
|
||||
const openTimer = useRef(0)
|
||||
const closeTimer = useRef(0)
|
||||
|
||||
// Previews are a resting-state affordance. While a thread or stack list is open, that view is
|
||||
// the thing being read — a preview floating over it would just compete with it.
|
||||
const suppressed = useValue(
|
||||
'marker preview suppressed',
|
||||
() => openThreadId.get(editor) !== null || openStackId.get(editor) !== null,
|
||||
[editor]
|
||||
)
|
||||
const shown = useValue('marker preview shown', () => hoveredMarkerId.get(editor) === markerId, [
|
||||
editor,
|
||||
markerId,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const open = openTimer
|
||||
const close = closeTimer
|
||||
return () => {
|
||||
window.clearTimeout(open.current)
|
||||
window.clearTimeout(close.current)
|
||||
// Don't strand the atom on a marker that has unmounted — zoomed into a cluster, deleted,
|
||||
// or folded away — or no other marker's preview could ever show.
|
||||
if (hoveredMarkerId.get(editor) === markerId) hoveredMarkerId.set(editor, null)
|
||||
}
|
||||
}, [editor, markerId])
|
||||
|
||||
// Suppression can start *while* a preview is up: clicking the hovered pin opens its thread.
|
||||
// Retract the preview rather than leaving it stranded behind the popover that just opened.
|
||||
useEffect(() => {
|
||||
if (suppressed && hoveredMarkerId.get(editor) === markerId) {
|
||||
hoveredMarkerId.set(editor, null)
|
||||
}
|
||||
}, [suppressed, editor, markerId])
|
||||
|
||||
const onPointerEnter = () => {
|
||||
window.clearTimeout(openTimer.current)
|
||||
window.clearTimeout(closeTimer.current)
|
||||
if (suppressed) return
|
||||
openTimer.current = window.setTimeout(
|
||||
() => hoveredMarkerId.set(editor, markerId),
|
||||
PREVIEW_OPEN_DELAY_MS
|
||||
)
|
||||
}
|
||||
|
||||
const onPointerLeave = () => {
|
||||
window.clearTimeout(openTimer.current)
|
||||
window.clearTimeout(closeTimer.current)
|
||||
closeTimer.current = window.setTimeout(() => {
|
||||
if (hoveredMarkerId.get(editor) === markerId) hoveredMarkerId.set(editor, null)
|
||||
}, PREVIEW_CLOSE_DELAY_MS)
|
||||
}
|
||||
|
||||
return {
|
||||
previewShown: shown && !suppressed,
|
||||
previewHandlers: { onPointerEnter, onPointerLeave },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hover panel: each thread's opening comment as a read-only card, capped with a "+N more" line.
|
||||
* Mounted only while hovering, so its store subscription costs nothing at rest.
|
||||
*/
|
||||
export function ThreadPreview({
|
||||
editor,
|
||||
threads,
|
||||
variant,
|
||||
point,
|
||||
onSelectThread,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
...props
|
||||
}: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
|
||||
editor: Editor
|
||||
/** The marker's threads, in the order they should read (oldest first). */
|
||||
threads: readonly TLCommentThread[]
|
||||
/** What the marker's click opens, which this panel imitates. */
|
||||
variant: ThreadPreviewVariant
|
||||
/** The marker's anchor point in viewport space — the same origin its popover is placed from. */
|
||||
point: { x: number; y: number }
|
||||
/** Open a thread from its card. Omit to leave the cards inert. */
|
||||
onSelectThread?(thread: TLCommentThread): void
|
||||
/** The owning marker's hover handlers, so the panel counts as part of its hover region. */
|
||||
onPointerEnter?(): void
|
||||
onPointerLeave?(): void
|
||||
}) {
|
||||
const options = useCommentingOptions()
|
||||
const msg = useTranslation()
|
||||
const comments = useComments(editor)
|
||||
const resolveName = useResolveName(props.resolveAuthor)
|
||||
|
||||
// The panel floats over the canvas and scrolls nothing of its own, so a wheel on it should zoom
|
||||
// and pan the canvas underneath — like every other tldraw panel, and like the popover it
|
||||
// previews. Without this the canvas would freeze wherever the preview happened to be.
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
usePassThroughWheelEvents(ref)
|
||||
|
||||
// Each thread's opening comment and its total comment count. `useComments` is oldest-first, so
|
||||
// the first hit per thread is that thread's first comment. One pass over every comment beats a
|
||||
// per-thread hook — the thread count here is driven by cluster size, which has no fixed bound.
|
||||
const { firstByThread, countByThread } = useMemo(() => {
|
||||
const first = new Map<string, TLComment>()
|
||||
const count = new Map<string, number>()
|
||||
for (const comment of comments) {
|
||||
if (!first.has(comment.threadId)) first.set(comment.threadId, comment)
|
||||
count.set(comment.threadId, (count.get(comment.threadId) ?? 0) + 1)
|
||||
}
|
||||
return { firstByThread: first, countByThread: count }
|
||||
}, [comments])
|
||||
|
||||
const { cards, overflow } = useMemo(
|
||||
() => selectPreviewCards(threads, (thread) => firstByThread.get(thread.id)),
|
||||
[threads, firstByThread]
|
||||
)
|
||||
|
||||
// Nothing readable yet — render no panel rather than an empty one. It matters most for the
|
||||
// thread variant, where the panel *is* the card's surface: an empty one would paint as a blank
|
||||
// box floating on the canvas.
|
||||
if (cards.length === 0) return null
|
||||
|
||||
// Placed at the same origin as the popover this previews, so the two only differ by what their
|
||||
// stylesheets do — see `.tlui-cmt-canvas-preview__panel--thread` for the header compensation.
|
||||
const offset = POPOVER_OFFSET[variant]
|
||||
// A thread preview is one comment on the panel itself, so the panel carries the hover and the
|
||||
// click. A list preview puts each thread on its own card, as the stack list does.
|
||||
const isThread = variant === 'thread'
|
||||
const panelClass = [
|
||||
'tlui-cmt-canvas-preview__panel',
|
||||
`tlui-cmt-canvas-preview__panel--${variant}`,
|
||||
isThread && onSelectThread ? 'tlui-cmt-canvas-preview__panel--selectable' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<EditorPortal>
|
||||
{/* The root is the hover region — it carries the bridge back to the marker — while the panel
|
||||
inside it is the visible surface. Keeping them apart is what lets the surface light up on
|
||||
its own hover without the bridge (which reaches back over the marker) lighting it up too. */}
|
||||
<div
|
||||
ref={ref}
|
||||
className={`tlui-cmt-canvas-preview tlui-cmt-canvas-preview--${variant}`}
|
||||
style={
|
||||
{
|
||||
left: point.x + offset.x,
|
||||
top: point.y + offset.y,
|
||||
// The stylesheet sizes the hover bridge against this, so the gap it spans follows
|
||||
// the offset rather than being restated as a second magic number.
|
||||
'--tlui-cmt-preview-offset': `${offset.x}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
// The panel sits over the canvas; a press on it is not a canvas press.
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={panelClass}>
|
||||
{cards.map(({ thread, first }) => {
|
||||
// The preview shows only the opening comment, so its reply count is what tells the
|
||||
// reader the thread continues past what they see.
|
||||
const replies = replyCountLabel(msg, (countByThread.get(thread.id) ?? 1) - 1)
|
||||
return (
|
||||
<div
|
||||
key={thread.id}
|
||||
className={
|
||||
isThread
|
||||
? undefined
|
||||
: onSelectThread
|
||||
? 'tlui-cmt-preview-card tlui-cmt-preview-card--selectable'
|
||||
: 'tlui-cmt-preview-card'
|
||||
}
|
||||
onClick={
|
||||
onSelectThread
|
||||
? (e) => {
|
||||
e.stopPropagation()
|
||||
onSelectThread(thread)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CommentCard
|
||||
{...toCardProps(first, props, options.components, resolveName)}
|
||||
footer={
|
||||
replies ? <span className="tlui-cmt-card__replies">{replies}</span> : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div className="tlui-cmt-preview-more">
|
||||
{msg('comments.preview-more').replace('{count}', String(overflow))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</EditorPortal>
|
||||
)
|
||||
}
|
||||
|
||||
/** Order a marker's threads for reading: oldest first, id as the tiebreak so it's stable. */
|
||||
export function sortThreadsForPreview(
|
||||
threads: readonly TLCommentThread[]
|
||||
): readonly TLCommentThread[] {
|
||||
return [...threads].sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1))
|
||||
}
|
||||
238
packages/commenting/src/canvas/thread-stack.tsx
Normal file
238
packages/commenting/src/canvas/thread-stack.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { type MouseEvent as ReactMouseEvent, memo, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
TLCommentThread,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { CommentCard } from '../ui/comment-card'
|
||||
import { CountBadge } from '../ui/count-badge'
|
||||
import { UNKNOWN_AUTHOR } from './comment-render'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useThreadComments } from './hooks'
|
||||
import { useCommentingOptions } from './options'
|
||||
import { pinStackKey } from './pin-stacking'
|
||||
import { openStackId, openThreadId } from './state'
|
||||
import { ThreadPreview, useMarkerPreview } from './thread-preview'
|
||||
import { anchorPagePoint, anchorViewportPoint } from './thread-state'
|
||||
import {
|
||||
POPOVER_OFFSET,
|
||||
ThreadPopover,
|
||||
ThreadView,
|
||||
toCardProps,
|
||||
useResolveName,
|
||||
} from './thread-view'
|
||||
|
||||
/**
|
||||
* The pin for threads whose anchors resolve to the same page point — pins zooming can never
|
||||
* separate, so they share one count badge. Clicking it lists each thread as a card; clicking a card
|
||||
* expands that thread in place, via the single open-thread state.
|
||||
*/
|
||||
export const ThreadStackPin = memo(function ThreadStackPin({
|
||||
editor,
|
||||
threads,
|
||||
...props
|
||||
}: CommentingContext & {
|
||||
editor: Editor
|
||||
/** The stack's threads, oldest first. All resolve to the same anchor point. */
|
||||
threads: readonly TLCommentThread[]
|
||||
}) {
|
||||
const msg = useTranslation()
|
||||
const badgeRef = useRef<HTMLButtonElement>(null)
|
||||
// The badge takes pointer events (to open on click), so wheel input over it would otherwise be
|
||||
// swallowed instead of zooming — pass it through to the canvas, as the pin and cluster badge do.
|
||||
usePassThroughWheelEvents(badgeRef)
|
||||
// Hovering the badge previews its threads; clicking still opens them as the interactive list.
|
||||
// The preview is what makes the badge legible before you commit to opening it.
|
||||
const { previewShown, previewHandlers } = useMarkerPreview(editor, `stack:${threads[0].id}`)
|
||||
// The list stays open while a member thread is expanded, so Escape steps back: expanded thread ->
|
||||
// card list -> closed. Held in editor state because this pin remounts as its owning render path
|
||||
// changes, and keyed by the coincident page point so the open state survives losing a member.
|
||||
const stackId = useValue(
|
||||
'stack id',
|
||||
() => {
|
||||
const pagePoint = anchorPagePoint(editor, threads[0].anchor)
|
||||
return pagePoint ? pinStackKey(pagePoint) : threads[0].id
|
||||
},
|
||||
[editor, threads]
|
||||
)
|
||||
const listOpen = useValue('stack list open', () => openStackId.get(editor) === stackId, [
|
||||
editor,
|
||||
stackId,
|
||||
])
|
||||
const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
|
||||
const openMember = threads.find((thread) => thread.id === openId)
|
||||
const open = listOpen || openMember !== undefined
|
||||
|
||||
const point = useValue(
|
||||
'stack point',
|
||||
() => {
|
||||
const first = threads[0]
|
||||
if (first.pageId !== editor.getCurrentPageId()) return null
|
||||
// The badge hangs off its anchor point bottom-left like a pin, and applies the same imprecise-shape
|
||||
// inset the pins it stands in for would (see ThreadPin) — otherwise the marker would snap from tucked
|
||||
// inside the shape to the raw corner the moment a second comment turns a pin into a stack.
|
||||
return anchorViewportPoint(editor, first.anchor)
|
||||
},
|
||||
[editor, threads]
|
||||
)
|
||||
|
||||
// Clicking outside the popover (and off the badge) closes the whole stack — mirrors the
|
||||
// single pin's dismiss. Capture phase + class checks, since the popover portals elsewhere.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target) return
|
||||
if (target.closest('.tlui-cmt-canvas-popover')) return
|
||||
const badge = badgeRef.current
|
||||
if (badge && badge.contains(target)) return
|
||||
// A click inside a menu/popover layered above us belongs to that layer; defer to its
|
||||
// own dismissal instead of closing the stack out from under it.
|
||||
if (
|
||||
target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
|
||||
)
|
||||
return
|
||||
openStackId.set(editor, null)
|
||||
openThreadId.set(editor, null)
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
}, [open, editor])
|
||||
|
||||
// Escape with only the card list showing closes it. When a member thread is expanded, the
|
||||
// layer's Escape handler collapses that first and marks the event consumed — stepping back
|
||||
// to the list rather than closing everything at once.
|
||||
useEffect(() => {
|
||||
if (!listOpen) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || e.defaultPrevented) return
|
||||
openStackId.set(editor, null)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [listOpen, editor])
|
||||
|
||||
if (!point) return null
|
||||
|
||||
const toggle = () => {
|
||||
if (open) {
|
||||
openStackId.set(editor, null)
|
||||
openThreadId.set(editor, null)
|
||||
} else {
|
||||
openStackId.set(editor, stackId)
|
||||
}
|
||||
}
|
||||
|
||||
// The expanded thread gains a header that pushes its first comment down from where the hover preview
|
||||
// showed it. When that thread is the list's first, lift the whole list so its "You" holds position
|
||||
// across hover -> open. Only the first entry — lifting can't also hold a lower thread's neighbours.
|
||||
const liftForHeader = threads[0].id === openId ? 42 : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tlui-cmt-canvas-pin" style={{ left: point.x, top: point.y }}>
|
||||
<button
|
||||
ref={badgeRef}
|
||||
type="button"
|
||||
className="tlui-cmt-button tlui-cmt-canvas-stack-badge"
|
||||
aria-label={msg('comments.stack-label').replace('{count}', String(threads.length))}
|
||||
aria-expanded={open}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
{...previewHandlers}
|
||||
onFocus={previewHandlers.onPointerEnter}
|
||||
onBlur={previewHandlers.onPointerLeave}
|
||||
>
|
||||
<CountBadge count={threads.length} open={open} />
|
||||
</button>
|
||||
</div>
|
||||
{previewShown && !open && (
|
||||
<ThreadPreview
|
||||
editor={editor}
|
||||
threads={threads}
|
||||
// Lines the preview up with the stack list itself, so opening it leaves the cards
|
||||
// exactly where the preview had them.
|
||||
variant="list"
|
||||
point={point}
|
||||
// Picking a card from the preview lands in the same place clicking the badge and
|
||||
// then the card would: the list open, that thread expanded within it.
|
||||
onSelectThread={(thread) => {
|
||||
openStackId.set(editor, stackId)
|
||||
openThreadId.set(editor, thread.id)
|
||||
}}
|
||||
{...previewHandlers}
|
||||
currentUserId={props.currentUserId}
|
||||
resolveAuthor={props.resolveAuthor}
|
||||
/>
|
||||
)}
|
||||
{open && (
|
||||
<ThreadPopover
|
||||
base={{
|
||||
x: point.x + POPOVER_OFFSET.list.x,
|
||||
y: point.y + POPOVER_OFFSET.list.y - liftForHeader,
|
||||
}}
|
||||
>
|
||||
<div className="tlui-cmt-stack-list">
|
||||
{threads.map((thread) =>
|
||||
thread.id === openId ? (
|
||||
<div key={thread.id} className="tlui-cmt-stack-list__thread">
|
||||
<ThreadView editor={editor} thread={thread} {...props} />
|
||||
</div>
|
||||
) : (
|
||||
<StackThreadCard
|
||||
key={thread.id}
|
||||
editor={editor}
|
||||
thread={thread}
|
||||
{...props}
|
||||
onOpen={() => openThreadId.set(editor, thread.id)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ThreadPopover>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
/** A collapsed stack entry: the thread's first comment as a card; clicking expands the thread. */
|
||||
function StackThreadCard({
|
||||
editor,
|
||||
thread,
|
||||
onOpen,
|
||||
...props
|
||||
}: CommentingContext & { editor: Editor; thread: TLCommentThread; onOpen(): void }) {
|
||||
const msg = useTranslation()
|
||||
const options = useCommentingOptions()
|
||||
const comments = useThreadComments(editor, thread.id)
|
||||
const resolveName = useResolveName(props.resolveAuthor)
|
||||
const first = comments[0]
|
||||
if (!first) return null
|
||||
const open = (e: ReactMouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onOpen()
|
||||
}
|
||||
return (
|
||||
<div className="tlui-cmt-stack-list__card" onClick={open}>
|
||||
{/* The card's keyboard affordance. A button *wrapping* the card would put the comment
|
||||
body inside it, and a body renders its links as real anchors — interactive content
|
||||
nested in a button, which is an invalid content model and reads to assistive tech as a
|
||||
broken control. So the button covers the card as a sibling instead, leaving the body's
|
||||
own links above it and still reachable. */}
|
||||
<button
|
||||
type="button"
|
||||
className="tlui-cmt-button tlui-cmt-stack-list__card-action"
|
||||
aria-label={msg(
|
||||
thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
|
||||
).replace('{name}', props.resolveAuthor(thread.createdBy)?.name ?? UNKNOWN_AUTHOR)}
|
||||
onClick={open}
|
||||
/>
|
||||
<CommentCard {...toCardProps(first, props, options.components, resolveName)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
295
packages/commenting/src/canvas/thread-state.test.ts
Normal file
295
packages/commenting/src/canvas/thread-state.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
Box,
|
||||
commentSchemaRecords,
|
||||
createCommentThread,
|
||||
createShapeId,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
PageRecordType,
|
||||
TLShapeId,
|
||||
} from 'tldraw'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { commentsSidebarOpen } from './state'
|
||||
import {
|
||||
anchorPagePoint,
|
||||
commentCenterScreenOffset,
|
||||
commentTargetShapeAt,
|
||||
focusThread,
|
||||
impreciseShapePinInset,
|
||||
IMPRECISE_PIN_INSET_PX,
|
||||
isBoxInInflatedViewport,
|
||||
isInInflatedViewport,
|
||||
shapeAnchorAt,
|
||||
} from './thread-state'
|
||||
|
||||
/**
|
||||
* A real editor: the behavior under test is hit-testing against real shape geometry, which is
|
||||
* exactly what a stub can't model.
|
||||
*/
|
||||
let editor: Editor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: defaultTools,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
editor.updateViewportScreenBounds(new Box(0, 0, 1000, 1000))
|
||||
})
|
||||
|
||||
describe('commentTargetShapeAt', () => {
|
||||
it('hits an arrow pointed at near its line', () => {
|
||||
const id = createShapeId()
|
||||
editor.createShape({
|
||||
id,
|
||||
type: 'arrow',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
|
||||
})
|
||||
|
||||
// Dead on the line, and a couple of pixels off it — both are a hit. Without a hit-test
|
||||
// margin only the pixel-perfect click would land, which made arrows uncommentable.
|
||||
expect(commentTargetShapeAt(editor, { x: 200, y: 100 })?.id).toBe(id)
|
||||
expect(commentTargetShapeAt(editor, { x: 200, y: 102 })?.id).toBe(id)
|
||||
})
|
||||
|
||||
it('hits a line shape near its segment', () => {
|
||||
const id = createShapeId()
|
||||
editor.createShape({ id, type: 'line', x: 100, y: 100 })
|
||||
|
||||
const bounds = editor.getShapePageBounds(id)!
|
||||
expect(commentTargetShapeAt(editor, { x: bounds.center.x, y: bounds.center.y })?.id).toBe(id)
|
||||
})
|
||||
|
||||
it('hits inside a hollow shape', () => {
|
||||
const id = createShapeId()
|
||||
editor.createShape({
|
||||
id,
|
||||
type: 'geo',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: { w: 100, h: 100, fill: 'none' },
|
||||
})
|
||||
|
||||
expect(commentTargetShapeAt(editor, { x: 150, y: 150 })?.id).toBe(id)
|
||||
})
|
||||
|
||||
it('returns undefined on empty canvas', () => {
|
||||
editor.createShape({
|
||||
id: createShapeId(),
|
||||
type: 'arrow',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
|
||||
})
|
||||
|
||||
expect(commentTargetShapeAt(editor, { x: 600, y: 600 })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
/** A 100×100 square whose page bounds are (100,100)–(200,200), unrotated. */
|
||||
function createSquare(): TLShapeId {
|
||||
const id = createShapeId()
|
||||
editor.createShape({ id, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } })
|
||||
return id
|
||||
}
|
||||
|
||||
describe('shape anchors under a shape transform', () => {
|
||||
it('carries a precise pin around when the shape rotates', () => {
|
||||
const id = createSquare()
|
||||
// Top-right corner of the square.
|
||||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
|
||||
expect(anchor).toMatchObject({ type: 'shape', shapeId: id, x: 1, y: 0, isPrecise: true })
|
||||
expect(anchorPagePoint(editor, anchor)).toMatchObject({ x: 200, y: 100 })
|
||||
|
||||
// A quarter turn about the square's centre takes that corner to the bottom-right. The
|
||||
// square's page bounds don't change, so a pin normalized against them would sit still.
|
||||
editor.rotateShapesBy([id], Math.PI / 2)
|
||||
const rotated = anchorPagePoint(editor, anchor)!
|
||||
expect(rotated.x).toBeCloseTo(200)
|
||||
expect(rotated.y).toBeCloseTo(200)
|
||||
})
|
||||
|
||||
it('records the spot a pin was dropped on a rotated shape', () => {
|
||||
const id = createSquare()
|
||||
editor.rotateShapesBy([id], Math.PI / 2)
|
||||
|
||||
// After the quarter turn the square's local top-right corner sits at the page's bottom-right.
|
||||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 200 }, true)
|
||||
expect(anchor.type).toBe('shape')
|
||||
if (anchor.type !== 'shape') return
|
||||
expect(anchor.x).toBeCloseTo(1)
|
||||
expect(anchor.y).toBeCloseTo(0)
|
||||
|
||||
const point = anchorPagePoint(editor, anchor)!
|
||||
expect(point.x).toBeCloseTo(200)
|
||||
expect(point.y).toBeCloseTo(200)
|
||||
})
|
||||
|
||||
it('still tracks moves and resizes', () => {
|
||||
const id = createSquare()
|
||||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
|
||||
|
||||
editor.updateShape({ id, type: 'geo', x: 300, props: { w: 200 } })
|
||||
expect(anchorPagePoint(editor, anchor)).toMatchObject({ x: 500, y: 100 })
|
||||
})
|
||||
|
||||
it('turns an imprecise pin inset with the shape so it keeps stepping inward', () => {
|
||||
const id = createSquare()
|
||||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, false)
|
||||
// The default top-right spot: step left and down, into the square.
|
||||
expect(impreciseShapePinInset(editor, anchor)).toMatchObject({
|
||||
x: -IMPRECISE_PIN_INSET_PX,
|
||||
y: IMPRECISE_PIN_INSET_PX,
|
||||
})
|
||||
|
||||
editor.rotateShapesBy([id], Math.PI / 2)
|
||||
// The corner is now bottom-right, so stepping inward means left and up.
|
||||
const inset = impreciseShapePinInset(editor, anchor)!
|
||||
expect(inset.x).toBeCloseTo(-IMPRECISE_PIN_INSET_PX)
|
||||
expect(inset.y).toBeCloseTo(-IMPRECISE_PIN_INSET_PX)
|
||||
})
|
||||
|
||||
it('hides the pin for a shape that no longer exists', () => {
|
||||
const id = createSquare()
|
||||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
|
||||
editor.deleteShape(id)
|
||||
expect(anchorPagePoint(editor, anchor)).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('commentCenterScreenOffset', () => {
|
||||
/** A sidebar element whose left edge sits at `left` client px, in the editor's container. */
|
||||
function mountSidebar(left: number) {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'tlui-cmt-canvas-sidebar'
|
||||
el.getBoundingClientRect = () => ({ left }) as DOMRect
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector('.tlui-cmt-canvas-sidebar')?.remove()
|
||||
})
|
||||
|
||||
it('is zero while the sidebar is closed', () => {
|
||||
mountSidebar(712)
|
||||
expect(commentCenterScreenOffset(editor)).toBe(0)
|
||||
})
|
||||
|
||||
it('is half the covered width, so the pin centers in the uncovered area', () => {
|
||||
commentsSidebarOpen.set(editor, true)
|
||||
// Viewport is 1000px wide; the sidebar covers the rightmost 288px. The centered pin (at
|
||||
// 356px) leaves the thread UI clear of the sidebar, so no nudge is needed.
|
||||
mountSidebar(712)
|
||||
expect(commentCenterScreenOffset(editor)).toBe(144)
|
||||
})
|
||||
|
||||
it('nudges the pin further left when the thread UI would reach the sidebar', () => {
|
||||
commentsSidebarOpen.set(editor, true)
|
||||
// Centered in the uncovered area the pin sits at 300px, and the thread UI (334px wide,
|
||||
// plus the 8px gap) would cross the sidebar's edge — so the pin backs off to 258px.
|
||||
mountSidebar(600)
|
||||
expect(commentCenterScreenOffset(editor)).toBe(500 - 258)
|
||||
})
|
||||
|
||||
it('never nudges the pin past the left edge', () => {
|
||||
commentsSidebarOpen.set(editor, true)
|
||||
// Clearing the sidebar would need a negative pin position; the left inset wins.
|
||||
mountSidebar(300)
|
||||
expect(commentCenterScreenOffset(editor)).toBe(500 - 8)
|
||||
})
|
||||
|
||||
it('is zero while the sidebar element is not mounted', () => {
|
||||
commentsSidebarOpen.set(editor, true)
|
||||
expect(commentCenterScreenOffset(editor)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInInflatedViewport', () => {
|
||||
// The viewport is 1000x1000 (see beforeEach) and the margin is 120 screen px.
|
||||
it('accepts a point on screen', () => {
|
||||
expect(isInInflatedViewport(editor, { x: 500, y: 500 })).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a point just off screen, so a pan finds it already mounted', () => {
|
||||
expect(isInInflatedViewport(editor, { x: -119, y: 500 })).toBe(true)
|
||||
expect(isInInflatedViewport(editor, { x: 1119, y: 500 })).toBe(true)
|
||||
expect(isInInflatedViewport(editor, { x: 500, y: -119 })).toBe(true)
|
||||
expect(isInInflatedViewport(editor, { x: 500, y: 1119 })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a point past the margin on any side', () => {
|
||||
expect(isInInflatedViewport(editor, { x: -121, y: 500 })).toBe(false)
|
||||
expect(isInInflatedViewport(editor, { x: 1121, y: 500 })).toBe(false)
|
||||
expect(isInInflatedViewport(editor, { x: 500, y: -121 })).toBe(false)
|
||||
expect(isInInflatedViewport(editor, { x: 500, y: 1121 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBoxInInflatedViewport', () => {
|
||||
it('accepts a box wholly on screen', () => {
|
||||
expect(isBoxInInflatedViewport(editor, { x: 100, y: 100, w: 200, h: 200 })).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a box straddling the edge, whose pin corner alone would be culled', () => {
|
||||
// This is the case the point test gets wrong: the box's visible edge is on screen while
|
||||
// its far corner — where a region thread's pin can sit — is thousands of px away.
|
||||
expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 5100, h: 5100 })).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a box larger than the viewport on every side', () => {
|
||||
expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 10000, h: 10000 })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a box wholly past the margin', () => {
|
||||
expect(isBoxInInflatedViewport(editor, { x: 1200, y: 100, w: 100, h: 100 })).toBe(false)
|
||||
expect(isBoxInInflatedViewport(editor, { x: 100, y: -400, w: 100, h: 100 })).toBe(false)
|
||||
})
|
||||
|
||||
it('follows the camera rather than page coordinates', () => {
|
||||
const box = { x: 2000, y: 0, w: 100, h: 100 }
|
||||
expect(isBoxInInflatedViewport(editor, box)).toBe(false)
|
||||
editor.setCamera({ x: -2000, y: 0, z: 1 })
|
||||
expect(isBoxInInflatedViewport(editor, box)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('focusThread', () => {
|
||||
it('records a cross-page jump as its own undo step', () => {
|
||||
const page1Id = editor.getCurrentPageId()
|
||||
const page2Id = PageRecordType.createId()
|
||||
const boxId = createShapeId()
|
||||
|
||||
// Set up the second page outside of history so the only undoable steps are the ones the
|
||||
// test creates: one edit on page 1, then the jump to the thread on page 2.
|
||||
editor.run(() => editor.createPage({ id: page2Id, name: 'Page 2' }), { history: 'ignore' })
|
||||
editor.clearHistory()
|
||||
|
||||
editor.markHistoryStoppingPoint('create box')
|
||||
editor.createShape({ id: boxId, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } })
|
||||
|
||||
const thread = createCommentThread({
|
||||
pageId: page2Id,
|
||||
anchor: { type: 'point', x: 50, y: 50 },
|
||||
createdBy: 'me',
|
||||
})
|
||||
focusThread(editor, thread)
|
||||
expect(editor.getCurrentPageId()).toBe(page2Id)
|
||||
|
||||
// The first undo only takes us back to page 1; the edit made there is still intact.
|
||||
editor.undo()
|
||||
expect(editor.getCurrentPageId()).toBe(page1Id)
|
||||
expect(editor.getShape(boxId)).toBeDefined()
|
||||
|
||||
editor.undo()
|
||||
expect(editor.getShape(boxId)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
252
packages/commenting/src/canvas/thread-state.ts
Normal file
252
packages/commenting/src/canvas/thread-state.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
BoxModel,
|
||||
Editor,
|
||||
Mat,
|
||||
TLCommentAnchor,
|
||||
TLCommentThread,
|
||||
TLShape,
|
||||
TLShapeId,
|
||||
Vec,
|
||||
VecLike,
|
||||
} from 'tldraw'
|
||||
import { getCommentingOptions } from './options'
|
||||
import { commentsSidebarOpen, openThreadId } from './state'
|
||||
import { POPOVER_OFFSET } from './thread-view'
|
||||
|
||||
/** How far an imprecise shape pin steps inside the shape from its anchor spot, in screen px —
|
||||
* most of the marker sits within the shape, with a small overhang past the corner. */
|
||||
export const IMPRECISE_PIN_INSET_PX = 20
|
||||
|
||||
/** Screen-pixel margin by which the viewport is inflated when culling canvas markers (thread pins
|
||||
* and cluster badges), so a marker just off-screen is already mounted when a pan brings it in. */
|
||||
const MARKER_CULL_MARGIN_PX = 120
|
||||
|
||||
/**
|
||||
* Whether a viewport-space point sits within the viewport inflated by
|
||||
* {@link MARKER_CULL_MARGIN_PX}. The cull test for screen-fixed markers: off-screen markers
|
||||
* return null from their position signal and unmount instead of tracking every camera frame.
|
||||
* @internal
|
||||
*/
|
||||
export function isInInflatedViewport(editor: Editor, point: VecLike): boolean {
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
const margin = MARKER_CULL_MARGIN_PX
|
||||
return (
|
||||
point.x >= -margin &&
|
||||
point.y >= -margin &&
|
||||
point.x <= viewport.w + margin &&
|
||||
point.y <= viewport.h + margin
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any part of a page-space box overlaps the viewport inflated by
|
||||
* {@link MARKER_CULL_MARGIN_PX}. The cull test for region-anchored threads, whose dashed box can
|
||||
* be on screen while the pin corner itself is not.
|
||||
* @internal
|
||||
*/
|
||||
export function isBoxInInflatedViewport(editor: Editor, box: BoxModel): boolean {
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
const margin = MARKER_CULL_MARGIN_PX
|
||||
// Zoom is positive, so the page box's corners keep their order through the transform.
|
||||
const min = editor.pageToViewport({ x: box.x, y: box.y })
|
||||
const max = editor.pageToViewport({ x: box.x + box.w, y: box.y + box.h })
|
||||
return (
|
||||
max.x >= -margin &&
|
||||
max.y >= -margin &&
|
||||
min.x <= viewport.w + margin &&
|
||||
min.y <= viewport.h + margin
|
||||
)
|
||||
}
|
||||
|
||||
/** Imprecise shape pins tuck inside the shape rather than hanging off its edge: the marker
|
||||
* extends up-right of its anchor point, so step it toward the shape's centre. Screen px — the
|
||||
* pin is screen-fixed while the shape scales with zoom. Null for anchors that need no inset. */
|
||||
export function impreciseShapePinInset(
|
||||
editor: Editor,
|
||||
anchor: TLCommentThread['anchor']
|
||||
): { x: number; y: number } | null {
|
||||
if (anchor.type !== 'shape' || anchor.isPrecise) return null
|
||||
const spot = getCommentingOptions(editor).impreciseShapeAnchor
|
||||
const inset = {
|
||||
x: Math.sign(0.5 - spot.x) * IMPRECISE_PIN_INSET_PX,
|
||||
y: Math.sign(0.5 - spot.y) * IMPRECISE_PIN_INSET_PX,
|
||||
}
|
||||
// The spot is a corner of the shape's own bounds, so which way "toward the centre" points turns
|
||||
// with the shape: without this a rotated shape's pin would step out of the shape, not into it.
|
||||
const rotation = editor.getShapePageTransform(anchor.shapeId as TLShapeId)?.rotation() ?? 0
|
||||
return rotation === 0 ? inset : Vec.Rot(inset, rotation)
|
||||
}
|
||||
|
||||
/** The corner a region's pin and composer sit on, as a normalized 0–1 offset (bottom-right). Pin
|
||||
* position, composer placement, region move, and which corner has no resize handle all derive
|
||||
* from it. */
|
||||
export const REGION_PIN_CORNER: VecLike = { x: 1, y: 1 }
|
||||
|
||||
/** A region anchor's pin corner: the corner its creating drag released on, when recorded, else
|
||||
* {@link REGION_PIN_CORNER}. @internal */
|
||||
export function regionAnchorPinCorner(
|
||||
anchor: Extract<TLCommentAnchor, { type: 'region' }>
|
||||
): VecLike {
|
||||
if (anchor.pinX !== undefined && anchor.pinY !== undefined) {
|
||||
return { x: anchor.pinX, y: anchor.pinY }
|
||||
}
|
||||
return REGION_PIN_CORNER
|
||||
}
|
||||
|
||||
/** The page point of a region's pin corner. */
|
||||
export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CORNER): VecLike {
|
||||
return {
|
||||
x: region.x + corner.x * region.w,
|
||||
y: region.y + corner.y * region.h,
|
||||
}
|
||||
}
|
||||
|
||||
/** The inverse of {@link regionPinPoint}: a region translated so its pin corner lands on `pinPage`;
|
||||
* size unchanged. */
|
||||
export function regionWithPinAt(
|
||||
region: Extract<TLCommentAnchor, { type: 'region' }>,
|
||||
pinCorner: VecLike,
|
||||
pinPage: VecLike
|
||||
): Extract<TLCommentAnchor, { type: 'region' }> {
|
||||
return { ...region, x: pinPage.x - pinCorner.x * region.w, y: pinPage.y - pinCorner.y * region.h }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a thread's pin sits on the page, for each anchor kind. Null hides the pin. Imprecise shape
|
||||
* anchors use {@link CommentingOptions.impreciseShapeAnchor} rather than the stored `x`/`y`.
|
||||
*
|
||||
* A shape anchor's `x`/`y` are normalized within the shape's bounds and resolved through its page
|
||||
* transform, so the pin rides rotation instead of being left behind in the bounding box.
|
||||
* @public
|
||||
*/
|
||||
export function anchorPagePoint(
|
||||
editor: Editor,
|
||||
anchor: TLCommentAnchor
|
||||
): { x: number; y: number } | null {
|
||||
switch (anchor.type) {
|
||||
case 'shape': {
|
||||
const shape = editor.getShape(anchor.shapeId as TLShapeId)
|
||||
if (!shape) return null
|
||||
const transform = editor.getShapePageTransform(shape)
|
||||
if (!transform) return null
|
||||
const { point, size } = editor.getShapeGeometry(shape).bounds
|
||||
// Precise pins sit at their stored x/y; imprecise ones at the editor's configured spot.
|
||||
const spot = anchor.isPrecise ? anchor : getCommentingOptions(editor).impreciseShapeAnchor
|
||||
return Mat.applyToPoint(transform, Vec.Add(point, Vec.MulV(spot, size)))
|
||||
}
|
||||
case 'point':
|
||||
return { x: anchor.x, y: anchor.y }
|
||||
case 'region':
|
||||
return regionPinPoint(anchor, regionAnchorPinCorner(anchor))
|
||||
case 'page':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a thread's marker is drawn, in viewport space: {@link anchorPagePoint} projected through
|
||||
* the camera, plus the imprecise-shape inset. Null when the anchor resolves to no point.
|
||||
* @internal
|
||||
*/
|
||||
export function anchorViewportPoint(
|
||||
editor: Editor,
|
||||
anchor: TLCommentAnchor
|
||||
): { x: number; y: number } | null {
|
||||
const pagePoint = anchorPagePoint(editor, anchor)
|
||||
if (!pagePoint) return null
|
||||
const viewportPoint = editor.pageToViewport(pagePoint)
|
||||
const inset = impreciseShapePinInset(editor, anchor)
|
||||
return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape a comment placed at a page point should anchor to, or undefined for empty canvas.
|
||||
*
|
||||
* Uses the editor's hit-test margin, the same slack select and hover use — without it, open-path
|
||||
* shapes (arrows, lines, draw strokes) are unhittable in practice.
|
||||
*
|
||||
* `hitFrameInside` lets a click inside a frame's body anchor to the frame, which it otherwise
|
||||
* wouldn't (the select-tool convention hits only the edge/label). A child shape under the pointer
|
||||
* still wins, so only a frame's empty interior anchors the frame.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function commentTargetShapeAt(editor: Editor, page: VecLike): TLShape | undefined {
|
||||
return editor.getShapeAtPoint(page, {
|
||||
hitInside: true,
|
||||
hitFrameInside: true,
|
||||
margin: editor.getHitTestMargin(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape anchor for a page point. `x`/`y` are the point's normalized (0–1) offset within the
|
||||
* shape's own bounds, taken in the shape's own space, so a pin on a rotated shape records the spot
|
||||
* it was dropped on. Remembered either way: when `precise` the pin sits at exactly `x`/`y`,
|
||||
* otherwise at the consumer's imprecise default.
|
||||
* @public
|
||||
*/
|
||||
export function shapeAnchorAt(
|
||||
editor: Editor,
|
||||
shapeId: TLShapeId,
|
||||
page: { x: number; y: number },
|
||||
precise: boolean
|
||||
): TLCommentAnchor {
|
||||
const shape = editor.getShape(shapeId)
|
||||
const bounds = shape && editor.getShapeGeometry(shape).bounds
|
||||
if (!shape || !bounds || bounds.w === 0 || bounds.h === 0) {
|
||||
return { type: 'shape', shapeId, x: 0.5, y: 0.5, isPrecise: precise }
|
||||
}
|
||||
const local = editor.getPointInShapeSpace(shape, page)
|
||||
return {
|
||||
type: 'shape',
|
||||
shapeId,
|
||||
x: (local.x - bounds.minX) / bounds.w,
|
||||
y: (local.y - bounds.minY) / bounds.h,
|
||||
isPrecise: precise,
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a thread and bring it into view — switch to its page if needed, then center its pin. @public */
|
||||
export function focusThread(editor: Editor, thread: TLCommentThread): void {
|
||||
if (thread.pageId !== editor.getCurrentPageId()) {
|
||||
editor.markHistoryStoppingPoint('change-page')
|
||||
editor.setCurrentPage(thread.pageId as any)
|
||||
}
|
||||
openThreadId.set(editor, thread.id)
|
||||
const point = anchorPagePoint(editor, thread.anchor)
|
||||
if (!point) return
|
||||
const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
|
||||
editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration: 200 } })
|
||||
}
|
||||
|
||||
/** The left inset a nudged pin never crosses, and the gap kept between thread UI and sidebar. */
|
||||
const CENTER_MARGIN_PX = 8
|
||||
|
||||
/** How far the open thread UI reaches right of its pin, in screen px: the popover's offset plus
|
||||
* the thread panel's fixed width (300px, `.tlui-cmt-thread`). */
|
||||
const THREAD_UI_EXTENT_PX = POPOVER_OFFSET.thread.x + 300
|
||||
|
||||
/**
|
||||
* How far right of a centered-on pin the true viewport center should sit, in screen px. While the
|
||||
* comments sidebar covers the viewport's right edge, dead-centering a pin would put its thread
|
||||
* popover under the sidebar — so centering aims at the middle of the uncovered area instead, never
|
||||
* past the viewport's left edge. Zero when the sidebar is closed or not on screen.
|
||||
* @internal
|
||||
*/
|
||||
export function commentCenterScreenOffset(editor: Editor): number {
|
||||
if (!commentsSidebarOpen.get(editor)) return 0
|
||||
const sidebar = editor.getContainer().querySelector('.tlui-cmt-canvas-sidebar')
|
||||
if (!sidebar) return 0
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
// Viewport screen bounds are the container's client rect, so this is container-local.
|
||||
const sidebarLeft = sidebar.getBoundingClientRect().left - viewport.x
|
||||
if (sidebarLeft >= viewport.w) return 0
|
||||
// Aim the pin at the middle of the uncovered area, nudged left until the thread UI clears the
|
||||
// sidebar — but never past the left edge (the edge wins when there's no room for both).
|
||||
const pinX = Math.max(
|
||||
Math.min(sidebarLeft / 2, sidebarLeft - CENTER_MARGIN_PX - THREAD_UI_EXTENT_PX),
|
||||
CENTER_MARGIN_PX
|
||||
)
|
||||
return viewport.w / 2 - pinX
|
||||
}
|
||||
72
packages/commenting/src/canvas/thread-view.test.ts
Normal file
72
packages/commenting/src/canvas/thread-view.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { CommentAuthor } from '@tldraw/mentions'
|
||||
import type { TLComment } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { absoluteThreadLink, toCardProps } from './thread-view'
|
||||
|
||||
const AUTHOR: CommentAuthor = { name: 'Ada' }
|
||||
|
||||
function comment(overrides: Partial<TLComment> = {}): TLComment {
|
||||
return {
|
||||
id: 'comment:1',
|
||||
typeName: 'comment',
|
||||
threadId: 'comment-thread:1',
|
||||
pageId: 'page:one',
|
||||
authorId: 'user:1',
|
||||
body: { type: 'doc', content: [] },
|
||||
createdAt: Date.parse('2026-07-30T12:00:00.000Z'),
|
||||
editedAt: null,
|
||||
meta: {},
|
||||
...overrides,
|
||||
} as unknown as TLComment
|
||||
}
|
||||
|
||||
const context = {
|
||||
currentUserId: 'user:1',
|
||||
resolveAuthor: (id: string) => (id === 'user:1' ? AUTHOR : undefined),
|
||||
}
|
||||
|
||||
const resolveName = (id: string) => context.resolveAuthor(id)?.name
|
||||
|
||||
describe('toCardProps', () => {
|
||||
it('marks your own comments and resolves the author', () => {
|
||||
const card = toCardProps(comment(), context, {}, resolveName)
|
||||
expect(card.you).toBe(true)
|
||||
expect(card.author).toBe(AUTHOR)
|
||||
expect(card.date).toBe('2026-07-30T12:00:00.000Z')
|
||||
expect(card.edited).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the unknown author when the id does not resolve', () => {
|
||||
const card = toCardProps(comment({ authorId: 'user:gone' }), context, {}, resolveName)
|
||||
expect(card.you).toBe(false)
|
||||
expect(card.author).not.toBe(AUTHOR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('absoluteThreadLink', () => {
|
||||
const BASE = 'https://example.com/f/abc123?page=2'
|
||||
|
||||
// A host's `getThreadHref` is an href — the sidebar rows use it as one — so it's allowed to be
|
||||
// relative. Copying that verbatim would put "/f/abc123?comment=…" on someone's clipboard.
|
||||
it('resolves a root-relative href against the current document', () => {
|
||||
expect(absoluteThreadLink('/f/abc123?comment=comment-thread%3A1', BASE)).toBe(
|
||||
'https://example.com/f/abc123?comment=comment-thread%3A1'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an already absolute href alone', () => {
|
||||
const href = 'https://other.example/f/xyz?comment=comment-thread%3A1'
|
||||
expect(absoluteThreadLink(href, BASE)).toBe(href)
|
||||
})
|
||||
|
||||
it('resolves a path-relative href', () => {
|
||||
expect(absoluteThreadLink('?comment=comment-thread%3A1', BASE)).toBe(
|
||||
'https://example.com/f/abc123?comment=comment-thread%3A1'
|
||||
)
|
||||
})
|
||||
|
||||
// Better a link that's odd than a copy button that silently does nothing.
|
||||
it('copies an unparseable href as-is', () => {
|
||||
expect(absoluteThreadLink('::not a url::', '::also not::')).toBe('::not a url::')
|
||||
})
|
||||
})
|
||||
612
packages/commenting/src/canvas/thread-view.tsx
Normal file
612
packages/commenting/src/canvas/thread-view.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
import { memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createComment,
|
||||
Editor,
|
||||
EditorPortal,
|
||||
TLComment,
|
||||
TLCommentThread,
|
||||
TLRichText,
|
||||
TldrawUiButton,
|
||||
TldrawUiDropdownMenuContent,
|
||||
TldrawUiDropdownMenuGroup,
|
||||
TldrawUiDropdownMenuItem,
|
||||
TldrawUiDropdownMenuRoot,
|
||||
TldrawUiDropdownMenuTrigger,
|
||||
TldrawUiIcon,
|
||||
useContainer,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { CommentCard, CommentCardProps } from '../ui/comment-card'
|
||||
import { CommentComposer } from '../ui/comment-composer'
|
||||
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
||||
import { CommentThread } from '../ui/comment-thread'
|
||||
import { CommentBody } from './comment-body'
|
||||
import {
|
||||
clearCommentDraft,
|
||||
getCommentDraft,
|
||||
replyDraftSlot,
|
||||
saveCommentDraft,
|
||||
} from './comment-drafts'
|
||||
import {
|
||||
commitCommentMutation,
|
||||
deleteComment,
|
||||
deleteThread,
|
||||
editComment,
|
||||
reopenThread,
|
||||
resolveThread,
|
||||
} from './comment-mutations'
|
||||
import { CommentReactionPicker, CommentReactions } from './comment-reactions'
|
||||
import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useThreadComments } from './hooks'
|
||||
import { useIsMobileCommenting, useMobilePlacement } from './mobile-placement'
|
||||
import {
|
||||
type CommentingComponents,
|
||||
getCanModifyComment,
|
||||
useCanComment,
|
||||
useCanModifyComment,
|
||||
useCommentingOptions,
|
||||
} from './options'
|
||||
import { openThreadId } from './state'
|
||||
|
||||
const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
|
||||
|
||||
/** The "⋯" dropdown used for both a comment's and the thread's actions. */
|
||||
function MoreOptionsMenu({
|
||||
id,
|
||||
moreFor,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
/** The comment whose ⋯ button this is, so `startEdit` can find it again to return focus. */
|
||||
moreFor?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const msg = useTranslation()
|
||||
return (
|
||||
<TldrawUiDropdownMenuRoot id={id}>
|
||||
<TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={msg('comments.more-options')}
|
||||
title={msg('comments.more-options')}
|
||||
className="tlui-cmt-thread__action"
|
||||
data-cmt-more-for={moreFor}
|
||||
>
|
||||
<TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
|
||||
</TldrawUiButton>
|
||||
</TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiDropdownMenuContent
|
||||
className="tlui-cmt-menu"
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
sideOffset={4}
|
||||
>
|
||||
<TldrawUiDropdownMenuGroup>{children}</TldrawUiDropdownMenuGroup>
|
||||
</TldrawUiDropdownMenuContent>
|
||||
</TldrawUiDropdownMenuRoot>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
label,
|
||||
danger,
|
||||
noClose,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
danger?: boolean
|
||||
noClose?: boolean
|
||||
onClick(): void
|
||||
}) {
|
||||
return (
|
||||
<TldrawUiDropdownMenuItem noClose={noClose}>
|
||||
<button
|
||||
type="button"
|
||||
className={danger ? 'tlui-cmt-menu-item tlui-cmt-menu-item--danger' : 'tlui-cmt-menu-item'}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
</TldrawUiDropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A name-only view of an author resolver, for the mention/rich-text paths. Stable identity, so
|
||||
* `CommentBody`'s memoized render doesn't recompute on every render of its host.
|
||||
*/
|
||||
export function useResolveName(resolveAuthor: CommentingContext['resolveAuthor']) {
|
||||
return useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
|
||||
}
|
||||
|
||||
/** How long the copy-link item reads "Link copied" before reverting. */
|
||||
const LINK_COPIED_MS = 2000
|
||||
|
||||
/**
|
||||
* What a thread's `getThreadHref` should put on the clipboard. Hosts may hand back a relative href,
|
||||
* which is meaningless once pasted elsewhere, so it's resolved against the current document first.
|
||||
* An href the URL parser can't make sense of is copied as-is rather than dropped.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function absoluteThreadLink(href: string, base = window.location.href): string {
|
||||
try {
|
||||
return new URL(href, base).toString()
|
||||
} catch {
|
||||
return href
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a thread's link, with a flag that stays set briefly afterwards so the control can confirm.
|
||||
* Only set once the write resolves, so a withheld clipboard doesn't claim a copy that didn't happen.
|
||||
*/
|
||||
function useCopyLink(href: string | undefined) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const timeout = window.setTimeout(() => setCopied(false), LINK_COPIED_MS)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [copied])
|
||||
const copy = useCallback(() => {
|
||||
if (href === undefined) return
|
||||
navigator.clipboard?.writeText(absoluteThreadLink(href)).then(
|
||||
() => setCopied(true),
|
||||
() => setCopied(false)
|
||||
)
|
||||
}, [href])
|
||||
return [copied, copy] as const
|
||||
}
|
||||
|
||||
export function toCardProps(
|
||||
comment: TLComment,
|
||||
props: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'>,
|
||||
components: CommentingComponents,
|
||||
resolveName: (id: string) => string | undefined
|
||||
): CommentCardProps {
|
||||
const Body = components.CommentBody
|
||||
// The `CommentBody` component slot overrides the built-in rich-text default (which resolves
|
||||
// mention ids to names).
|
||||
const body = Body ? (
|
||||
<Body comment={comment} />
|
||||
) : (
|
||||
<CommentBody richText={comment.body} resolveName={resolveName} />
|
||||
)
|
||||
return {
|
||||
author: props.resolveAuthor(comment.authorId) ?? UNKNOWN_COMMENT_AUTHOR,
|
||||
body,
|
||||
date: new Date(comment.createdAt).toISOString(),
|
||||
you: comment.authorId === props.currentUserId,
|
||||
edited: comment.editedAt != null,
|
||||
}
|
||||
}
|
||||
|
||||
/** A pin is this square (mirrors `--tlui-cmt-pin-size`). The two marker kinds differ in size *and*
|
||||
* anchor point, so lining their previews up means correcting for both. Keep in sync with the CSS. */
|
||||
const PIN_SIZE = 28
|
||||
|
||||
/** A cluster/stack badge is this square (`--tlui-cmt-marker-size`) — pin-sized, so the marker
|
||||
* kinds read as one family. Keep in sync with the stylesheet. */
|
||||
const MARKER_SIZE = PIN_SIZE
|
||||
|
||||
/** Where a popover's top card sits vertically, measured from the marker visual's middle. */
|
||||
const CARD_TOP_Y = -28
|
||||
|
||||
/** The horizontal gap between a marker visual's right edge and its popover/preview panel. The
|
||||
* preview's hover bridge spans exactly this (see `--tlui-cmt-preview-bridge`). */
|
||||
const PREVIEW_GAP = 6
|
||||
|
||||
/**
|
||||
* Where a marker's popover sits relative to the marker's anchor point. The hover preview uses the
|
||||
* same origins, so moving a popover here moves its preview with it.
|
||||
*
|
||||
* Both marker kinds hang off their point the same way (`translate(0, -100%)`), differing only in
|
||||
* size — so each offset clears its own width plus the shared gap, and re-bases the shared card-top
|
||||
* measure from its bottom anchor to its middle.
|
||||
*/
|
||||
export const POPOVER_OFFSET = {
|
||||
thread: { x: PIN_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - PIN_SIZE / 2 },
|
||||
list: { x: MARKER_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - MARKER_SIZE / 2 },
|
||||
} as const
|
||||
|
||||
/** The open thread's popover container, portaled above the UI panels. A wheel over it passes
|
||||
* through to the canvas (unless it scrolls its own content), like tldraw's panels. */
|
||||
export function ThreadPopover({
|
||||
base,
|
||||
children,
|
||||
}: {
|
||||
base: { x: number; y: number }
|
||||
children: ReactNode
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
usePassThroughWheelEvents(ref)
|
||||
// On mobile the popover slides off its fixed offset to stay above the software keyboard and
|
||||
// on-screen; desktop keeps the fixed offset (base returned unchanged).
|
||||
const isMobile = useIsMobileCommenting()
|
||||
const placed = useMobilePlacement(ref, base, isMobile)
|
||||
return (
|
||||
<EditorPortal>
|
||||
{/* contextmenu also stops here: portals bubble React events to the canvas's context-menu
|
||||
trigger (the layer mounts inside it), which would open the canvas menu over this panel. */}
|
||||
<div
|
||||
ref={ref}
|
||||
className="tlui-cmt-canvas-popover"
|
||||
style={{ left: placed.left, top: placed.top }}
|
||||
onPointerDown={stop}
|
||||
onContextMenu={stop}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</EditorPortal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One thread's interactive view: its comments, the reply composer, edit-in-place on your own
|
||||
* comments, and the resolve/delete actions. Read receipts are reported for every unread comment
|
||||
* while mounted, so only mount it where the thread is actually being shown.
|
||||
*
|
||||
* Memoized because the popover position rides the pin's per-frame render point, so the pin
|
||||
* re-renders on every camera frame while a thread is open.
|
||||
*/
|
||||
export const ThreadView = memo(function ThreadView({
|
||||
editor,
|
||||
thread,
|
||||
...props
|
||||
}: CommentingContext & { editor: Editor; thread: TLCommentThread }) {
|
||||
const {
|
||||
currentUserId,
|
||||
resolveAuthor,
|
||||
onPostComment,
|
||||
isCommentUnread,
|
||||
onCommentsRead,
|
||||
getMentionSuggestions,
|
||||
renderMentionSuggestion,
|
||||
getThreadHref,
|
||||
} = props
|
||||
const options = useCommentingOptions()
|
||||
const comments = useThreadComments(editor, thread.id)
|
||||
const msg = useTranslation()
|
||||
const resolveName = useResolveName(resolveAuthor)
|
||||
// Rebuilt only when the comments change, not on every render — each of which would otherwise
|
||||
// re-allocate a date string and body element per comment.
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
comments.map((c) =>
|
||||
toCardProps(c, { currentUserId, resolveAuthor }, options.components, resolveName)
|
||||
),
|
||||
[comments, currentUserId, resolveAuthor, options.components, resolveName]
|
||||
)
|
||||
const me = currentUserId ? resolveAuthor(currentUserId) : undefined
|
||||
// Composing, editing, deleting, and resolving are all gated on the viewer's permission. Where it's
|
||||
// withheld the composer gives way to the ComposerFallback slot and the affordances are hidden.
|
||||
const canComment = useCanComment(currentUserId)
|
||||
// Editing and deleting are further per-record: the author's to do by default, wider or narrower
|
||||
// under a `canModifyComment` callback. Computed for the thread's comments in one pass, since
|
||||
// `renderComment` is a callback rather than a component and can't call a hook per comment.
|
||||
const commentPermissions = useValue(
|
||||
'comment permissions',
|
||||
() =>
|
||||
new Map(
|
||||
comments.map((comment) => [
|
||||
comment.id,
|
||||
{
|
||||
edit: getCanModifyComment(editor, currentUserId, { action: 'edit-comment', comment }),
|
||||
delete: getCanModifyComment(editor, currentUserId, {
|
||||
action: 'delete-comment',
|
||||
comment,
|
||||
}),
|
||||
},
|
||||
])
|
||||
),
|
||||
[editor, currentUserId, comments]
|
||||
)
|
||||
// Deleting a thread is its creator's by default (and server-enforced); `canModifyComment` is what
|
||||
// widens that. Still a commenting write, so `canComment` stays the outer gate.
|
||||
const canModifyThread = useCanModifyComment(currentUserId, { action: 'delete-thread', thread })
|
||||
const canDeleteThread = canComment && canModifyThread
|
||||
const ComposerFallback = options.components.ComposerFallback
|
||||
// An unsent reply survives closing the thread (saved on every change, keyed by thread id) —
|
||||
// the flip side of dismissing without a discard warning.
|
||||
const [reply, setReply] = useState<TLRichText>(
|
||||
() => getCommentDraft(replyDraftSlot(thread.id)) ?? EMPTY_COMMENT
|
||||
)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
|
||||
const canReply = canComment && !thread.resolved
|
||||
const container = useContainer()
|
||||
// Resolved when the composer closes rather than held as an element: the card unmounts while the
|
||||
// composer stands in its place, so the node captured on the way in is gone by the way out.
|
||||
const editReturnFocus = useRef<(() => HTMLElement | null) | null>(null)
|
||||
|
||||
// Tab from the canvas drops the caret in the reply box: a pin click leaves focus on the editor
|
||||
// container, so the first Tab would otherwise walk the app's UI. Capture phase, once per open thread.
|
||||
const [focusReply, setFocusReply] = useState(false)
|
||||
const tabTaken = useRef(false)
|
||||
const swallowTabUp = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!canReply) {
|
||||
// Resolving unmounts the reply box under an open thread. Reopening it should feel like a
|
||||
// freshly opened thread: no autoFocus left armed from the last Tab, and Tab available again.
|
||||
setFocusReply(false)
|
||||
tabTaken.current = false
|
||||
return
|
||||
}
|
||||
const doc = container.ownerDocument
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
||||
if (e.defaultPrevented || tabTaken.current) return
|
||||
// Focus rests on the container (or nothing) after a pin click. Anywhere else is deliberate, and
|
||||
// Tab belongs to whatever holds it.
|
||||
if (e.target !== container && e.target !== doc.body) return
|
||||
tabTaken.current = true
|
||||
swallowTabUp.current = true
|
||||
setFocusReply(true)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
// The select tool navigates shapes on Tab's *keyup*, so a quick tap would both focus the reply and
|
||||
// step the selection. Swallow the release of the press we took, and only that one.
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !swallowTabUp.current) return
|
||||
swallowTabUp.current = false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
doc.addEventListener('keydown', onKeyDown, true)
|
||||
doc.addEventListener('keyup', onKeyUp, true)
|
||||
return () => {
|
||||
doc.removeEventListener('keydown', onKeyDown, true)
|
||||
doc.removeEventListener('keyup', onKeyUp, true)
|
||||
}
|
||||
}, [canReply, container])
|
||||
|
||||
// Leaving the edit composer unmounts it while it holds focus, dropping focus to the editor
|
||||
// container — outside the thread, so a Tab from there walks the app's UI. Hand focus back to
|
||||
// whatever opened the composer instead.
|
||||
useEffect(() => {
|
||||
if (editingId !== null) return
|
||||
const resolve = editReturnFocus.current
|
||||
editReturnFocus.current = null
|
||||
// Runs after React has swapped the card back in, so the target is mounted again by now.
|
||||
resolve?.()?.focus()
|
||||
}, [editingId])
|
||||
|
||||
// Reported as one batch so the host can record the receipts in a single write. The write flips
|
||||
// isCommentUnread to false, so re-runs find nothing to report.
|
||||
useEffect(() => {
|
||||
if (!isCommentUnread || !onCommentsRead) return
|
||||
const unreadIds = comments.filter((comment) => isCommentUnread(comment.id)).map((c) => c.id)
|
||||
if (unreadIds.length > 0) {
|
||||
onCommentsRead(unreadIds)
|
||||
}
|
||||
}, [comments, isCommentUnread, onCommentsRead])
|
||||
|
||||
const postReply = () => {
|
||||
if (isCommentEmpty(reply) || !currentUserId) return
|
||||
const comment = commitCommentMutation(editor, ({ put }) => {
|
||||
const comment = createComment({
|
||||
threadId: thread.id,
|
||||
pageId: thread.pageId,
|
||||
authorId: currentUserId,
|
||||
body: reply,
|
||||
})
|
||||
put([comment])
|
||||
return comment
|
||||
})
|
||||
setReply(EMPTY_COMMENT)
|
||||
clearCommentDraft(replyDraftSlot(thread.id))
|
||||
// The host's callback is its own operation, not part of the post's history scope. It runs
|
||||
// last so a throwing host can't strand the composer holding a draft of a posted reply.
|
||||
onPostComment?.(comment)
|
||||
}
|
||||
|
||||
const toggleResolve = () => {
|
||||
if (!currentUserId) return
|
||||
if (thread.resolved) reopenThread(editor, thread)
|
||||
else resolveThread(editor, thread, currentUserId)
|
||||
}
|
||||
|
||||
// No `currentUserId` check: unlike a resolve, a delete stamps nothing on the record, so who may
|
||||
// make it is `canModifyComment`'s call alone (which withholds it from an unidentified viewer by
|
||||
// default).
|
||||
const removeThread = () => deleteThread(editor, thread)
|
||||
|
||||
const ThreadActions = options.components.ThreadActions
|
||||
// The host's URL for this thread. Its presence is what puts "copy link" in the header menu.
|
||||
const threadHref = getThreadHref?.(thread.id)
|
||||
const [linkCopied, copyThreadLink] = useCopyLink(threadHref)
|
||||
|
||||
const startEdit = (comment: TLComment, { fromMoreMenu = false } = {}) => {
|
||||
// Edit from the ⋯ menu comes back to that button, looked up again on the way out since the card
|
||||
// unmounts. Otherwise return to whatever held focus, ignoring the body and the editor container.
|
||||
const active = container.ownerDocument.activeElement
|
||||
editReturnFocus.current = fromMoreMenu
|
||||
? () => container.querySelector<HTMLElement>(`[data-cmt-more-for="${comment.id}"]`)
|
||||
: active instanceof HTMLElement && active !== container && active !== document.body
|
||||
? () => (active.isConnected ? active : null)
|
||||
: null
|
||||
setEditingId(comment.id)
|
||||
setEditText(comment.body)
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
const comment = comments.find((c) => c.id === editingId)
|
||||
if (!comment || isCommentEmpty(editText)) return
|
||||
editComment(editor, comment, editText)
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
// Swap a comment for a pre-filled composer while it's being edited; otherwise show the card,
|
||||
// with the affordances the viewer is allowed on it.
|
||||
const renderComment = (card: CommentCardProps, index: number): ReactNode => {
|
||||
const comment = comments[index]
|
||||
const permissions = commentPermissions.get(comment.id)
|
||||
if (editingId === comment.id) {
|
||||
return (
|
||||
<div
|
||||
className="tlui-cmt-editing"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setEditingId(null)
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CommentComposer
|
||||
author={card.author}
|
||||
placeholder={msg('comments.edit-placeholder')}
|
||||
value={editText}
|
||||
onChange={setEditText}
|
||||
onSubmit={saveEdit}
|
||||
sendLabel={msg('comments.save')}
|
||||
disabled={isCommentEmpty(editText)}
|
||||
getMentionSuggestions={getMentionSuggestions}
|
||||
renderMentionSuggestion={renderMentionSuggestion}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<CommentCard
|
||||
{...card}
|
||||
footer={
|
||||
<CommentReactions
|
||||
comment={comment}
|
||||
// Reacting is a commenting write: without `canComment` the tally renders
|
||||
// read-only (no identity → pills aren't clickable).
|
||||
currentUserId={canComment ? currentUserId : null}
|
||||
resolveName={resolveName}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
canComment && (
|
||||
<>
|
||||
{(permissions?.edit || permissions?.delete) && (
|
||||
<MoreOptionsMenu id={`comment-actions-${comment.id}`} moreFor={comment.id}>
|
||||
{permissions?.edit && (
|
||||
<MenuItem
|
||||
label={msg('comments.edit')}
|
||||
onClick={() => startEdit(comment, { fromMoreMenu: true })}
|
||||
/>
|
||||
)}
|
||||
{permissions?.delete && (
|
||||
<MenuItem
|
||||
label={msg('action.delete')}
|
||||
danger
|
||||
onClick={() => deleteComment(editor, comment)}
|
||||
/>
|
||||
)}
|
||||
</MoreOptionsMenu>
|
||||
)}
|
||||
{/* Last so react is always the rightmost pill. */}
|
||||
<CommentReactionPicker comment={comment} currentUserId={currentUserId} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve and delete are commenting writes: behind `canComment`, plus the `currentUserId` a
|
||||
// resolve stamps into `resolved.by`.
|
||||
const resolveLabel = msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')
|
||||
const headerActions = (
|
||||
<>
|
||||
{/* Host verbs — assign, link a ticket — sit ahead of the built-in actions. */}
|
||||
{ThreadActions && <ThreadActions thread={thread} comments={comments} />}
|
||||
{(threadHref !== undefined || canDeleteThread) && (
|
||||
<MoreOptionsMenu id={`comment-thread-actions-${thread.id}`}>
|
||||
{/* A link is a read affordance: offered to anyone who can see the thread,
|
||||
including a viewer who can't comment. `noClose` keeps the menu up so the
|
||||
item can confirm the copy in place. */}
|
||||
{threadHref !== undefined && (
|
||||
<MenuItem
|
||||
label={msg(linkCopied ? 'comments.link-copied' : 'comments.copy-link')}
|
||||
noClose
|
||||
onClick={copyThreadLink}
|
||||
/>
|
||||
)}
|
||||
{canDeleteThread && (
|
||||
<MenuItem label={msg('comments.delete')} danger onClick={removeThread} />
|
||||
)}
|
||||
</MoreOptionsMenu>
|
||||
)}
|
||||
{canComment && currentUserId && (
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={resolveLabel}
|
||||
title={resolveLabel}
|
||||
className="tlui-cmt-thread__action"
|
||||
onClick={toggleResolve}
|
||||
>
|
||||
<TldrawUiIcon icon="check" label={resolveLabel} small />
|
||||
</TldrawUiButton>
|
||||
)}
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={msg('comments.dismiss')}
|
||||
title={msg('comments.dismiss')}
|
||||
className="tlui-cmt-thread__action"
|
||||
onClick={() => openThreadId.set(editor, null)}
|
||||
>
|
||||
<TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
|
||||
</TldrawUiButton>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<CommentThread
|
||||
header={msg('comments.thread-title')}
|
||||
headerActions={headerActions}
|
||||
renderComment={renderComment}
|
||||
comments={cards}
|
||||
resolvedBanner={
|
||||
thread.resolved
|
||||
? msg('comments.resolved-by').replace(
|
||||
'{name}',
|
||||
resolveAuthor(thread.resolved.by)?.name ?? UNKNOWN_AUTHOR
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
composer={
|
||||
canReply
|
||||
? {
|
||||
author: me ?? UNKNOWN_COMMENT_AUTHOR,
|
||||
placeholder: msg('comments.reply-placeholder'),
|
||||
sendLabel: msg('comments.send'),
|
||||
value: reply,
|
||||
onChange: (value: TLRichText) => {
|
||||
setReply(value)
|
||||
saveCommentDraft(replyDraftSlot(thread.id), value)
|
||||
},
|
||||
onSubmit: postReply,
|
||||
// Up in the empty reply box edits the comment directly above it, chat-style —
|
||||
// only when you're allowed to edit it (the same gate as the Edit link).
|
||||
onArrowUpWhenEmpty: () => {
|
||||
const last = comments[comments.length - 1]
|
||||
if (last && commentPermissions.get(last.id)?.edit) startEdit(last)
|
||||
},
|
||||
// No user, no author for the record — dead send button.
|
||||
disabled: isCommentEmpty(reply) || !currentUserId,
|
||||
getMentionSuggestions,
|
||||
renderMentionSuggestion,
|
||||
// Reuses the composer's own focus path, so the caret lands at the end of a
|
||||
// restored draft rather than in front of it.
|
||||
autoFocus: focusReply,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
footer={
|
||||
!canComment && !thread.resolved && ComposerFallback ? (
|
||||
<ComposerFallback context="thread" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
300
packages/commenting/src/clustering/computeClusterTable.test.ts
Normal file
300
packages/commenting/src/clustering/computeClusterTable.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeClusterTable } from './computeClusterTable'
|
||||
import { mstEdges } from './mst'
|
||||
import { cappedReplay } from './replay'
|
||||
import { createClusterRuntime } from './runtime'
|
||||
import { contract, finalize } from './schedule'
|
||||
import { ClusterTable, LeafInput, MergeEvent } from './types'
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function shuffled<T>(items: readonly T[], rand: () => number): T[] {
|
||||
const out = items.slice()
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[out[i], out[j]] = [out[j], out[i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
/** The pipeline composed by hand — computeClusterTable must match it exactly. */
|
||||
function manualPipeline(
|
||||
leaves: readonly LeafInput[],
|
||||
opts: {
|
||||
Tc: number
|
||||
Tu: number
|
||||
eps: number
|
||||
Dmax: number
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
maxSplitZoom?: number
|
||||
}
|
||||
): MergeEvent[] {
|
||||
const raw = cappedReplay(leaves, mstEdges(leaves), { Tc: opts.Tc, Dmax: opts.Dmax })
|
||||
return finalize(contract(raw, opts.eps), {
|
||||
Tc: opts.Tc,
|
||||
Tu: opts.Tu,
|
||||
minZoom: opts.minZoom,
|
||||
maxZoom: opts.maxZoom,
|
||||
maxSplitZoom: opts.maxSplitZoom ?? 6,
|
||||
})
|
||||
}
|
||||
|
||||
function eventShape(ev: MergeEvent) {
|
||||
return {
|
||||
zMerge: ev.zMerge,
|
||||
zSplit: ev.zSplit,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
members: ev.result.members,
|
||||
}
|
||||
}
|
||||
|
||||
function tableShapes(table: ClusterTable) {
|
||||
return table.events.map(eventShape)
|
||||
}
|
||||
|
||||
const ZOOM_BOUNDS = { minZoom: 0.05, maxZoom: 8 }
|
||||
|
||||
describe('computeClusterTable composition', () => {
|
||||
it('equals the hand-composed pipeline with all options explicit', () => {
|
||||
const opts = { Tc: 40, Tu: 60, eps: 0.12, Dmax: 120, ...ZOOM_BOUNDS }
|
||||
for (const seed of [1, 42]) {
|
||||
for (const n of [2, 10, 60]) {
|
||||
const leaves = randomLeaves(n, seed * 19 + n)
|
||||
const table = computeClusterTable(leaves, opts)
|
||||
expect(tableShapes(table)).toEqual(manualPipeline(leaves, opts).map(eventShape))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves all defaults from an empty option set (Tc 22, Tu 26.4, eps 0.7, Dmax 82.5)', () => {
|
||||
const leaves = randomLeaves(30, 7)
|
||||
const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
const expected = manualPipeline(leaves, {
|
||||
Tc: 22,
|
||||
Tu: 26.4,
|
||||
eps: 0.7,
|
||||
Dmax: 82.5,
|
||||
...ZOOM_BOUNDS,
|
||||
})
|
||||
expect(tableShapes(table)).toEqual(expected.map(eventShape))
|
||||
})
|
||||
|
||||
it('derives Tu and Dmax from a caller-supplied Tc (Tu = 1.2·Tc, Dmax = 3.75·Tc)', () => {
|
||||
const leaves = randomLeaves(30, 13)
|
||||
const table = computeClusterTable(leaves, { Tc: 50, ...ZOOM_BOUNDS })
|
||||
const expected = manualPipeline(leaves, {
|
||||
Tc: 50,
|
||||
Tu: 60,
|
||||
eps: 0.7,
|
||||
Dmax: 187.5,
|
||||
...ZOOM_BOUNDS,
|
||||
})
|
||||
expect(tableShapes(table)).toEqual(expected.map(eventShape))
|
||||
})
|
||||
|
||||
it('caps coincident pairs at the default maxSplitZoom of 6 (600%)', () => {
|
||||
// Two threads at the same point: raw merge zoom is Infinity (never split), but the
|
||||
// default cap schedules them to merge at 5 (maxSplitZoom / (Tu/Tc) = 6 / 1.2) and split
|
||||
// at 6 like any ultra-tight pair.
|
||||
const leaves = [leaf('a', 50, 50), leaf('b', 50, 50)]
|
||||
const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
expect(table.events).toHaveLength(1)
|
||||
expect(table.events[0].zMerge).toBe(5)
|
||||
expect(table.events[0].zSplit).toBe(6)
|
||||
})
|
||||
|
||||
it('honors partial overrides without disturbing the other defaults', () => {
|
||||
const leaves = randomLeaves(30, 29)
|
||||
const table = computeClusterTable(leaves, { Tu: 100, eps: 0, ...ZOOM_BOUNDS })
|
||||
const expected = manualPipeline(leaves, {
|
||||
Tc: 22,
|
||||
Tu: 100,
|
||||
eps: 0,
|
||||
Dmax: 82.5,
|
||||
...ZOOM_BOUNDS,
|
||||
})
|
||||
expect(tableShapes(table)).toEqual(expected.map(eventShape))
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeClusterTable leaves output', () => {
|
||||
it('returns leaf nodes in input order with exact leaf shape', () => {
|
||||
const leaves = [leaf('z-last', 5, 6), leaf('a-first', 1, 2), leaf('m-mid', 3, 4)]
|
||||
const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
expect(table.leaves).toEqual([
|
||||
{ id: 'z-last', centroid: { x: 5, y: 6 }, count: 1, members: ['z-last'] },
|
||||
{ id: 'a-first', centroid: { x: 1, y: 2 }, count: 1, members: ['a-first'] },
|
||||
{ id: 'm-mid', centroid: { x: 3, y: 4 }, count: 1, members: ['m-mid'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('handles n = 0 and n = 1 (empty events, leaves still produced)', () => {
|
||||
expect(computeClusterTable([], { ...ZOOM_BOUNDS })).toEqual({ events: [], leaves: [] })
|
||||
const table = computeClusterTable([leaf('only', 9, 9)], { ...ZOOM_BOUNDS })
|
||||
expect(table.events).toEqual([])
|
||||
expect(table.leaves).toEqual([
|
||||
{ id: 'only', centroid: { x: 9, y: 9 }, count: 1, members: ['only'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('is usable directly by the runtime (n = 1 and n = 0 tables)', () => {
|
||||
const table = computeClusterTable([leaf('only', 9, 9)], { ...ZOOM_BOUNDS })
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(1)
|
||||
rt.onCamera(0.1)
|
||||
expect([...rt.getVisible().keys()]).toEqual(['only'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeClusterTable determinism', () => {
|
||||
it('is invariant under input permutation, events as id-keyed structures', () => {
|
||||
const base = randomLeaves(50, 23)
|
||||
const expected = tableShapes(computeClusterTable(base, { ...ZOOM_BOUNDS }))
|
||||
for (const shuffleSeed of [5, 17]) {
|
||||
const perm = shuffled(base, mulberry32(shuffleSeed))
|
||||
const table = computeClusterTable(perm, { ...ZOOM_BOUNDS })
|
||||
expect(tableShapes(table)).toEqual(expected)
|
||||
// leaves follow the (new) input order
|
||||
expect(table.leaves.map((l) => l.id)).toEqual(perm.map((l) => l.id))
|
||||
}
|
||||
})
|
||||
|
||||
it('returns identical output for repeated calls', () => {
|
||||
const leaves = randomLeaves(20, 3)
|
||||
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS })).toEqual(
|
||||
computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeClusterTable validation', () => {
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
|
||||
|
||||
it('rejects every invalid option combination', () => {
|
||||
expect(() => computeClusterTable(leaves, { Tc: 0, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { Tc: -5, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { Tc: 40, Tu: 40, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { Tu: 20, ...ZOOM_BOUNDS })).toThrow() // < default Tc 22
|
||||
expect(() => computeClusterTable(leaves, { eps: -0.01, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { Dmax: 20, ...ZOOM_BOUNDS })).toThrow() // < default Tc 22
|
||||
expect(() => computeClusterTable(leaves, { Tc: 50, Dmax: 40, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { minZoom: 0, maxZoom: 8 })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { minZoom: 8, maxZoom: 8 })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { minZoom: 9, maxZoom: 8 })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { minZoom: 0.05, maxZoom: NaN })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { minZoom: 0.05, maxZoom: Infinity })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { eps: NaN, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { Tc: NaN, ...ZOOM_BOUNDS })).toThrow()
|
||||
})
|
||||
|
||||
it('validates even when there is nothing to cluster', () => {
|
||||
expect(() => computeClusterTable([], { minZoom: 0, maxZoom: 8 })).toThrow()
|
||||
expect(() => computeClusterTable([leaf('x', 0, 0)], { Tc: -1, ...ZOOM_BOUNDS })).toThrow()
|
||||
})
|
||||
|
||||
it('propagates input errors from the MST stage', () => {
|
||||
expect(() =>
|
||||
computeClusterTable([leaf('dup', 0, 0), leaf('dup', 1, 1)], { ...ZOOM_BOUNDS })
|
||||
).toThrow(/dup/)
|
||||
expect(() =>
|
||||
computeClusterTable([leaf('ok', 0, 0), leaf('bad', NaN, 0)], { ...ZOOM_BOUNDS })
|
||||
).toThrow(/bad/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeClusterTable end-to-end scenarios', () => {
|
||||
it('produces the appendix A.2 zoom bands for the 8-pin chain, via the runtime', () => {
|
||||
const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
|
||||
const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
|
||||
// events (centroid pricing): four pair births at zMerge 2.2 (zSplit 2.64), two
|
||||
// quads at zMerge 1.1 (zSplit 1.32), the bridge at zMerge 0.55 (zSplit 0.66)
|
||||
const seedAt = (zoom: number) => {
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(zoom)
|
||||
return [...rt.getVisible().keys()].sort()
|
||||
}
|
||||
// zoom 8: above every band midpoint → 8 separate pins
|
||||
expect(seedAt(8)).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8'])
|
||||
// zoom 2: below the pair midpoints (√5.81 ≈ 2.41), above the quad midpoint (√1.45 ≈ 1.2)
|
||||
expect(seedAt(2)).toEqual(['cluster:2:p1', 'cluster:2:p3', 'cluster:2:p5', 'cluster:2:p7'])
|
||||
// zoom 1: quads merged, above the bridge midpoint (√0.363 ≈ 0.6)
|
||||
expect(seedAt(1)).toEqual(['cluster:4:p1', 'cluster:4:p5'])
|
||||
// zoom 0.5: below everything → one badge of 8
|
||||
expect(seedAt(0.5)).toEqual(['cluster:8:p1'])
|
||||
})
|
||||
|
||||
it('keeps far-apart groups exactly independent at eps = 0 (bridge pruned by minZoom)', () => {
|
||||
// Two groups 10,000 page units apart: the bridging merge would fire at
|
||||
// z ≈ 40/10000 = 0.004 < minZoom → pruned. With eps = 0 no contraction
|
||||
// window can couple the groups, so the combined table is exactly the two
|
||||
// solo tables' events merged in threshold order.
|
||||
const groupA = randomLeaves(12, 5, 300)
|
||||
const groupB = randomLeaves(12, 9, 300).map((l) => ({
|
||||
id: `far-${l.id}`,
|
||||
point: { x: l.point.x + 10000, y: l.point.y },
|
||||
}))
|
||||
const opts = { eps: 0, ...ZOOM_BOUNDS }
|
||||
|
||||
const combined = tableShapes(computeClusterTable([...groupA, ...groupB], opts))
|
||||
const soloA = tableShapes(computeClusterTable(groupA, opts))
|
||||
const soloB = tableShapes(computeClusterTable(groupB, opts))
|
||||
|
||||
const merged = [...soloA, ...soloB].sort((a, b) => b.zMerge - a.zMerge)
|
||||
expect(combined.map((e) => e.result).sort()).toEqual(merged.map((e) => e.result).sort())
|
||||
expect(combined).toHaveLength(soloA.length + soloB.length)
|
||||
|
||||
// no event ever mixes members across the gap
|
||||
for (const ev of combined) {
|
||||
const far = ev.members.filter((m) => m.startsWith('far-')).length
|
||||
expect(far === 0 || far === ev.members.length).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps group memberships unmixed at default eps too (thresholds may synchronize)', () => {
|
||||
// Global contraction windows may pull the two groups' thresholds together
|
||||
// (synchronized firing is by design) but must never fuse their clusters.
|
||||
const groupA = randomLeaves(10, 55, 300)
|
||||
const groupB = randomLeaves(10, 66, 300).map((l) => ({
|
||||
id: `far-${l.id}`,
|
||||
point: { x: l.point.x + 10000, y: l.point.y },
|
||||
}))
|
||||
const combined = computeClusterTable([...groupA, ...groupB], { ...ZOOM_BOUNDS })
|
||||
for (const ev of combined.events) {
|
||||
const far = ev.result.members.filter((m) => m.startsWith('far-')).length
|
||||
expect(far === 0 || far === ev.result.members.length).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeClusterTable performance', () => {
|
||||
it('builds 2,000 leaves well within budget', () => {
|
||||
const leaves = randomLeaves(2000, 2026)
|
||||
const start = performance.now()
|
||||
const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
const elapsed = performance.now() - start
|
||||
expect(table.leaves).toHaveLength(2000)
|
||||
// generous bound to stay non-flaky on slow CI
|
||||
expect(elapsed).toBeLessThan(2500)
|
||||
})
|
||||
})
|
||||
74
packages/commenting/src/clustering/computeClusterTable.ts
Normal file
74
packages/commenting/src/clustering/computeClusterTable.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { mstEdges } from './mst'
|
||||
import { cappedReplay } from './replay'
|
||||
import { contract, finalize } from './schedule'
|
||||
import type {
|
||||
ClusterNode,
|
||||
ClusterOptions,
|
||||
ClusterTable,
|
||||
LeafInput,
|
||||
LeafScreenOffsets,
|
||||
} from './types'
|
||||
|
||||
/** @internal */
|
||||
export function computeClusterTable(
|
||||
leaves: readonly LeafInput[],
|
||||
options: ClusterOptions,
|
||||
// Render offsets for markers that draw off their anchor (imprecise pins), keyed by leaf id.
|
||||
// Omitted or empty, the run is identical — output and code paths — to an offset-unaware one.
|
||||
screenOffsets?: LeafScreenOffsets
|
||||
): ClusterTable {
|
||||
const opts = resolveOptions(options)
|
||||
const leafNodes = leaves.map(leafToNode)
|
||||
const edges = mstEdges(leaves)
|
||||
|
||||
if (leaves.length < 2) {
|
||||
return { events: [], leaves: leafNodes }
|
||||
}
|
||||
|
||||
const raw = cappedReplay(leaves, edges, opts, screenOffsets)
|
||||
const events = finalize(contract(raw, opts.eps), opts)
|
||||
|
||||
return { events, leaves: leafNodes }
|
||||
}
|
||||
|
||||
function resolveOptions(options: ClusterOptions): Required<ClusterOptions> {
|
||||
const Tc = options.Tc ?? 22
|
||||
const Tu = options.Tu ?? 1.2 * Tc
|
||||
const eps = options.eps ?? 0.7
|
||||
const Dmax = options.Dmax ?? 3.75 * Tc
|
||||
const maxSplitZoom = options.maxSplitZoom ?? 6
|
||||
const { minZoom, maxZoom } = options
|
||||
|
||||
if (!Number.isFinite(Tc) || Tc <= 0) {
|
||||
throw new Error('Tc must be finite and greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(Tu) || Tu <= Tc) {
|
||||
throw new Error('Tu must be finite and greater than Tc')
|
||||
}
|
||||
if (!Number.isFinite(eps) || eps < 0) {
|
||||
throw new Error('eps must be finite and greater than or equal to 0')
|
||||
}
|
||||
if (!Number.isFinite(Dmax) || Dmax < Tc) {
|
||||
throw new Error('Dmax must be finite and greater than or equal to Tc')
|
||||
}
|
||||
if (!Number.isFinite(minZoom) || minZoom <= 0) {
|
||||
throw new Error('minZoom must be finite and greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(maxZoom) || maxZoom <= minZoom) {
|
||||
throw new Error('maxZoom must be finite and greater than minZoom')
|
||||
}
|
||||
if (!Number.isFinite(maxSplitZoom) || maxSplitZoom <= 0) {
|
||||
throw new Error('maxSplitZoom must be finite and greater than 0')
|
||||
}
|
||||
|
||||
return { Tc, Tu, eps, Dmax, minZoom, maxZoom, maxSplitZoom }
|
||||
}
|
||||
|
||||
function leafToNode(leaf: LeafInput): ClusterNode {
|
||||
return {
|
||||
id: leaf.id,
|
||||
centroid: { x: leaf.point.x, y: leaf.point.y },
|
||||
count: 1,
|
||||
members: [leaf.id],
|
||||
}
|
||||
}
|
||||
189
packages/commenting/src/clustering/locality.test.ts
Normal file
189
packages/commenting/src/clustering/locality.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeClusterTable } from './computeClusterTable'
|
||||
import { createClusterRuntime } from './runtime'
|
||||
import { ClusterTable, LeafInput, MergeEvent } from './types'
|
||||
|
||||
// The locality contract the overlay's rendering relies on (CLUSTERING.md §8.4):
|
||||
// a visible cluster's members never sit farther than (1+eps)·r·Dmax screen px
|
||||
// from its centroid, so culling with that margin never hides a badge whose
|
||||
// member anchors are on screen. The bound stacks all three mechanisms:
|
||||
// cap — zEff·pageDiag ≤ Dmax at every raw merge
|
||||
// contraction— fusing to the window anchor overshoots by ≤ (1+eps)
|
||||
// hysteresis — a merged cluster survives up to zSplit = r·zMerge
|
||||
// and uses the FULL extent, not half: a count-weighted centroid of a skewed
|
||||
// cluster (many pins on one side, one far out) can sit a full extent from its
|
||||
// farthest member.
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
const OPTS = { Tc: 40, Tu: 60, eps: 0.12, Dmax: 120, minZoom: 0.05, maxZoom: 8, maxSplitZoom: 1e9 }
|
||||
const R = OPTS.Tu / OPTS.Tc
|
||||
const SLACK = 1 + 1e-9
|
||||
|
||||
function pointsOf(leaves: readonly LeafInput[]): Map<string, { x: number; y: number }> {
|
||||
return new Map(leaves.map((l) => [l.id, l.point]))
|
||||
}
|
||||
|
||||
/** Bbox diagonal of a member set, recomputed from the original leaf anchors. */
|
||||
function memberDiag(
|
||||
members: readonly string[],
|
||||
pts: Map<string, { x: number; y: number }>
|
||||
): number {
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const id of members) {
|
||||
const p = pts.get(id)!
|
||||
minX = Math.min(minX, p.x)
|
||||
minY = Math.min(minY, p.y)
|
||||
maxX = Math.max(maxX, p.x)
|
||||
maxY = Math.max(maxY, p.y)
|
||||
}
|
||||
return Math.hypot(maxX - minX, maxY - minY)
|
||||
}
|
||||
|
||||
function zoomWalk(steps: number, seed: number): number[] {
|
||||
const rand = mulberry32(seed)
|
||||
const lnMin = Math.log(OPTS.minZoom)
|
||||
const lnMax = Math.log(OPTS.maxZoom)
|
||||
let ln = (lnMin + lnMax) / 2
|
||||
const walk: number[] = []
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const r = rand()
|
||||
if (r < 0.15) ln = lnMin + rand() * (lnMax - lnMin)
|
||||
else ln = Math.min(lnMax, Math.max(lnMin, ln + (rand() - 0.5) * 0.4))
|
||||
walk.push(Math.exp(ln))
|
||||
}
|
||||
return walk
|
||||
}
|
||||
|
||||
describe('locality bounds (the culling contract)', () => {
|
||||
it('bounds every finite event at birth: zMerge · pageDiag(result) ≤ (1+eps) · Dmax', () => {
|
||||
for (const seed of [3, 42, 77]) {
|
||||
const leaves = randomLeaves(50, seed)
|
||||
const table = computeClusterTable(leaves, OPTS)
|
||||
const pts = pointsOf(leaves)
|
||||
for (const ev of table.events) {
|
||||
if (!Number.isFinite(ev.zMerge)) continue
|
||||
const diag = memberDiag(ev.result.members, pts)
|
||||
expect(ev.zMerge * diag).toBeLessThanOrEqual((1 + OPTS.eps) * OPTS.Dmax * SLACK)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds every VISIBLE cluster during random zoom walks: member-to-centroid ≤ (1+eps)·r·Dmax screen px', () => {
|
||||
const margin = (1 + OPTS.eps) * R * OPTS.Dmax * SLACK
|
||||
for (const seed of [5, 21]) {
|
||||
const leaves = randomLeaves(40, seed * 7)
|
||||
const table = computeClusterTable(leaves, OPTS)
|
||||
const pts = pointsOf(leaves)
|
||||
const rt = createClusterRuntime(table)
|
||||
const walk = zoomWalk(200, seed)
|
||||
rt.seed(walk[0])
|
||||
for (const zoom of walk) {
|
||||
rt.onCamera(zoom)
|
||||
for (const cluster of rt.getVisible().values()) {
|
||||
if (cluster.count === 1) continue
|
||||
for (const id of cluster.members) {
|
||||
const p = pts.get(id)!
|
||||
const dist = Math.hypot(p.x - cluster.centroid.x, p.y - cluster.centroid.y)
|
||||
expect(zoom * dist).toBeLessThanOrEqual(margin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('exercises the skewed-centroid worst case (many pins one side, one far out)', () => {
|
||||
// 9 pins tightly packed at the origin plus one at distance 35: the
|
||||
// count-weighted centroid sits near the pack, so the far member is almost
|
||||
// a FULL extent away — the case that breaks a Dmax/2-style margin.
|
||||
const leaves = [
|
||||
...Array.from({ length: 9 }, (_, i) => leaf(`pack-${i}`, (i % 3) * 2, Math.floor(i / 3) * 2)),
|
||||
leaf('far', 39, 2),
|
||||
]
|
||||
const table = computeClusterTable(leaves, OPTS)
|
||||
const pts = pointsOf(leaves)
|
||||
const full = table.events.find((ev) => ev.result.count === 10)
|
||||
expect(full).toBeDefined()
|
||||
// the far member really is beyond half the extent from the centroid…
|
||||
const diag = memberDiag(full!.result.members, pts)
|
||||
const p = pts.get('far')!
|
||||
const dist = Math.hypot(p.x - full!.result.centroid.x, p.y - full!.result.centroid.y)
|
||||
expect(dist).toBeGreaterThan(diag / 2)
|
||||
// …and still within the full-extent bound at any zoom the cluster survives
|
||||
expect(full!.zSplit * dist).toBeLessThanOrEqual((1 + OPTS.eps) * R * OPTS.Dmax * SLACK)
|
||||
})
|
||||
})
|
||||
|
||||
describe('move lifecycle (pick up = delete, drop = insert — CLUSTERING.md §9.2)', () => {
|
||||
function tableShapes(table: ClusterTable) {
|
||||
return table.events.map((ev: MergeEvent) => ({
|
||||
zMerge: ev.zMerge,
|
||||
zSplit: ev.zSplit,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
}))
|
||||
}
|
||||
|
||||
it('drop-then-rebuild equals a fresh build of the final positions', () => {
|
||||
const before = randomLeaves(20, 11)
|
||||
// pick up leaf-5 (delete)…
|
||||
const during = before.filter((l) => l.id !== 'leaf-5')
|
||||
// …drop it somewhere else (insert at the end — array order differs from a
|
||||
// fresh build, which must not matter)
|
||||
const after = [...during, leaf('leaf-5', 777, 333)]
|
||||
const fresh = before.map((l) => (l.id === 'leaf-5' ? leaf('leaf-5', 777, 333) : l))
|
||||
|
||||
expect(tableShapes(computeClusterTable(after, OPTS))).toEqual(
|
||||
tableShapes(computeClusterTable(fresh, OPTS))
|
||||
)
|
||||
})
|
||||
|
||||
it('the mid-drag table is simply the table without the picked-up comment', () => {
|
||||
const before = randomLeaves(12, 13)
|
||||
const during = before.filter((l) => l.id !== 'leaf-3')
|
||||
const table = computeClusterTable(during, OPTS)
|
||||
expect(table.leaves.some((l) => l.id === 'leaf-3')).toBe(false)
|
||||
for (const ev of table.events) {
|
||||
expect(ev.result.members).not.toContain('leaf-3')
|
||||
}
|
||||
})
|
||||
|
||||
it('re-seeding after the rebuild matches seeding the fresh table directly', () => {
|
||||
const before = randomLeaves(20, 17)
|
||||
const after = [...before.filter((l) => l.id !== 'leaf-5'), leaf('leaf-5', 777, 333)]
|
||||
const rebuilt = createClusterRuntime(computeClusterTable(after, OPTS))
|
||||
const freshRt = createClusterRuntime(createFreshTableSameShape(after))
|
||||
for (const zoom of [0.1, 0.5, 1, 2, 4, 7.9]) {
|
||||
rebuilt.seed(zoom)
|
||||
freshRt.seed(zoom)
|
||||
expect([...rebuilt.getVisible().keys()].sort()).toEqual(
|
||||
[...freshRt.getVisible().keys()].sort()
|
||||
)
|
||||
}
|
||||
|
||||
function createFreshTableSameShape(leaves: LeafInput[]): ClusterTable {
|
||||
return computeClusterTable(leaves.slice().reverse(), OPTS)
|
||||
}
|
||||
})
|
||||
})
|
||||
383
packages/commenting/src/clustering/mst.test.ts
Normal file
383
packages/commenting/src/clustering/mst.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mstEdges } from './mst'
|
||||
import { LeafInput, MstEdge } from './types'
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
/** An edge as a canonical `idLo|idHi` string (output edges are already id-normalized). */
|
||||
function pairKey(e: MstEdge, leaves: readonly LeafInput[]): string {
|
||||
return `${leaves[e.a].id}|${leaves[e.b].id}`
|
||||
}
|
||||
|
||||
function pairKeySet(edges: MstEdge[], leaves: readonly LeafInput[]): Set<string> {
|
||||
return new Set(edges.map((e) => pairKey(e, leaves)))
|
||||
}
|
||||
|
||||
/** Map of `idLo|idHi` → distance, for order-independent comparisons across shuffles. */
|
||||
function pairDistanceMap(edges: MstEdge[], leaves: readonly LeafInput[]): Map<string, number> {
|
||||
return new Map(edges.map((e) => [pairKey(e, leaves), e.d]))
|
||||
}
|
||||
|
||||
/** Deterministic PRNG so random tests are reproducible. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function shuffled<T>(items: readonly T[], rand: () => number): T[] {
|
||||
const out = items.slice()
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[out[i], out[j]] = [out[j], out[i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
class UnionFind {
|
||||
private parent: number[]
|
||||
constructor(n: number) {
|
||||
this.parent = Array.from({ length: n }, (_, i) => i)
|
||||
}
|
||||
find(x: number): number {
|
||||
while (this.parent[x] !== x) {
|
||||
this.parent[x] = this.parent[this.parent[x]]
|
||||
x = this.parent[x]
|
||||
}
|
||||
return x
|
||||
}
|
||||
union(a: number, b: number): boolean {
|
||||
const ra = this.find(a)
|
||||
const rb = this.find(b)
|
||||
if (ra === rb) return false
|
||||
this.parent[ra] = rb
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference oracle: Kruskal over the complete graph under the contract's strict edge
|
||||
* order — (distance, idLo, idHi). Distances are compared as squares, which orders
|
||||
* identically to true distances and avoids sqrt rounding. This is the definition the
|
||||
* implementation must reproduce exactly, ties and all.
|
||||
*/
|
||||
function referenceKruskal(leaves: readonly LeafInput[]): Set<string> {
|
||||
const n = leaves.length
|
||||
if (n < 2) return new Set()
|
||||
const all: { i: number; j: number; d2: number; idLo: string; idHi: string }[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const dx = leaves[i].point.x - leaves[j].point.x
|
||||
const dy = leaves[i].point.y - leaves[j].point.y
|
||||
const [idLo, idHi] =
|
||||
leaves[i].id < leaves[j].id ? [leaves[i].id, leaves[j].id] : [leaves[j].id, leaves[i].id]
|
||||
all.push({ i, j, d2: dx * dx + dy * dy, idLo, idHi })
|
||||
}
|
||||
}
|
||||
all.sort((a, b) => {
|
||||
if (a.d2 !== b.d2) return a.d2 - b.d2
|
||||
if (a.idLo !== b.idLo) return a.idLo < b.idLo ? -1 : 1
|
||||
return a.idHi < b.idHi ? -1 : 1
|
||||
})
|
||||
const uf = new UnionFind(n)
|
||||
const accepted = new Set<string>()
|
||||
for (const e of all) {
|
||||
if (uf.union(e.i, e.j)) {
|
||||
accepted.add(`${e.idLo}|${e.idHi}`)
|
||||
if (accepted.size === n - 1) break
|
||||
}
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
/** Assert the structural contract: n−1 edges, spanning, no duplicate pairs, valid endpoints. */
|
||||
function expectSpanningTree(edges: MstEdge[], leaves: readonly LeafInput[]) {
|
||||
const n = leaves.length
|
||||
expect(edges).toHaveLength(Math.max(0, n - 1))
|
||||
const uf = new UnionFind(n)
|
||||
const seen = new Set<string>()
|
||||
for (const e of edges) {
|
||||
expect(Number.isInteger(e.a)).toBe(true)
|
||||
expect(Number.isInteger(e.b)).toBe(true)
|
||||
expect(e.a).toBeGreaterThanOrEqual(0)
|
||||
expect(e.b).toBeGreaterThanOrEqual(0)
|
||||
expect(e.a).toBeLessThan(n)
|
||||
expect(e.b).toBeLessThan(n)
|
||||
expect(e.a).not.toBe(e.b)
|
||||
const key = pairKey(e, leaves)
|
||||
expect(seen.has(key)).toBe(false)
|
||||
seen.add(key)
|
||||
// n−1 accepted unions with no rejected ones ⇒ connected and acyclic
|
||||
expect(uf.union(e.a, e.b)).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert output ordering (d, then idLo, then idHi) and endpoint id-normalization. */
|
||||
function expectSortedAndNormalized(edges: MstEdge[], leaves: readonly LeafInput[]) {
|
||||
for (const e of edges) {
|
||||
expect(leaves[e.a].id < leaves[e.b].id).toBe(true)
|
||||
}
|
||||
for (let i = 1; i < edges.length; i++) {
|
||||
const prev = edges[i - 1]
|
||||
const curr = edges[i]
|
||||
if (prev.d !== curr.d) {
|
||||
expect(prev.d).toBeLessThan(curr.d)
|
||||
continue
|
||||
}
|
||||
const prevLo = leaves[prev.a].id
|
||||
const currLo = leaves[curr.a].id
|
||||
if (prevLo !== currLo) {
|
||||
expect(prevLo < currLo).toBe(true)
|
||||
continue
|
||||
}
|
||||
expect(leaves[prev.b].id < leaves[curr.b].id).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert each edge's distance is the exact Math.hypot of its endpoints. */
|
||||
function expectExactDistances(edges: MstEdge[], leaves: readonly LeafInput[]) {
|
||||
for (const e of edges) {
|
||||
const expected = Math.hypot(
|
||||
leaves[e.a].point.x - leaves[e.b].point.x,
|
||||
leaves[e.a].point.y - leaves[e.b].point.y
|
||||
)
|
||||
expect(e.d).toBe(expected)
|
||||
}
|
||||
}
|
||||
|
||||
describe('mstEdges fixtures', () => {
|
||||
it('returns [] for zero and one leaves', () => {
|
||||
expect(mstEdges([])).toEqual([])
|
||||
expect(mstEdges([leaf('only', 5, 5)])).toEqual([])
|
||||
})
|
||||
|
||||
it('connects two points with one exact-distance edge', () => {
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 3, 4)]
|
||||
expect(mstEdges(leaves)).toEqual([{ a: 0, b: 1, d: 5 }])
|
||||
})
|
||||
|
||||
it('normalizes endpoints by id, not input order', () => {
|
||||
// 'b' comes first in the array; the edge must still point a→'a', b→'b'
|
||||
const leaves = [leaf('b', 3, 4), leaf('a', 0, 0)]
|
||||
expect(mstEdges(leaves)).toEqual([{ a: 1, b: 0, d: 5 }])
|
||||
})
|
||||
|
||||
it('handles coincident points with a d = 0 edge', () => {
|
||||
const leaves = [leaf('a', 7, 7), leaf('b', 7, 7)]
|
||||
expect(mstEdges(leaves)).toEqual([{ a: 0, b: 1, d: 0 }])
|
||||
})
|
||||
|
||||
it('builds the path MST for collinear points with distinct gaps, sorted ascending', () => {
|
||||
// x positions 0, 1, 3, 7 → consecutive gaps 1, 2, 4; no skip edge can beat them
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 1, 0), leaf('c', 3, 0), leaf('d', 7, 0)]
|
||||
expect(mstEdges(leaves)).toEqual([
|
||||
{ a: 0, b: 1, d: 1 },
|
||||
{ a: 1, b: 2, d: 2 },
|
||||
{ a: 2, b: 3, d: 4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('breaks four-way side ties of the unit square by id order', () => {
|
||||
// All four sides have d = 1. Kruskal under (d, idLo, idHi) accepts
|
||||
// (a,b), then (a,d), then (b,c); (c,d) would close the cycle.
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 1, 0), leaf('c', 1, 1), leaf('d', 0, 1)]
|
||||
expect(mstEdges(leaves)).toEqual([
|
||||
{ a: 0, b: 1, d: 1 },
|
||||
{ a: 0, b: 3, d: 1 },
|
||||
{ a: 1, b: 2, d: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('builds the path MST for the uniform chain (the clustering worst case)', () => {
|
||||
// 8 pins spaced 10 apart — CLUSTERING.md appendix A.2. All 7 edges d = 10.
|
||||
const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
|
||||
const edges = mstEdges(leaves)
|
||||
expect(edges.map((e) => pairKey(e, leaves))).toEqual([
|
||||
'p1|p2',
|
||||
'p2|p3',
|
||||
'p3|p4',
|
||||
'p4|p5',
|
||||
'p5|p6',
|
||||
'p6|p7',
|
||||
'p7|p8',
|
||||
])
|
||||
for (const e of edges) expect(e.d).toBe(10)
|
||||
})
|
||||
|
||||
it('crosses a dumbbell gap exactly once, via the closest pair (cut property)', () => {
|
||||
// Two tight clusters far apart; l2/r0 is the unique closest crossing pair.
|
||||
const leaves = [
|
||||
leaf('l0', 0, 0),
|
||||
leaf('l1', 2, 1),
|
||||
leaf('l2', 4, 0),
|
||||
leaf('r0', 100, 0),
|
||||
leaf('r1', 102, 1),
|
||||
leaf('r2', 104, 0),
|
||||
]
|
||||
const edges = mstEdges(leaves)
|
||||
const crossings = edges.filter(
|
||||
(e) => leaves[e.a].id.startsWith('l') !== leaves[e.b].id.startsWith('l')
|
||||
)
|
||||
expect(crossings).toHaveLength(1)
|
||||
expect(pairKey(crossings[0], leaves)).toBe('l2|r0')
|
||||
expect(crossings[0].d).toBe(96)
|
||||
})
|
||||
|
||||
it('chains coincident points with d = 0 edges plus one bridge', () => {
|
||||
const leaves = [leaf('a', 5, 5), leaf('b', 5, 5), leaf('c', 5, 5), leaf('far', 105, 5)]
|
||||
const edges = mstEdges(leaves)
|
||||
const zeros = edges.filter((e) => e.d === 0)
|
||||
const bridges = edges.filter((e) => e.d !== 0)
|
||||
expect(zeros).toHaveLength(2)
|
||||
expect(bridges).toHaveLength(1)
|
||||
expect(bridges[0].d).toBe(100)
|
||||
// zero edges come first in the ascending sort, tie-broken by id: (a,b) then (a,c)
|
||||
expect(edges.slice(0, 2).map((e) => pairKey(e, leaves))).toEqual(['a|b', 'a|c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mstEdges vs reference Kruskal oracle', () => {
|
||||
it.each([2, 3, 5, 10, 50, 120])('matches the oracle on %i random points', (n) => {
|
||||
for (const seed of [1, 42, 1234]) {
|
||||
const leaves = randomLeaves(n, seed * 31 + n)
|
||||
const edges = mstEdges(leaves)
|
||||
expectSpanningTree(edges, leaves)
|
||||
expectSortedAndNormalized(edges, leaves)
|
||||
expectExactDistances(edges, leaves)
|
||||
expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the oracle on a 5×5 integer grid (maximal distance ties)', () => {
|
||||
const leaves: LeafInput[] = []
|
||||
for (let y = 0; y < 5; y++) {
|
||||
for (let x = 0; x < 5; x++) {
|
||||
leaves.push(leaf(`g-${x}-${y}`, x, y))
|
||||
}
|
||||
}
|
||||
const edges = mstEdges(leaves)
|
||||
expectSpanningTree(edges, leaves)
|
||||
expectSortedAndNormalized(edges, leaves)
|
||||
// every grid MST edge is an axis-aligned unit step
|
||||
for (const e of edges) expect(e.d).toBe(1)
|
||||
expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
|
||||
})
|
||||
|
||||
it('matches the oracle across realistic canvas coordinate regimes', () => {
|
||||
// Canvas pages routinely have negative coordinates (panning left/up of the
|
||||
// origin), large offsets, and tightly packed points — none of which the
|
||||
// uniform [0, 1000]² sets exercise.
|
||||
const regimes: { name: string; make(rand: () => number, i: number): LeafInput }[] = [
|
||||
{
|
||||
name: 'mixed-sign',
|
||||
make: (rand, i) => leaf(`m-${i}`, (rand() - 0.5) * 2000, (rand() - 0.5) * 2000),
|
||||
},
|
||||
{
|
||||
name: 'far-offset',
|
||||
make: (rand, i) => leaf(`f-${i}`, -5_000_000 + rand() * 1000, 3_000_000 + rand() * 1000),
|
||||
},
|
||||
{
|
||||
name: 'tiny-scale',
|
||||
make: (rand, i) => leaf(`t-${i}`, rand() * 0.001, rand() * 0.001),
|
||||
},
|
||||
]
|
||||
for (const regime of regimes) {
|
||||
const rand = mulberry32(101)
|
||||
const leaves = Array.from({ length: 60 }, (_, i) => regime.make(rand, i))
|
||||
const edges = mstEdges(leaves)
|
||||
expectSpanningTree(edges, leaves)
|
||||
expectSortedAndNormalized(edges, leaves)
|
||||
expectExactDistances(edges, leaves)
|
||||
expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the oracle on random lattice points (ties + coincident points)', () => {
|
||||
const rand = mulberry32(7)
|
||||
const leaves = Array.from({ length: 60 }, (_, i) =>
|
||||
leaf(`lat-${i}`, Math.floor(rand() * 10), Math.floor(rand() * 10))
|
||||
)
|
||||
const edges = mstEdges(leaves)
|
||||
expectSpanningTree(edges, leaves)
|
||||
expectSortedAndNormalized(edges, leaves)
|
||||
expectExactDistances(edges, leaves)
|
||||
expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
|
||||
})
|
||||
})
|
||||
|
||||
describe('mstEdges determinism', () => {
|
||||
it('is invariant under input permutation (as an id-pair → distance map)', () => {
|
||||
const base = randomLeaves(80, 99)
|
||||
const expected = pairDistanceMap(mstEdges(base), base)
|
||||
for (const shuffleSeed of [5, 17, 23]) {
|
||||
const perm = shuffled(base, mulberry32(shuffleSeed))
|
||||
expect(pairDistanceMap(mstEdges(perm), perm)).toEqual(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('is invariant under permutation even with heavy ties', () => {
|
||||
// Uniform chains have all-equal edge lengths — tie-breaking must be id-based,
|
||||
// never index-based, or a shuffled rebuild would produce a different tree.
|
||||
const base = Array.from({ length: 12 }, (_, i) =>
|
||||
leaf(`c${String(i).padStart(2, '0')}`, i * 10, 0)
|
||||
)
|
||||
const expected = pairDistanceMap(mstEdges(base), base)
|
||||
for (const shuffleSeed of [3, 11]) {
|
||||
const perm = shuffled(base, mulberry32(shuffleSeed))
|
||||
expect(pairDistanceMap(mstEdges(perm), perm)).toEqual(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns identical output for repeated calls on the same input', () => {
|
||||
const leaves = randomLeaves(40, 7)
|
||||
expect(mstEdges(leaves)).toEqual(mstEdges(leaves))
|
||||
})
|
||||
|
||||
it('does not mutate its input', () => {
|
||||
const leaves = randomLeaves(10, 3)
|
||||
const snapshot = JSON.parse(JSON.stringify(leaves))
|
||||
mstEdges(leaves)
|
||||
expect(leaves).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mstEdges validation', () => {
|
||||
it('throws on duplicate ids, naming the duplicate', () => {
|
||||
const leaves = [leaf('a', 0, 0), leaf('dup', 1, 0), leaf('dup', 2, 0)]
|
||||
expect(() => mstEdges(leaves)).toThrow(/dup/)
|
||||
})
|
||||
|
||||
it('throws on non-finite coordinates, naming the leaf', () => {
|
||||
expect(() => mstEdges([leaf('ok', 0, 0), leaf('bad-x', NaN, 0)])).toThrow(/bad-x/)
|
||||
expect(() => mstEdges([leaf('ok', 0, 0), leaf('bad-y', 0, Infinity)])).toThrow(/bad-y/)
|
||||
expect(() => mstEdges([leaf('ok', 0, 0), leaf('bad-neg', 0, -Infinity)])).toThrow(/bad-neg/)
|
||||
})
|
||||
|
||||
it('fails fast on invalid input even when n < 2', () => {
|
||||
expect(() => mstEdges([leaf('bad', NaN, NaN)])).toThrow(/bad/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mstEdges performance', () => {
|
||||
it('handles 4,000 points well within budget', () => {
|
||||
const leaves = randomLeaves(4000, 2026)
|
||||
const start = performance.now()
|
||||
const edges = mstEdges(leaves)
|
||||
const elapsed = performance.now() - start
|
||||
expect(edges).toHaveLength(3999)
|
||||
// generous bound to stay non-flaky on slow CI; typical runs are ~100× faster
|
||||
expect(elapsed).toBeLessThan(2000)
|
||||
})
|
||||
})
|
||||
134
packages/commenting/src/clustering/mst.ts
Normal file
134
packages/commenting/src/clustering/mst.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { LeafInput, MstEdge } from './types'
|
||||
|
||||
/**
|
||||
* Return the Euclidean MST over the leaves, ordered by the canonical edge key:
|
||||
* distance, then normalized endpoint ids.
|
||||
*/
|
||||
export function mstEdges(leaves: readonly LeafInput[]): MstEdge[] {
|
||||
const n = leaves.length
|
||||
const ids = new Array<string>(n)
|
||||
const xs = new Float64Array(n)
|
||||
const ys = new Float64Array(n)
|
||||
const seen = new Set<string>()
|
||||
|
||||
let root = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const leaf = leaves[i]
|
||||
if (seen.has(leaf.id)) {
|
||||
throw new Error(`Duplicate leaf id: ${leaf.id}`)
|
||||
}
|
||||
seen.add(leaf.id)
|
||||
if (!Number.isFinite(leaf.point.x) || !Number.isFinite(leaf.point.y)) {
|
||||
throw new Error(`Non-finite coordinate for leaf id: ${leaf.id}`)
|
||||
}
|
||||
ids[i] = leaf.id
|
||||
xs[i] = leaf.point.x
|
||||
ys[i] = leaf.point.y
|
||||
if (i > 0 && leaf.id < ids[root]) root = i
|
||||
}
|
||||
|
||||
if (n < 2) return []
|
||||
|
||||
const inTree = new Uint8Array(n)
|
||||
const bestD2 = new Float64Array(n)
|
||||
const bestFrom = new Int32Array(n)
|
||||
|
||||
inTree[root] = 1
|
||||
bestD2.fill(Number.POSITIVE_INFINITY)
|
||||
bestFrom.fill(-1)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i === root) continue
|
||||
bestD2[i] = squaredDistance(xs, ys, root, i)
|
||||
bestFrom[i] = root
|
||||
}
|
||||
|
||||
const edges: MstEdge[] = []
|
||||
|
||||
for (let edgeCount = 0; edgeCount < n - 1; edgeCount++) {
|
||||
let next = -1
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (inTree[i]) continue
|
||||
if (
|
||||
next === -1 ||
|
||||
edgeKeyLess(bestD2[i], bestFrom[i], i, bestD2[next], bestFrom[next], next, ids)
|
||||
) {
|
||||
next = i
|
||||
}
|
||||
}
|
||||
|
||||
const from = bestFrom[next]
|
||||
edges.push(createOutputEdge(xs, ys, ids, from, next))
|
||||
inTree[next] = 1
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (inTree[i]) continue
|
||||
const d2 = squaredDistance(xs, ys, next, i)
|
||||
if (edgeKeyLess(d2, next, i, bestD2[i], bestFrom[i], i, ids)) {
|
||||
bestD2[i] = d2
|
||||
bestFrom[i] = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
edges.sort((a, b) => outputEdgeCompare(a, b, leaves))
|
||||
return edges
|
||||
}
|
||||
|
||||
function squaredDistance(xs: Float64Array, ys: Float64Array, a: number, b: number): number {
|
||||
const dx = xs[a] - xs[b]
|
||||
const dy = ys[a] - ys[b]
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
function edgeKeyLess(
|
||||
d2A: number,
|
||||
a0: number,
|
||||
a1: number,
|
||||
d2B: number,
|
||||
b0: number,
|
||||
b1: number,
|
||||
ids: readonly string[]
|
||||
): boolean {
|
||||
if (d2A !== d2B) return d2A < d2B
|
||||
|
||||
const aLo = ids[a0] < ids[a1] ? ids[a0] : ids[a1]
|
||||
const aHi = ids[a0] < ids[a1] ? ids[a1] : ids[a0]
|
||||
const bLo = ids[b0] < ids[b1] ? ids[b0] : ids[b1]
|
||||
const bHi = ids[b0] < ids[b1] ? ids[b1] : ids[b0]
|
||||
|
||||
if (aLo !== bLo) return aLo < bLo
|
||||
return aHi < bHi
|
||||
}
|
||||
|
||||
function createOutputEdge(
|
||||
xs: Float64Array,
|
||||
ys: Float64Array,
|
||||
ids: readonly string[],
|
||||
i: number,
|
||||
j: number
|
||||
): MstEdge {
|
||||
const a = ids[i] < ids[j] ? i : j
|
||||
const b = ids[i] < ids[j] ? j : i
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
d: Math.hypot(xs[a] - xs[b], ys[a] - ys[b]),
|
||||
}
|
||||
}
|
||||
|
||||
function outputEdgeCompare(a: MstEdge, b: MstEdge, leaves: readonly LeafInput[]): number {
|
||||
if (a.d < b.d) return -1
|
||||
if (a.d > b.d) return 1
|
||||
|
||||
const aLo = leaves[a.a].id
|
||||
const aHi = leaves[a.b].id
|
||||
const bLo = leaves[b.a].id
|
||||
const bHi = leaves[b.b].id
|
||||
|
||||
if (aLo < bLo) return -1
|
||||
if (aLo > bLo) return 1
|
||||
if (aHi < bHi) return -1
|
||||
if (aHi > bHi) return 1
|
||||
return 0
|
||||
}
|
||||
540
packages/commenting/src/clustering/replay.test.ts
Normal file
540
packages/commenting/src/clustering/replay.test.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mstEdges } from './mst'
|
||||
// These imports are red until step 2 is implemented — that is intentional.
|
||||
// Implement `replay.ts` (and the step 2 additions to `types.ts`) per
|
||||
// CLUSTERING-STEPS.md until this suite passes, without modifying this file.
|
||||
import { cappedReplay, D_FLOOR } from './replay'
|
||||
import { ClusterNode, LeafInput, MstEdge, RawMergeEvent } from './types'
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
/** Deterministic PRNG so random tests are reproducible. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function shuffled<T>(items: readonly T[], rand: () => number): T[] {
|
||||
const out = items.slice()
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[out[i], out[j]] = [out[j], out[i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
/** Order-independent, index-free projection of an event for cross-run comparison. */
|
||||
function eventShape(ev: RawMergeEvent) {
|
||||
return {
|
||||
z: ev.z,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
members: ev.result.members,
|
||||
count: ev.result.count,
|
||||
}
|
||||
}
|
||||
|
||||
function eventShapes(events: RawMergeEvent[]) {
|
||||
return events.map(eventShape)
|
||||
}
|
||||
|
||||
// Direct transcription of contract clause 4 (CLUSTERING-STEPS.md step 2): at
|
||||
// each of the n−1 steps, evaluate every unfired MST edge's zEff against the
|
||||
// CURRENT clusters and fire the highest, tie-broken by the edge's normalized
|
||||
// id pair. O(n · E) per build — slow and obviously correct.
|
||||
|
||||
interface RefCluster {
|
||||
members: string[] // sorted lexicographic; members[0] is the min-member id
|
||||
centroid: { x: number; y: number }
|
||||
count: number
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
}
|
||||
|
||||
function refNode(c: RefCluster): ClusterNode {
|
||||
return {
|
||||
id: c.count === 1 ? c.members[0] : `cluster:${c.count}:${c.members[0]}`,
|
||||
centroid: { x: c.centroid.x, y: c.centroid.y },
|
||||
count: c.count,
|
||||
members: c.members.slice(),
|
||||
}
|
||||
}
|
||||
|
||||
function refDiag(a: RefCluster, b: RefCluster): number {
|
||||
const w = Math.max(a.maxX, b.maxX) - Math.min(a.minX, b.minX)
|
||||
const h = Math.max(a.maxY, b.maxY) - Math.min(a.minY, b.minY)
|
||||
return Math.hypot(w, h)
|
||||
}
|
||||
|
||||
function referenceReplay(
|
||||
leaves: readonly LeafInput[],
|
||||
edges: readonly MstEdge[],
|
||||
opts: { Tc: number; Dmax: number }
|
||||
): RawMergeEvent[] {
|
||||
const n = leaves.length
|
||||
const parent = Array.from({ length: n }, (_, i) => i)
|
||||
const find = (x: number): number => {
|
||||
while (parent[x] !== x) {
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
}
|
||||
return x
|
||||
}
|
||||
const clusters = new Map<number, RefCluster>()
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = leaves[i].point
|
||||
clusters.set(i, {
|
||||
members: [leaves[i].id],
|
||||
centroid: { x: p.x, y: p.y },
|
||||
count: 1,
|
||||
minX: p.x,
|
||||
minY: p.y,
|
||||
maxX: p.x,
|
||||
maxY: p.y,
|
||||
})
|
||||
}
|
||||
|
||||
const zOf = (e: MstEdge): number => {
|
||||
// coincident anchors: permanently merged; the fit term is not consulted
|
||||
if (e.d < D_FLOOR) return Infinity
|
||||
const a = clusters.get(find(e.a))!
|
||||
const b = clusters.get(find(e.b))!
|
||||
// badge-anchored gap: priced by the clusters' centroid distance
|
||||
const gap = opts.Tc / Math.hypot(a.centroid.x - b.centroid.x, a.centroid.y - b.centroid.y)
|
||||
const fit = opts.Dmax / refDiag(a, b)
|
||||
return Math.min(gap, fit)
|
||||
}
|
||||
|
||||
const unfired = edges.slice()
|
||||
const events: RawMergeEvent[] = []
|
||||
let lastZ = Infinity // emitted thresholds are clamped non-increasing
|
||||
while (unfired.length > 0) {
|
||||
let best = 0
|
||||
let bestZ = zOf(unfired[0])
|
||||
for (let i = 1; i < unfired.length; i++) {
|
||||
const z = zOf(unfired[i])
|
||||
if (z > bestZ) {
|
||||
best = i
|
||||
bestZ = z
|
||||
continue
|
||||
}
|
||||
if (z === bestZ) {
|
||||
// tie-break by the edge's normalized id pair, ascending idLo then idHi
|
||||
const a = unfired[i]
|
||||
const b = unfired[best]
|
||||
const aLo = leaves[a.a].id
|
||||
const aHi = leaves[a.b].id
|
||||
const bLo = leaves[b.a].id
|
||||
const bHi = leaves[b.b].id
|
||||
if (aLo < bLo || (aLo === bLo && aHi < bHi)) {
|
||||
best = i
|
||||
bestZ = z
|
||||
}
|
||||
}
|
||||
}
|
||||
const e = unfired.splice(best, 1)[0]
|
||||
const ra = find(e.a)
|
||||
const rb = find(e.b)
|
||||
const ca = clusters.get(ra)!
|
||||
const cb = clusters.get(rb)!
|
||||
const [first, second] = ca.members[0] < cb.members[0] ? [ca, cb] : [cb, ca]
|
||||
const count = ca.count + cb.count
|
||||
const merged: RefCluster = {
|
||||
members: [...ca.members, ...cb.members].sort(),
|
||||
centroid: {
|
||||
x: (ca.count * ca.centroid.x + cb.count * cb.centroid.x) / count,
|
||||
y: (ca.count * ca.centroid.y + cb.count * cb.centroid.y) / count,
|
||||
},
|
||||
count,
|
||||
minX: Math.min(ca.minX, cb.minX),
|
||||
minY: Math.min(ca.minY, cb.minY),
|
||||
maxX: Math.max(ca.maxX, cb.maxX),
|
||||
maxY: Math.max(ca.maxY, cb.maxY),
|
||||
}
|
||||
parent[ra] = rb
|
||||
clusters.delete(ra)
|
||||
clusters.set(find(rb), merged)
|
||||
const z = Math.min(bestZ, lastZ)
|
||||
lastZ = z
|
||||
events.push({
|
||||
z,
|
||||
children: [refNode(first), refNode(second)],
|
||||
result: refNode(merged),
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function expectNonIncreasingZ(events: RawMergeEvent[]) {
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i].z).toBeLessThanOrEqual(events[i - 1].z)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dendrogram integrity: children are consumed exactly once, results are well
|
||||
* formed unions, and replaying all events over the leaves ends in one root.
|
||||
*/
|
||||
function expectDendrogramIntegrity(events: RawMergeEvent[], leaves: readonly LeafInput[]) {
|
||||
const live = new Map<string, ClusterNode>()
|
||||
for (const l of leaves) {
|
||||
live.set(l.id, { id: l.id, centroid: { ...l.point }, count: 1, members: [l.id] })
|
||||
}
|
||||
for (const ev of events) {
|
||||
expect(ev.children).toHaveLength(2)
|
||||
// children must currently exist (consumed exactly once, produced before use)
|
||||
for (const child of ev.children) {
|
||||
expect(live.get(child.id)).toBeDefined()
|
||||
live.delete(child.id)
|
||||
}
|
||||
// children tuple ordered by ascending min-member id
|
||||
expect(ev.children[0].members[0] < ev.children[1].members[0]).toBe(true)
|
||||
// result members = sorted union of children members
|
||||
const union = [...ev.children[0].members, ...ev.children[1].members].sort()
|
||||
expect(ev.result.members).toEqual(union)
|
||||
expect(ev.result.count).toBe(ev.children[0].count + ev.children[1].count)
|
||||
expect(ev.result.id).toBe(`cluster:${ev.result.count}:${union[0]}`)
|
||||
expect(live.has(ev.result.id)).toBe(false)
|
||||
live.set(ev.result.id, ev.result)
|
||||
}
|
||||
// n−1 merges over a spanning tree always end in a single root
|
||||
if (leaves.length >= 2) {
|
||||
expect(live.size).toBe(1)
|
||||
const root = [...live.values()][0]
|
||||
expect(root.members).toEqual(leaves.map((l) => l.id).sort())
|
||||
}
|
||||
}
|
||||
|
||||
/** Locality invariant: at its birth zoom, a cluster spans at most Dmax screen px. */
|
||||
function expectLocality(events: RawMergeEvent[], leaves: readonly LeafInput[], Dmax: number) {
|
||||
const pointOf = new Map(leaves.map((l) => [l.id, l.point]))
|
||||
for (const ev of events) {
|
||||
if (!Number.isFinite(ev.z)) continue
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const id of ev.result.members) {
|
||||
const p = pointOf.get(id)!
|
||||
minX = Math.min(minX, p.x)
|
||||
minY = Math.min(minY, p.y)
|
||||
maxX = Math.max(maxX, p.x)
|
||||
maxY = Math.max(maxY, p.y)
|
||||
}
|
||||
const diag = Math.hypot(maxX - minX, maxY - minY)
|
||||
expect(ev.z * diag).toBeLessThanOrEqual(Dmax * (1 + 1e-9))
|
||||
}
|
||||
}
|
||||
|
||||
/** Centroids equal the count-weighted mean = plain mean of all member anchors. */
|
||||
function expectCentroids(events: RawMergeEvent[], leaves: readonly LeafInput[]) {
|
||||
const pointOf = new Map(leaves.map((l) => [l.id, l.point]))
|
||||
for (const ev of events) {
|
||||
let sx = 0
|
||||
let sy = 0
|
||||
for (const id of ev.result.members) {
|
||||
const p = pointOf.get(id)!
|
||||
sx += p.x
|
||||
sy += p.y
|
||||
}
|
||||
expect(ev.result.centroid.x).toBeCloseTo(sx / ev.result.count, 8)
|
||||
expect(ev.result.centroid.y).toBeCloseTo(sy / ev.result.count, 8)
|
||||
}
|
||||
}
|
||||
|
||||
describe('cappedReplay fixtures', () => {
|
||||
it('exports the pinned D_FLOOR constant', () => {
|
||||
expect(D_FLOOR).toBe(1e-9)
|
||||
})
|
||||
|
||||
it('returns [] for fewer than two leaves', () => {
|
||||
expect(cappedReplay([], [], { Tc: 40, Dmax: 120 })).toEqual([])
|
||||
const one = [leaf('only', 3, 3)]
|
||||
expect(cappedReplay(one, mstEdges(one), { Tc: 40, Dmax: 120 })).toEqual([])
|
||||
})
|
||||
|
||||
it('merges a gap-limited pair at z = Tc/d with exact node bookkeeping', () => {
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
expect(events).toEqual([
|
||||
{
|
||||
z: 4,
|
||||
children: [
|
||||
{ id: 'a', centroid: { x: 0, y: 0 }, count: 1, members: ['a'] },
|
||||
{ id: 'b', centroid: { x: 10, y: 0 }, count: 1, members: ['b'] },
|
||||
],
|
||||
result: { id: 'cluster:2:a', centroid: { x: 5, y: 0 }, count: 2, members: ['a', 'b'] },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('gives coincident anchors z = Infinity', () => {
|
||||
const leaves = [leaf('a', 7, 7), leaf('b', 7, 7)]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].z).toBe(Infinity)
|
||||
expect(events[0].result.id).toBe('cluster:2:a')
|
||||
})
|
||||
|
||||
it('treats sub-D_FLOOR distances as coincident, but not distances above it', () => {
|
||||
const below = [leaf('a', 0, 0), leaf('b', 1e-12, 0)]
|
||||
expect(cappedReplay(below, mstEdges(below), { Tc: 40, Dmax: 120 })[0].z).toBe(Infinity)
|
||||
const above = [leaf('a', 0, 0), leaf('b', 1e-6, 0)]
|
||||
const [ev] = cappedReplay(above, mstEdges(above), { Tc: 40, Dmax: 120 })
|
||||
expect(ev.z).toBe(40 / 1e-6)
|
||||
expect(Number.isFinite(ev.z)).toBe(true)
|
||||
})
|
||||
|
||||
it('fires the coincident merge first, then the bridge (Infinity sorts to the head)', () => {
|
||||
const leaves = [leaf('a', 5, 5), leaf('b', 5, 5), leaf('c', 105, 5)]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
expect(eventShapes(events)).toEqual([
|
||||
{ z: Infinity, children: ['a', 'b'], result: 'cluster:2:a', members: ['a', 'b'], count: 2 },
|
||||
{
|
||||
z: 40 / 100,
|
||||
children: ['cluster:2:a', 'c'],
|
||||
result: 'cluster:3:a',
|
||||
members: ['a', 'b', 'c'],
|
||||
count: 3,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('replays the ABCDE path of CLUSTERING.md appendix A.1 (cap not binding)', () => {
|
||||
// a —10— b —25— c —12— d —40— e, Tc = 300, cap effectively off.
|
||||
// The MST edges still decide WHICH clusters merge; the gap term prices
|
||||
// each merge by the clusters' CENTROID distance:
|
||||
// a+b at 300/10 (leaves: centroid dist = edge length)
|
||||
// c+d at 300/12... no — c–d edge, leaves, d = 12 → 300/12 = 25
|
||||
// {a,b}+{c,d} via b–c: centroids (5,0) vs (41,0) → 300/36
|
||||
// +e via d–e: centroids (23,0) vs (87,0) → 300/64
|
||||
const leaves = [
|
||||
leaf('a', 0, 0),
|
||||
leaf('b', 10, 0),
|
||||
leaf('c', 35, 0),
|
||||
leaf('d', 47, 0),
|
||||
leaf('e', 87, 0),
|
||||
]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 300, Dmax: 1e9 })
|
||||
expect(eventShapes(events)).toEqual([
|
||||
{ z: 30, children: ['a', 'b'], result: 'cluster:2:a', members: ['a', 'b'], count: 2 },
|
||||
{ z: 25, children: ['c', 'd'], result: 'cluster:2:c', members: ['c', 'd'], count: 2 },
|
||||
{
|
||||
z: 300 / 36,
|
||||
children: ['cluster:2:a', 'cluster:2:c'],
|
||||
result: 'cluster:4:a',
|
||||
members: ['a', 'b', 'c', 'd'],
|
||||
count: 4,
|
||||
},
|
||||
{
|
||||
z: 300 / 64,
|
||||
children: ['cluster:4:a', 'e'],
|
||||
result: 'cluster:5:a',
|
||||
members: ['a', 'b', 'c', 'd', 'e'],
|
||||
count: 5,
|
||||
},
|
||||
])
|
||||
// exact incremental centroids: (5,0), (41,0), (23,0), (35.8,0)
|
||||
expect(events.map((e) => e.result.centroid.x)).toEqual([5, 41, 23, 35.8])
|
||||
})
|
||||
|
||||
it('prices cluster merges by badge (centroid) distance, not nearest members', () => {
|
||||
// Two tight pairs whose facing members are only 14 apart but whose
|
||||
// centroids are 20 apart. Nearest-member pricing would join the badges at
|
||||
// z = 40/14 ≈ 2.86 — while the rendered badges still look far apart.
|
||||
// Centroid pricing fires at 40/20 = 2.
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 6, 0), leaf('c', 20, 0), leaf('d', 26, 0)]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 1000 })
|
||||
expect(events.map((e) => e.z)).toEqual([40 / 6, 40 / 6, 2])
|
||||
expect(events[2].children.map((c) => c.id)).toEqual(['cluster:2:a', 'cluster:2:c'])
|
||||
})
|
||||
|
||||
it('builds the 8-pin chain hierarchically: pairs, then quads, then the octet', () => {
|
||||
// p1..p8 spaced 10 apart, Tc = 40, Dmax = 120 (CLUSTERING.md appendix A.2).
|
||||
// Leaf pairs all price at 40/10 = 4. Once a pair forms, extending it is
|
||||
// priced from the pair's CENTROID (dist 15 → z ≈ 2.67), which loses ties
|
||||
// against the remaining leaf pairs — so the chain forms 4 pairs first.
|
||||
// Pair-pair merges price at centroid dist 20 → z = 2 (quads), and the
|
||||
// final quad-quad bridge at centroid dist 40 (centroids 15 and 55) → z = 1.
|
||||
// Centroid pricing alone now produces the progressive doubling the cap
|
||||
// used to force; the cap remains as the hard extent guarantee.
|
||||
const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
expect(events.map((e) => e.z)).toEqual([4, 4, 4, 4, 2, 2, 1])
|
||||
expect(events.map((e) => e.result.id)).toEqual([
|
||||
'cluster:2:p1',
|
||||
'cluster:2:p3',
|
||||
'cluster:2:p5',
|
||||
'cluster:2:p7',
|
||||
'cluster:4:p1',
|
||||
'cluster:4:p5',
|
||||
'cluster:8:p1',
|
||||
])
|
||||
expect(events[4].children.map((c) => c.id)).toEqual(['cluster:2:p1', 'cluster:2:p3'])
|
||||
expect(events[6].children.map((c) => c.id)).toEqual(['cluster:4:p1', 'cluster:4:p5'])
|
||||
expect(events[6].result.members).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8'])
|
||||
})
|
||||
|
||||
it('computes the fit term from the union bbox DIAGONAL, not an axis span', () => {
|
||||
// {a,b} spans (0,0)–(0,30); adding c(40,30) makes the union bbox 40×30,
|
||||
// diag hypot(40,30) = 50. With Tc = 100, Dmax = 110:
|
||||
// a–b: leaves at dist 30 → min(100/30, 110/30) = 100/30 (gap-limited)
|
||||
// b–c after a+b: centroid (0,15) to c(40,30) → gap = 100/√1825 ≈ 2.34;
|
||||
// fit via the diagonal = 110/50 = 2.2 wins (an axis-span implementation
|
||||
// would give 110/40 = 2.75 and the gap term would win instead)
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 0, 30), leaf('c', 40, 30)]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 100, Dmax: 110 })
|
||||
expect(events.map((e) => e.z)).toEqual([100 / 30, 2.2])
|
||||
expect(events[1].result.id).toBe('cluster:3:a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cappedReplay vs reference simulator', () => {
|
||||
const OPTION_SETS = [
|
||||
{ Tc: 40, Dmax: 120 }, // default shape
|
||||
{ Tc: 40, Dmax: 40 }, // tightest legal cap — maximally fit-limited
|
||||
{ Tc: 5, Dmax: 1000 }, // cap effectively off
|
||||
]
|
||||
|
||||
it.each([2, 3, 5, 10, 40, 80])('matches the oracle on %i random points', (n) => {
|
||||
for (const seed of [1, 42]) {
|
||||
const leaves = randomLeaves(n, seed * 31 + n)
|
||||
const edges = mstEdges(leaves)
|
||||
for (const opts of OPTION_SETS) {
|
||||
const actual = cappedReplay(leaves, edges, opts)
|
||||
const expected = referenceReplay(leaves, edges, opts)
|
||||
expect(eventShapes(actual)).toEqual(eventShapes(expected))
|
||||
// pinned formulas (hypot diag, incremental centroid) ⇒ exact floats
|
||||
expect(actual.map((e) => e.z)).toEqual(expected.map((e) => e.z))
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
expect(actual[i].result.centroid).toEqual(expected[i].result.centroid)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the oracle on mixed-sign and far-offset coordinates', () => {
|
||||
const rand = mulberry32(101)
|
||||
const mixed = Array.from({ length: 40 }, (_, i) =>
|
||||
leaf(`m-${i}`, (rand() - 0.5) * 2000, (rand() - 0.5) * 2000)
|
||||
)
|
||||
const offset = Array.from({ length: 40 }, (_, i) =>
|
||||
leaf(`f-${i}`, -5_000_000 + rand() * 1000, 3_000_000 + rand() * 1000)
|
||||
)
|
||||
for (const leaves of [mixed, offset]) {
|
||||
const edges = mstEdges(leaves)
|
||||
for (const opts of OPTION_SETS) {
|
||||
expect(eventShapes(cappedReplay(leaves, edges, opts))).toEqual(
|
||||
eventShapes(referenceReplay(leaves, edges, opts))
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the oracle on lattice points (exact ties + coincident anchors)', () => {
|
||||
const rand = mulberry32(7)
|
||||
const leaves = Array.from({ length: 50 }, (_, i) =>
|
||||
leaf(`lat-${i}`, Math.floor(rand() * 10) * 10, Math.floor(rand() * 10) * 10)
|
||||
)
|
||||
const edges = mstEdges(leaves)
|
||||
for (const opts of OPTION_SETS) {
|
||||
expect(eventShapes(cappedReplay(leaves, edges, opts))).toEqual(
|
||||
eventShapes(referenceReplay(leaves, edges, opts))
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('cappedReplay invariants', () => {
|
||||
it('satisfies all structural invariants on random inputs', () => {
|
||||
for (const seed of [3, 99]) {
|
||||
for (const n of [2, 7, 30, 60]) {
|
||||
const leaves = randomLeaves(n, seed * 17 + n)
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
expect(events).toHaveLength(n - 1)
|
||||
expectNonIncreasingZ(events)
|
||||
expectDendrogramIntegrity(events, leaves)
|
||||
expectLocality(events, leaves, 120)
|
||||
expectCentroids(events, leaves)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('holds the locality invariant even at the tightest legal cap', () => {
|
||||
const leaves = randomLeaves(50, 13)
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 40 })
|
||||
expectNonIncreasingZ(events)
|
||||
expectDendrogramIntegrity(events, leaves)
|
||||
expectLocality(events, leaves, 40)
|
||||
})
|
||||
|
||||
it('is invariant under input permutation (as id-keyed event structures)', () => {
|
||||
const base = randomLeaves(40, 21)
|
||||
const expected = eventShapes(cappedReplay(base, mstEdges(base), { Tc: 40, Dmax: 120 }))
|
||||
for (const shuffleSeed of [5, 23]) {
|
||||
const perm = shuffled(base, mulberry32(shuffleSeed))
|
||||
expect(eventShapes(cappedReplay(perm, mstEdges(perm), { Tc: 40, Dmax: 120 }))).toEqual(
|
||||
expected
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('is pure: identical repeat output, no input mutation', () => {
|
||||
const leaves = randomLeaves(20, 5)
|
||||
const edges = mstEdges(leaves)
|
||||
const leavesSnapshot = JSON.parse(JSON.stringify(leaves))
|
||||
const edgesSnapshot = JSON.parse(JSON.stringify(edges))
|
||||
const first = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
|
||||
const second = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
|
||||
expect(first).toEqual(second)
|
||||
expect(leaves).toEqual(leavesSnapshot)
|
||||
expect(edges).toEqual(edgesSnapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cappedReplay validation', () => {
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
|
||||
const edges = mstEdges(leaves)
|
||||
|
||||
it('throws on non-positive Tc, naming the option', () => {
|
||||
expect(() => cappedReplay(leaves, edges, { Tc: 0, Dmax: 120 })).toThrow(/Tc/)
|
||||
expect(() => cappedReplay(leaves, edges, { Tc: -5, Dmax: 120 })).toThrow(/Tc/)
|
||||
})
|
||||
|
||||
it('throws on non-positive Dmax, naming the option', () => {
|
||||
expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: 0 })).toThrow(/Dmax/)
|
||||
expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: -1 })).toThrow(/Dmax/)
|
||||
})
|
||||
|
||||
it('throws when Dmax < Tc, naming the option', () => {
|
||||
expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: 30 })).toThrow(/Dmax/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cappedReplay performance', () => {
|
||||
it('replays 2,000 points well within budget', () => {
|
||||
const leaves = randomLeaves(2000, 2026)
|
||||
const edges = mstEdges(leaves)
|
||||
const start = performance.now()
|
||||
const events = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
|
||||
const elapsed = performance.now() - start
|
||||
expect(events).toHaveLength(1999)
|
||||
// generous bound to stay non-flaky on slow CI
|
||||
expect(elapsed).toBeLessThan(2000)
|
||||
})
|
||||
})
|
||||
412
packages/commenting/src/clustering/replay.ts
Normal file
412
packages/commenting/src/clustering/replay.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import type { ClusterNode, LeafInput, LeafScreenOffsets, MstEdge, RawMergeEvent } from './types'
|
||||
|
||||
export const D_FLOOR = 1e-9
|
||||
|
||||
export function cappedReplay(
|
||||
leaves: readonly LeafInput[],
|
||||
edges: readonly MstEdge[],
|
||||
opts: { Tc: number; Dmax: number },
|
||||
// Render offsets for markers that draw off their anchor (imprecise pins). Omitted or empty,
|
||||
// every code path below is the offset-unaware original — pricing, events, and floats alike.
|
||||
screenOffsets?: LeafScreenOffsets
|
||||
): RawMergeEvent[] {
|
||||
validateOptions(opts)
|
||||
|
||||
if (edges.length === 0) return []
|
||||
|
||||
const clusters = new ClusterState(leaves, screenOffsets)
|
||||
const heap = new EdgeMaxHeap(edges, leaves)
|
||||
// Edges incident to each current cluster root, for eager repricing: a merge
|
||||
// moves the result's centroid, which can RAISE an incident edge's key (the
|
||||
// centroid may move toward a neighbor), so the lazy pop-time recheck alone
|
||||
// is not enough — every price change pushes a fresh heap entry.
|
||||
const incident: number[][] = leaves.map(() => [])
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
heap.push({ edgeIndex: i, z: zForEdge(edges[i], clusters, opts) })
|
||||
incident[edges[i].a].push(i)
|
||||
incident[edges[i].b].push(i)
|
||||
}
|
||||
|
||||
const events: RawMergeEvent[] = []
|
||||
// Emitted thresholds are clamped non-increasing: with centroid pricing a
|
||||
// later merge's raw price can in principle exceed an earlier one's, and the
|
||||
// table's descending sort is load-bearing for the runtime cursor.
|
||||
let lastZ = Number.POSITIVE_INFINITY
|
||||
while (events.length < edges.length) {
|
||||
const entry = heap.pop()
|
||||
if (!entry) break
|
||||
|
||||
const edge = edges[entry.edgeIndex]
|
||||
const aRoot = clusters.find(edge.a)
|
||||
const bRoot = clusters.find(edge.b)
|
||||
if (aRoot === bRoot) continue // stale duplicate of an already-fired edge
|
||||
|
||||
const current = { edgeIndex: entry.edgeIndex, z: zForRoots(edge, aRoot, bRoot, clusters, opts) }
|
||||
const next = heap.peek()
|
||||
if (next && heap.higherPriority(next, current)) {
|
||||
heap.push(current)
|
||||
continue
|
||||
}
|
||||
|
||||
const z = Math.min(current.z, lastZ)
|
||||
lastZ = z
|
||||
events.push(clusters.merge(aRoot, bRoot, z))
|
||||
|
||||
// eager reprice: the new cluster's centroid and bbox changed, so every
|
||||
// surviving incident edge gets a fresh entry at its current price
|
||||
const root = clusters.find(edge.a)
|
||||
const other = root === aRoot ? bRoot : aRoot
|
||||
const survivors: number[] = []
|
||||
for (const idx of [...incident[root], ...incident[other]]) {
|
||||
const e2 = edges[idx]
|
||||
const ra = clusters.find(e2.a)
|
||||
const rb = clusters.find(e2.b)
|
||||
if (ra === rb) continue
|
||||
survivors.push(idx)
|
||||
heap.push({ edgeIndex: idx, z: zForRoots(e2, ra, rb, clusters, opts) })
|
||||
}
|
||||
incident[root] = survivors
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function validateOptions(opts: { Tc: number; Dmax: number }) {
|
||||
if (!Number.isFinite(opts.Tc) || opts.Tc <= 0) {
|
||||
throw new Error('Tc must be greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(opts.Dmax) || opts.Dmax <= 0) {
|
||||
throw new Error('Dmax must be greater than 0')
|
||||
}
|
||||
if (opts.Dmax < opts.Tc) {
|
||||
throw new Error('Dmax must be greater than or equal to Tc')
|
||||
}
|
||||
}
|
||||
|
||||
function zForEdge(
|
||||
edge: MstEdge,
|
||||
clusters: ClusterState,
|
||||
opts: { Tc: number; Dmax: number }
|
||||
): number {
|
||||
return zForRoots(edge, clusters.find(edge.a), clusters.find(edge.b), clusters, opts)
|
||||
}
|
||||
|
||||
function zForRoots(
|
||||
edge: MstEdge,
|
||||
aRoot: number,
|
||||
bRoot: number,
|
||||
clusters: ClusterState,
|
||||
opts: { Tc: number; Dmax: number }
|
||||
): number {
|
||||
// Null unless offset pricing is active AND this pair's mean render offsets differ. Every
|
||||
// other pair — including every pair when no offsets were passed — takes the branch below,
|
||||
// which is the offset-unaware pricing verbatim.
|
||||
const off = clusters.offsetDelta(aRoot, bRoot)
|
||||
if (off === null) {
|
||||
if (edge.d < D_FLOOR) return Number.POSITIVE_INFINITY
|
||||
// Badge-anchored gap pricing: clusters render as badges at their centroids,
|
||||
// so the merge is priced by the distance between the rendered centers, not
|
||||
// the nearest members. For leaves the two are identical; for clusters the
|
||||
// centroid distance is larger, so groups merge later than their closest
|
||||
// members would suggest — matching what the user actually sees.
|
||||
// (Coincident CENTROIDS with non-coincident members leave the gap term
|
||||
// Infinity and the fit term finite — the min stays finite, no special case.)
|
||||
const gap = opts.Tc / clusters.centroidDistance(aRoot, bRoot)
|
||||
const fit = opts.Dmax / clusters.unionBboxDiag(aRoot, bRoot)
|
||||
return Math.min(gap, fit)
|
||||
}
|
||||
|
||||
// Offset-aware gap pricing: these markers render at `z·centroid + meanOffset`, so their
|
||||
// visual distance at zoom z is |z·ΔC + Δō| and the merge prices at its Tc crossing. When
|
||||
// that distance never reaches Tc (the constant offsets hold the visuals apart harder than
|
||||
// the anchors close), the pair prices at 0 — visually never mergeable, pruned by finalize's
|
||||
// minZoom cut. No D_FLOOR fast path here: coincident anchors with differing offsets are a
|
||||
// constant |Δō| apart on screen, which is exactly the ΔC = 0 case below.
|
||||
const dc = clusters.centroidDelta(aRoot, bRoot)
|
||||
if (dc.x === 0 && dc.y === 0) {
|
||||
return Math.hypot(off.x, off.y) < opts.Tc ? Number.POSITIVE_INFINITY : 0
|
||||
}
|
||||
const gap = largestVisualCrossing(dc.x, dc.y, off.x, off.y, opts.Tc)
|
||||
if (gap === null) return 0
|
||||
const fit = opts.Dmax / clusters.unionBboxDiag(aRoot, bRoot)
|
||||
return Math.min(gap, fit)
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest positive root of `|z·ΔC + Δō| = level`, i.e. of
|
||||
* `|ΔC|²·z² + 2(ΔC·Δō)·z + (|Δō|² − level²) = 0` — the zoom at which two offset markers are
|
||||
* exactly `level` screen px apart, with the distance below the level for every smaller zoom
|
||||
* (matching the "merged at z ≤ threshold" model; a lower second crossing, where opposed offsets
|
||||
* push the visuals back above the level near z = 0, is deliberately collapsed). Null when the
|
||||
* visual distance never reaches the level. Callers handle ΔC = 0 (constant distance) themselves.
|
||||
*/
|
||||
function largestVisualCrossing(
|
||||
dcx: number,
|
||||
dcy: number,
|
||||
dox: number,
|
||||
doy: number,
|
||||
level: number
|
||||
): number | null {
|
||||
const a = dcx * dcx + dcy * dcy
|
||||
const b = 2 * (dcx * dox + dcy * doy)
|
||||
const c = dox * dox + doy * doy - level * level
|
||||
const disc = b * b - 4 * a * c
|
||||
if (disc < 0) return null
|
||||
const z = (-b + Math.sqrt(disc)) / (2 * a)
|
||||
return z > 0 ? z : null
|
||||
}
|
||||
|
||||
class ClusterState {
|
||||
private readonly parent: Int32Array
|
||||
private readonly minX: Float64Array
|
||||
private readonly minY: Float64Array
|
||||
private readonly maxX: Float64Array
|
||||
private readonly maxY: Float64Array
|
||||
private readonly centroidX: Float64Array
|
||||
private readonly centroidY: Float64Array
|
||||
private readonly counts: Int32Array
|
||||
private readonly nodes: ClusterNode[]
|
||||
// Per-cluster sums of member render offsets (screen px), maintained like the centroid sums.
|
||||
// Null when no offsets were passed — offsetDelta() then answers null unconditionally, which
|
||||
// routes every pricing call down the offset-unaware path.
|
||||
private readonly offsetX: Float64Array | null
|
||||
private readonly offsetY: Float64Array | null
|
||||
|
||||
constructor(leaves: readonly LeafInput[], screenOffsets?: LeafScreenOffsets) {
|
||||
const n = leaves.length
|
||||
this.parent = new Int32Array(n)
|
||||
this.minX = new Float64Array(n)
|
||||
this.minY = new Float64Array(n)
|
||||
this.maxX = new Float64Array(n)
|
||||
this.maxY = new Float64Array(n)
|
||||
this.centroidX = new Float64Array(n)
|
||||
this.centroidY = new Float64Array(n)
|
||||
this.counts = new Int32Array(n)
|
||||
this.nodes = new Array(n)
|
||||
|
||||
if (screenOffsets !== undefined && screenOffsets.size > 0) {
|
||||
this.offsetX = new Float64Array(n)
|
||||
this.offsetY = new Float64Array(n)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const offset = screenOffsets.get(leaves[i].id)
|
||||
if (offset) {
|
||||
this.offsetX[i] = offset.x
|
||||
this.offsetY[i] = offset.y
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.offsetX = null
|
||||
this.offsetY = null
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const leaf = leaves[i]
|
||||
this.parent[i] = i
|
||||
this.minX[i] = leaf.point.x
|
||||
this.minY[i] = leaf.point.y
|
||||
this.maxX[i] = leaf.point.x
|
||||
this.maxY[i] = leaf.point.y
|
||||
this.centroidX[i] = leaf.point.x
|
||||
this.centroidY[i] = leaf.point.y
|
||||
this.counts[i] = 1
|
||||
this.nodes[i] = {
|
||||
id: leaf.id,
|
||||
centroid: { x: leaf.point.x, y: leaf.point.y },
|
||||
count: 1,
|
||||
members: [leaf.id],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
find(index: number): number {
|
||||
let root = index
|
||||
while (this.parent[root] !== root) {
|
||||
root = this.parent[root]
|
||||
}
|
||||
while (this.parent[index] !== index) {
|
||||
const next = this.parent[index]
|
||||
this.parent[index] = root
|
||||
index = next
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Allocation-free: this is the hot path when no offsets are passed. */
|
||||
centroidDistance(aRoot: number, bRoot: number): number {
|
||||
return Math.hypot(
|
||||
this.centroidX[aRoot] - this.centroidX[bRoot],
|
||||
this.centroidY[aRoot] - this.centroidY[bRoot]
|
||||
)
|
||||
}
|
||||
|
||||
centroidDelta(aRoot: number, bRoot: number): { x: number; y: number } {
|
||||
return {
|
||||
x: this.centroidX[aRoot] - this.centroidX[bRoot],
|
||||
y: this.centroidY[aRoot] - this.centroidY[bRoot],
|
||||
}
|
||||
}
|
||||
|
||||
/** The difference of the two clusters' mean render offsets (screen px), or null when it's
|
||||
* zero — including always when no offsets were passed. Null routes pricing down the
|
||||
* offset-unaware path. */
|
||||
offsetDelta(aRoot: number, bRoot: number): { x: number; y: number } | null {
|
||||
if (this.offsetX === null || this.offsetY === null) return null
|
||||
const x = this.offsetX[aRoot] / this.counts[aRoot] - this.offsetX[bRoot] / this.counts[bRoot]
|
||||
const y = this.offsetY[aRoot] / this.counts[aRoot] - this.offsetY[bRoot] / this.counts[bRoot]
|
||||
if (x === 0 && y === 0) return null
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
unionBboxDiag(aRoot: number, bRoot: number): number {
|
||||
const minX = Math.min(this.minX[aRoot], this.minX[bRoot])
|
||||
const minY = Math.min(this.minY[aRoot], this.minY[bRoot])
|
||||
const maxX = Math.max(this.maxX[aRoot], this.maxX[bRoot])
|
||||
const maxY = Math.max(this.maxY[aRoot], this.maxY[bRoot])
|
||||
return Math.hypot(maxX - minX, maxY - minY)
|
||||
}
|
||||
|
||||
merge(aRoot: number, bRoot: number, z: number): RawMergeEvent {
|
||||
const leftRoot = this.nodes[aRoot].members[0] < this.nodes[bRoot].members[0] ? aRoot : bRoot
|
||||
const rightRoot = leftRoot === aRoot ? bRoot : aRoot
|
||||
const left = this.nodes[leftRoot]
|
||||
const right = this.nodes[rightRoot]
|
||||
const count = this.counts[leftRoot] + this.counts[rightRoot]
|
||||
const members = mergeSortedMembers(left.members, right.members)
|
||||
|
||||
const minX = Math.min(this.minX[leftRoot], this.minX[rightRoot])
|
||||
const minY = Math.min(this.minY[leftRoot], this.minY[rightRoot])
|
||||
const maxX = Math.max(this.maxX[leftRoot], this.maxX[rightRoot])
|
||||
const maxY = Math.max(this.maxY[leftRoot], this.maxY[rightRoot])
|
||||
const centroidX =
|
||||
(this.counts[leftRoot] * this.centroidX[leftRoot] +
|
||||
this.counts[rightRoot] * this.centroidX[rightRoot]) /
|
||||
count
|
||||
const centroidY =
|
||||
(this.counts[leftRoot] * this.centroidY[leftRoot] +
|
||||
this.counts[rightRoot] * this.centroidY[rightRoot]) /
|
||||
count
|
||||
|
||||
// Leaf ids are assumed not to start with `cluster:`; see the step 2 contract.
|
||||
const result: ClusterNode = {
|
||||
id: `cluster:${count}:${members[0]}`,
|
||||
centroid: { x: centroidX, y: centroidY },
|
||||
count,
|
||||
members,
|
||||
}
|
||||
|
||||
this.parent[rightRoot] = leftRoot
|
||||
this.minX[leftRoot] = minX
|
||||
this.minY[leftRoot] = minY
|
||||
this.maxX[leftRoot] = maxX
|
||||
this.maxY[leftRoot] = maxY
|
||||
this.centroidX[leftRoot] = centroidX
|
||||
this.centroidY[leftRoot] = centroidY
|
||||
this.counts[leftRoot] = count
|
||||
this.nodes[leftRoot] = result
|
||||
if (this.offsetX !== null && this.offsetY !== null) {
|
||||
this.offsetX[leftRoot] += this.offsetX[rightRoot]
|
||||
this.offsetY[leftRoot] += this.offsetY[rightRoot]
|
||||
}
|
||||
|
||||
return { z, children: [left, right], result }
|
||||
}
|
||||
}
|
||||
|
||||
function mergeSortedMembers(a: readonly string[], b: readonly string[]): string[] {
|
||||
const out = new Array<string>(a.length + b.length)
|
||||
let i = 0
|
||||
let j = 0
|
||||
let k = 0
|
||||
while (i < a.length && j < b.length) {
|
||||
if (a[i] < b[j]) {
|
||||
out[k++] = a[i++]
|
||||
} else {
|
||||
out[k++] = b[j++]
|
||||
}
|
||||
}
|
||||
while (i < a.length) out[k++] = a[i++]
|
||||
while (j < b.length) out[k++] = b[j++]
|
||||
return out
|
||||
}
|
||||
|
||||
interface HeapEntry {
|
||||
edgeIndex: number
|
||||
z: number
|
||||
}
|
||||
|
||||
class EdgeMaxHeap {
|
||||
private readonly items: HeapEntry[] = []
|
||||
// Per-edge (lo, hi) id pair for the z tie-break, precomputed once so comparisons allocate
|
||||
// nothing. Relies on MstEdge's invariant that leaves[a].id < leaves[b].id; the heap does not
|
||||
// re-normalize. With coincident anchors every edge prices to the same z (+Infinity), so the
|
||||
// tie-break runs on nearly every comparison of a rebuild: allocating the pair there churned
|
||||
// millions of short-lived tuples.
|
||||
private readonly loIds: string[]
|
||||
private readonly hiIds: string[]
|
||||
|
||||
constructor(edges: readonly MstEdge[], leaves: readonly LeafInput[]) {
|
||||
const n = edges.length
|
||||
this.loIds = new Array(n)
|
||||
this.hiIds = new Array(n)
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.loIds[i] = leaves[edges[i].a].id
|
||||
this.hiIds[i] = leaves[edges[i].b].id
|
||||
}
|
||||
}
|
||||
|
||||
peek(): HeapEntry | undefined {
|
||||
return this.items[0]
|
||||
}
|
||||
|
||||
push(entry: HeapEntry) {
|
||||
this.items.push(entry)
|
||||
this.siftUp(this.items.length - 1)
|
||||
}
|
||||
|
||||
pop(): HeapEntry | undefined {
|
||||
if (this.items.length === 0) return undefined
|
||||
const first = this.items[0]
|
||||
const last = this.items.pop()!
|
||||
if (this.items.length > 0) {
|
||||
this.items[0] = last
|
||||
this.siftDown(0)
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
higherPriority(a: HeapEntry, b: HeapEntry): boolean {
|
||||
if (a.z > b.z) return true
|
||||
if (a.z < b.z) return false
|
||||
// Tie-break on the normalized leaf-id pair, ascending.
|
||||
const aLo = this.loIds[a.edgeIndex]
|
||||
const bLo = this.loIds[b.edgeIndex]
|
||||
if (aLo !== bLo) return aLo < bLo
|
||||
return this.hiIds[a.edgeIndex] < this.hiIds[b.edgeIndex]
|
||||
}
|
||||
|
||||
private siftUp(index: number) {
|
||||
while (index > 0) {
|
||||
const parent = (index - 1) >> 1
|
||||
if (!this.higherPriority(this.items[index], this.items[parent])) break
|
||||
;[this.items[index], this.items[parent]] = [this.items[parent], this.items[index]]
|
||||
index = parent
|
||||
}
|
||||
}
|
||||
|
||||
private siftDown(index: number) {
|
||||
while (true) {
|
||||
const left = index * 2 + 1
|
||||
const right = left + 1
|
||||
let best = index
|
||||
if (left < this.items.length && this.higherPriority(this.items[left], this.items[best])) {
|
||||
best = left
|
||||
}
|
||||
if (right < this.items.length && this.higherPriority(this.items[right], this.items[best])) {
|
||||
best = right
|
||||
}
|
||||
if (best === index) break
|
||||
;[this.items[index], this.items[best]] = [this.items[best], this.items[index]]
|
||||
index = best
|
||||
}
|
||||
}
|
||||
}
|
||||
850
packages/commenting/src/clustering/runtime.test.ts
Normal file
850
packages/commenting/src/clustering/runtime.test.ts
Normal file
@@ -0,0 +1,850 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mstEdges } from './mst'
|
||||
import { cappedReplay } from './replay'
|
||||
import { createClusterRuntime } from './runtime'
|
||||
import { contract, finalize } from './schedule'
|
||||
import { ClusterNode, ClusterTable, LeafInput, MergeEvent } from './types'
|
||||
|
||||
function node(ids: string[], x = 0, y = 0): ClusterNode {
|
||||
const members = ids.slice().sort()
|
||||
return {
|
||||
id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
|
||||
centroid: { x, y },
|
||||
count: members.length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
function mev(
|
||||
zMerge: number,
|
||||
zSplit: number,
|
||||
children: ClusterNode[],
|
||||
result: ClusterNode
|
||||
): MergeEvent {
|
||||
return { zMerge, zSplit, children, result }
|
||||
}
|
||||
|
||||
function visibleIds(runtime: { getVisible(): ReadonlyMap<string, ClusterNode> }): string[] {
|
||||
return [...runtime.getVisible().keys()].sort()
|
||||
}
|
||||
|
||||
/** Reference derivation: start from all leaves, apply events[0..k) in order. */
|
||||
function referenceVisible(table: ClusterTable, k: number): Map<string, ClusterNode> {
|
||||
const map = new Map(table.leaves.map((l) => [l.id, l]))
|
||||
for (let i = 0; i < k; i++) {
|
||||
const ev = table.events[i]
|
||||
for (const child of ev.children) map.delete(child.id)
|
||||
map.set(ev.result.id, ev.result)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Contract clause 3 postconditions, asserted after every seed/onCamera. */
|
||||
function expectPostconditions(
|
||||
runtime: { readonly k: number; getVisible(): ReadonlyMap<string, ClusterNode> },
|
||||
table: ClusterTable,
|
||||
zoom: number
|
||||
) {
|
||||
const k = runtime.k
|
||||
expect(k).toBeGreaterThanOrEqual(0)
|
||||
expect(k).toBeLessThanOrEqual(table.events.length)
|
||||
if (k < table.events.length) expect(zoom).toBeGreaterThan(table.events[k].zMerge)
|
||||
if (k > 0) expect(zoom).toBeLessThan(table.events[k - 1].zSplit)
|
||||
const expected = referenceVisible(table, k)
|
||||
expect(visibleIds(runtime)).toEqual([...expected.keys()].sort())
|
||||
for (const [id, nodeRef] of expected) {
|
||||
// deep equality: a restored leaf may be the event's child object rather
|
||||
// than the leaves-array twin — both are table objects (no-cloning is
|
||||
// asserted by identity in the fixture tests, where objects are shared)
|
||||
expect(runtime.getVisible().get(id)).toEqual(nodeRef)
|
||||
}
|
||||
}
|
||||
|
||||
// The micro-trace table of CLUSTERING.md §8.5:
|
||||
// E0 { zMerge 4.0, zSplit 6.0, A+B+C → P }
|
||||
// E1 { zMerge 1.0, zSplit 1.5, P+D → Q }
|
||||
const A = node(['a'], 0, 0)
|
||||
const B = node(['b'], 10, 0)
|
||||
const C = node(['c'], 20, 0)
|
||||
const D = node(['d'], 100, 0)
|
||||
const P = node(['a', 'b', 'c'], 10, 0)
|
||||
const Q = node(['a', 'b', 'c', 'd'], 32.5, 0)
|
||||
|
||||
function microTraceTable(): ClusterTable {
|
||||
return {
|
||||
events: [mev(4, 6, [A, B, C], P), mev(1, 1.5, [P, D], Q)],
|
||||
leaves: [A, B, C, D],
|
||||
}
|
||||
}
|
||||
|
||||
describe('createClusterRuntime micro-trace (CLUSTERING.md §8.5)', () => {
|
||||
it('walks the exact documented trace', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
|
||||
// mount at zoom 5.0 → seed: √(4·6) ≈ 4.9 < 5.0 → k = 0
|
||||
rt.seed(5.0)
|
||||
expect(rt.k).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
expectPostconditions(rt, table, 5.0)
|
||||
|
||||
// zoom out to 3.5 → fire E0
|
||||
rt.onCamera(3.5)
|
||||
expect(rt.k).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
|
||||
expectPostconditions(rt, table, 3.5)
|
||||
|
||||
// zoom back in to 5.0 → inside E0's band (4 < 5 < 6): hold — no flicker
|
||||
rt.onCamera(5.0)
|
||||
expect(rt.k).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
|
||||
expectPostconditions(rt, table, 5.0)
|
||||
|
||||
// zoom in to 6.2 → split E0
|
||||
rt.onCamera(6.2)
|
||||
expect(rt.k).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
expectPostconditions(rt, table, 6.2)
|
||||
|
||||
// zoom-to-fit at 0.8 → fires E0 AND E1 in one call
|
||||
rt.onCamera(0.8)
|
||||
expect(rt.k).toBe(2)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:4:a'])
|
||||
expectPostconditions(rt, table, 0.8)
|
||||
|
||||
// pan (same zoom) → no-op
|
||||
rt.onCamera(0.8)
|
||||
expect(rt.k).toBe(2)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:4:a'])
|
||||
})
|
||||
|
||||
it('splits multiple events in one zoom-in jump', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(0.8) // √(4·6) ≈ 4.9 ≥ 0.8 and √(1·1.5) ≈ 1.22 ≥ 0.8 → k = 2
|
||||
expect(rt.k).toBe(2)
|
||||
rt.onCamera(7) // ≥ both zSplits (1.5 and 6) → unwinds to k = 0
|
||||
expect(rt.k).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
expectPostconditions(rt, table, 7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createClusterRuntime seeding', () => {
|
||||
it('seeds each side of the geometric band midpoint', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
const mid0 = Math.sqrt(4 * 6) // ≈ 4.898979
|
||||
const mid1 = Math.sqrt(1 * 1.5) // ≈ 1.224745
|
||||
|
||||
rt.seed(mid0 + 0.001) // just above E0's midpoint → unmerged
|
||||
expect(rt.k).toBe(0)
|
||||
|
||||
rt.seed(mid0) // exactly at the midpoint: zoom <= mid counts as merged
|
||||
expect(rt.k).toBe(1)
|
||||
|
||||
rt.seed(mid1 + 0.001) // between the two midpoints → only E0 active
|
||||
expect(rt.k).toBe(1)
|
||||
|
||||
rt.seed(mid1) // at/below E1's midpoint → both active
|
||||
expect(rt.k).toBe(2)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:4:a'])
|
||||
})
|
||||
|
||||
it('re-seeding resets state from scratch', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(0.8)
|
||||
expect(rt.k).toBe(2)
|
||||
rt.seed(5.0) // rebuild-style reset: history is discarded
|
||||
expect(rt.k).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('throws on non-positive or non-finite seed zoom', () => {
|
||||
const rt = createClusterRuntime(microTraceTable())
|
||||
expect(() => rt.seed(0)).toThrow()
|
||||
expect(() => rt.seed(-1)).toThrow()
|
||||
expect(() => rt.seed(NaN)).toThrow()
|
||||
expect(() => rt.seed(Infinity)).toThrow()
|
||||
})
|
||||
|
||||
it('throws if onCamera is called before any seed', () => {
|
||||
const rt = createClusterRuntime(microTraceTable())
|
||||
expect(() => rt.onCamera(3)).toThrow()
|
||||
})
|
||||
|
||||
it('seed followed by onCamera at the same zoom is a no-op', () => {
|
||||
const table = microTraceTable()
|
||||
for (const zoom of [0.5, 1.1, 2, 4.5, 4.9, 5.5, 8]) {
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(zoom)
|
||||
const k = rt.k
|
||||
const ids = visibleIds(rt)
|
||||
rt.onCamera(zoom)
|
||||
expect(rt.k).toBe(k)
|
||||
expect(visibleIds(rt)).toEqual(ids)
|
||||
expectPostconditions(rt, table, zoom)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('createClusterRuntime edge cases', () => {
|
||||
it('handles an empty table: all leaves visible at any zoom', () => {
|
||||
const table: ClusterTable = { events: [], leaves: [A, B] }
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(3)
|
||||
expect(rt.k).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b'])
|
||||
rt.onCamera(0.01)
|
||||
rt.onCamera(100)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps Infinity events permanently merged at any reachable zoom', () => {
|
||||
const R = node(['a', 'b'])
|
||||
const table: ClusterTable = { events: [mev(Infinity, Infinity, [A, B], R)], leaves: [A, B] }
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(8) // √(∞·∞) = ∞ ≥ any zoom → seeded merged
|
||||
expect(rt.k).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:2:a'])
|
||||
rt.onCamera(1e9) // no finite zoom reaches zSplit = ∞
|
||||
expect(rt.k).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:2:a'])
|
||||
})
|
||||
|
||||
it('exposes the table node objects by reference, without cloning', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(5)
|
||||
expect(rt.getVisible().get('a')).toBe(A)
|
||||
rt.onCamera(3.5)
|
||||
expect(rt.getVisible().get('cluster:3:a')).toBe(P)
|
||||
expect(rt.getVisible().get('d')).toBe(D)
|
||||
})
|
||||
|
||||
it('never mutates the table', () => {
|
||||
const table = microTraceTable()
|
||||
const snapshot = JSON.parse(JSON.stringify(table))
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(5)
|
||||
rt.onCamera(0.8)
|
||||
rt.onCamera(7)
|
||||
rt.seed(2)
|
||||
expect(JSON.parse(JSON.stringify(table))).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const PIPE_OPTS = {
|
||||
Tc: 40,
|
||||
Tu: 60,
|
||||
eps: 0.12,
|
||||
Dmax: 120,
|
||||
minZoom: 0.05,
|
||||
maxZoom: 8,
|
||||
maxSplitZoom: 1e9,
|
||||
}
|
||||
|
||||
function buildTable(leaves: LeafInput[]): ClusterTable {
|
||||
const rawEvents = cappedReplay(leaves, mstEdges(leaves), {
|
||||
Tc: PIPE_OPTS.Tc,
|
||||
Dmax: PIPE_OPTS.Dmax,
|
||||
})
|
||||
const events = finalize(contract(rawEvents, PIPE_OPTS.eps), {
|
||||
Tc: PIPE_OPTS.Tc,
|
||||
Tu: PIPE_OPTS.Tu,
|
||||
minZoom: PIPE_OPTS.minZoom,
|
||||
maxZoom: PIPE_OPTS.maxZoom,
|
||||
maxSplitZoom: PIPE_OPTS.maxSplitZoom,
|
||||
})
|
||||
const leafNodes: ClusterNode[] = leaves.map((l) => ({
|
||||
id: l.id,
|
||||
centroid: { x: l.point.x, y: l.point.y },
|
||||
count: 1,
|
||||
members: [l.id],
|
||||
}))
|
||||
return { events, leaves: leafNodes }
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
/** A zoom walk within camera bounds: mostly small multiplicative steps, some jumps. */
|
||||
function zoomWalk(steps: number, seed: number): number[] {
|
||||
const rand = mulberry32(seed)
|
||||
const lnMin = Math.log(PIPE_OPTS.minZoom)
|
||||
const lnMax = Math.log(PIPE_OPTS.maxZoom)
|
||||
let ln = (lnMin + lnMax) / 2
|
||||
const walk: number[] = []
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const r = rand()
|
||||
if (r < 0.15) {
|
||||
// jump anywhere (zoom-to-fit, keyboard shortcuts)
|
||||
ln = lnMin + rand() * (lnMax - lnMin)
|
||||
} else if (r < 0.25) {
|
||||
// hold (pan-only frames)
|
||||
} else {
|
||||
// scrub: small multiplicative step, clamped to bounds
|
||||
ln = Math.min(lnMax, Math.max(lnMin, ln + (rand() - 0.5) * 0.4))
|
||||
}
|
||||
walk.push(Math.exp(ln))
|
||||
}
|
||||
return walk
|
||||
}
|
||||
|
||||
/**
|
||||
* Independent per-event hysteresis simulator: each event keeps its own bit with
|
||||
* the two-threshold update rule. The prefix property says the bit vector is
|
||||
* always exactly the prefix mask [0..k) — this cross-checks the cursor against
|
||||
* the definition it compresses.
|
||||
*/
|
||||
class BitSimulator {
|
||||
private bits: boolean[]
|
||||
constructor(
|
||||
private events: readonly MergeEvent[],
|
||||
seedZoom: number
|
||||
) {
|
||||
this.bits = events.map((e) => seedZoom <= Math.sqrt(e.zMerge * e.zSplit))
|
||||
}
|
||||
update(zoom: number) {
|
||||
this.bits = this.events.map((e, i) => {
|
||||
if (zoom <= e.zMerge) return true
|
||||
if (zoom >= e.zSplit) return false
|
||||
return this.bits[i]
|
||||
})
|
||||
}
|
||||
expectPrefixOfLength(k: number) {
|
||||
for (let i = 0; i < this.bits.length; i++) {
|
||||
expect(this.bits[i]).toBe(i < k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('createClusterRuntime properties (random tables, random zoom walks)', () => {
|
||||
it('holds all postconditions and matches the per-event bit simulator', () => {
|
||||
for (const [n, seed] of [
|
||||
[2, 1],
|
||||
[15, 42],
|
||||
[60, 7],
|
||||
] as const) {
|
||||
const leaves = randomLeaves(n, seed * 13 + n)
|
||||
const table = buildTable(leaves)
|
||||
const rt = createClusterRuntime(table)
|
||||
const walk = zoomWalk(300, seed + 100)
|
||||
|
||||
const seedZoom = walk[0]
|
||||
rt.seed(seedZoom)
|
||||
const sim = new BitSimulator(table.events, seedZoom)
|
||||
expectPostconditions(rt, table, seedZoom)
|
||||
sim.expectPrefixOfLength(rt.k)
|
||||
|
||||
for (const zoom of walk.slice(1)) {
|
||||
rt.onCamera(zoom)
|
||||
sim.update(zoom)
|
||||
expectPostconditions(rt, table, zoom)
|
||||
sim.expectPrefixOfLength(rt.k)
|
||||
// conservation: visible clusters always partition all n leaves
|
||||
let total = 0
|
||||
for (const cluster of rt.getVisible().values()) total += cluster.count
|
||||
expect(total).toBe(n)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('is monotone: k never decreases while zooming out, never increases zooming in', () => {
|
||||
const leaves = randomLeaves(40, 33)
|
||||
const table = buildTable(leaves)
|
||||
const rt = createClusterRuntime(table)
|
||||
|
||||
rt.seed(PIPE_OPTS.maxZoom)
|
||||
let prevK = rt.k
|
||||
for (let zoom = PIPE_OPTS.maxZoom; zoom >= PIPE_OPTS.minZoom; zoom *= 0.97) {
|
||||
rt.onCamera(zoom)
|
||||
expect(rt.k).toBeGreaterThanOrEqual(prevK)
|
||||
prevK = rt.k
|
||||
}
|
||||
expect(rt.k).toBe(table.events.length) // fully zoomed out → everything merged
|
||||
|
||||
for (let zoom = PIPE_OPTS.minZoom; zoom <= PIPE_OPTS.maxZoom; zoom *= 1.03) {
|
||||
rt.onCamera(zoom)
|
||||
expect(rt.k).toBeLessThanOrEqual(prevK)
|
||||
prevK = rt.k
|
||||
}
|
||||
})
|
||||
|
||||
it('produces zero transitions while scrubbing inside a hysteresis band', () => {
|
||||
const leaves = randomLeaves(30, 55)
|
||||
const table = buildTable(leaves)
|
||||
// pick a finite event with a usable band inside camera bounds
|
||||
const ev = table.events.find(
|
||||
(e) =>
|
||||
Number.isFinite(e.zMerge) &&
|
||||
e.zMerge > PIPE_OPTS.minZoom * 2 &&
|
||||
e.zSplit < PIPE_OPTS.maxZoom &&
|
||||
e.zSplit / e.zMerge > 1.01
|
||||
)
|
||||
expect(ev).toBeDefined()
|
||||
const lo = ev!.zMerge * 1.001
|
||||
const hi = ev!.zSplit * 0.999
|
||||
|
||||
const rt = createClusterRuntime(table)
|
||||
// approach from below: merged state, then scrub within (zMerge, zSplit)
|
||||
rt.seed(ev!.zMerge * 0.9)
|
||||
rt.onCamera(lo)
|
||||
const k = rt.k
|
||||
const ids = visibleIds(rt)
|
||||
const rand = mulberry32(9)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
rt.onCamera(lo + rand() * (hi - lo))
|
||||
expect(rt.k).toBe(k)
|
||||
expect(visibleIds(rt)).toEqual(ids)
|
||||
}
|
||||
})
|
||||
|
||||
it('agrees with a fresh seed after any walk that ends outside all bands', () => {
|
||||
// Outside every band, state is history-independent: a runtime that walked
|
||||
// there must equal a runtime seeded there directly.
|
||||
const leaves = randomLeaves(30, 77)
|
||||
const table = buildTable(leaves)
|
||||
const walked = createClusterRuntime(table)
|
||||
walked.seed(2)
|
||||
for (const zoom of zoomWalk(120, 5)) walked.onCamera(zoom)
|
||||
|
||||
// find a zoom outside all bands near the end state
|
||||
const finalZoom = PIPE_OPTS.maxZoom
|
||||
walked.onCamera(finalZoom)
|
||||
const inBand = table.events.some((e) => finalZoom > e.zMerge && finalZoom < e.zSplit)
|
||||
if (!inBand) {
|
||||
const seeded = createClusterRuntime(table)
|
||||
seeded.seed(finalZoom)
|
||||
expect(seeded.k).toBe(walked.k)
|
||||
expect(visibleIds(seeded)).toEqual(visibleIds(walked))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('createClusterRuntime seedFrom (carryover seeding)', () => {
|
||||
it('band event merged in the previous partition stays merged', () => {
|
||||
// zoom 5 is inside E0's band and above the geometric midpoint (≈4.899):
|
||||
// a fresh seed would leave A/B/C split. History says merged — carryover keeps it.
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
5,
|
||||
new Map([
|
||||
[P.id, P],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(1)
|
||||
expect(rt.getSuppressedCount()).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
|
||||
})
|
||||
|
||||
it('band event unmerged in the previous partition stays unmerged (no snap-together)', () => {
|
||||
// zoom 4.5 is inside E0's band and below the geometric midpoint: a fresh seed
|
||||
// would merge A/B/C even though nothing changed for them. History says split.
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
4.5,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[C.id, C],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(1)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('thresholds override history in both directions', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
// zoom <= zMerge: mandatory merge, even though the previous partition was split
|
||||
rt.seedFrom(
|
||||
3,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[C.id, C],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(1)
|
||||
// zoom >= zSplit: mandatory split, even though the previous partition was merged
|
||||
rt.seedFrom(
|
||||
7,
|
||||
new Map([
|
||||
[P.id, P],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(0)
|
||||
})
|
||||
|
||||
it('a band event with no counterpart in the previous partition stays inactive', () => {
|
||||
// Empty history (or partial membership) → the rebuild introduced this merge; it
|
||||
// defers to the next zoom-out rather than firing under a static camera.
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(5, new Map())
|
||||
expect(rt.k).toBe(1)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
const partial = node(['a', 'b'], 5, 0)
|
||||
rt.seedFrom(
|
||||
5,
|
||||
new Map([
|
||||
[partial.id, partial],
|
||||
[C.id, C],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(1)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('membership in a superset cluster counts as merged', () => {
|
||||
// The previous partition holds all four in Q; E1 (band at zoom 1.2) inherits merged.
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(1.2, new Map([[Q.id, Q]]))
|
||||
expect(rt.k).toBe(2)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:4:a'])
|
||||
})
|
||||
|
||||
it('carries over mixed band states exactly (split before merged in table order)', () => {
|
||||
// Two independent band events at zoom 4.5: the earlier-sorted one split in history,
|
||||
// the later one merged. The old prefix cut forced the second to split (the mass-split
|
||||
// bug); with suppression the carryover is exact — each keeps its own state.
|
||||
const AB = node(['a', 'b'], 5, 0)
|
||||
const CD = node(['c', 'd'], 105, 0)
|
||||
const table: ClusterTable = {
|
||||
events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
|
||||
leaves: [A, B, C, D],
|
||||
}
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
4.5,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[CD.id, CD],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(2)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'cluster:2:c'])
|
||||
})
|
||||
|
||||
it('a suppressed event merges (heals) when a zoom-out crosses its own zMerge', () => {
|
||||
const AB = node(['a', 'b'], 5, 0)
|
||||
const CD = node(['c', 'd'], 105, 0)
|
||||
const table: ClusterTable = {
|
||||
events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
|
||||
leaves: [A, B, C, D],
|
||||
}
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
4.5,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[CD.id, CD],
|
||||
])
|
||||
)
|
||||
const versionBefore = rt.version
|
||||
rt.onCamera(4.4) // still inside AB's band: nothing happens
|
||||
expect(rt.version).toBe(versionBefore)
|
||||
rt.onCamera(3.9) // below AB's zMerge (4): the held-out merge fires at its own threshold
|
||||
expect(rt.version).toBeGreaterThan(versionBefore)
|
||||
expect(rt.k).toBe(2)
|
||||
expect(rt.getSuppressedCount()).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:2:a', 'cluster:2:c'])
|
||||
expectPostconditions(rt, table, 3.9)
|
||||
})
|
||||
|
||||
it('the split walk retreats past a suppressed event without corrupting the partition', () => {
|
||||
const AB = node(['a', 'b'], 5, 0)
|
||||
const CD = node(['c', 'd'], 105, 0)
|
||||
const table: ClusterTable = {
|
||||
events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
|
||||
leaves: [A, B, C, D],
|
||||
}
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
4.5,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[CD.id, CD],
|
||||
])
|
||||
)
|
||||
rt.onCamera(5.5) // past CD's zSplit (5): CD splits; suppressed AB stays put
|
||||
expect(rt.k).toBe(1)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
rt.onCamera(6.5) // past AB's zSplit (6): retreat past the suppressed (never-applied) event
|
||||
expect(rt.k).toBe(0)
|
||||
expect(rt.getSuppressedCount()).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
rt.onCamera(3.9) // zoom back out: normal cursor walk from a clean state
|
||||
expect(rt.k).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['c', 'cluster:2:a', 'd'])
|
||||
expectPostconditions(rt, table, 3.9)
|
||||
})
|
||||
|
||||
it('heals a suppressed event whose result a later event consumes, in one zoom-out jump', () => {
|
||||
// E1 consumes E0's result, so a single jump past both thresholds must apply E0 first.
|
||||
// Applying E1 first would no-op its delete of the never-applied ABC and then re-add ABC
|
||||
// on top of ABCD, covering a, b and c twice.
|
||||
const ABC = node(['a', 'b', 'c'], 10, 0)
|
||||
const ABCD = node(['a', 'b', 'c', 'd'], 32.5, 0)
|
||||
const table: ClusterTable = {
|
||||
events: [mev(4, 6, [A, B, C], ABC), mev(1, 1.5, [ABC, D], ABCD)],
|
||||
leaves: [A, B, C, D],
|
||||
}
|
||||
const rt = createClusterRuntime(table)
|
||||
// previous partition: everything split apart, so E0 seeds suppressed inside its band
|
||||
rt.seedFrom(
|
||||
5,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[C.id, C],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
|
||||
rt.onCamera(0.9) // one jump past E0's zMerge (4) and E1's zMerge (1)
|
||||
|
||||
expect(rt.getSuppressedCount()).toBe(0)
|
||||
expect(rt.k).toBe(2)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:4:a'])
|
||||
// conservation: every leaf is covered exactly once
|
||||
const members = [...rt.getVisible().values()].flatMap((n) => n.members)
|
||||
expect(members.slice().sort()).toEqual(['a', 'b', 'c', 'd'])
|
||||
expectPostconditions(rt, table, 0.9)
|
||||
})
|
||||
|
||||
it('seedFrom followed by onCamera at the same zoom is a no-op', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
5,
|
||||
new Map([
|
||||
[P.id, P],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
const k = rt.k
|
||||
const ids = visibleIds(rt)
|
||||
rt.onCamera(5)
|
||||
expect(rt.k).toBe(k)
|
||||
expect(visibleIds(rt)).toEqual(ids)
|
||||
expectPostconditions(rt, table, 5)
|
||||
})
|
||||
|
||||
it('matches a fresh seed when the previous partition mirrors the geometric tiebreak', () => {
|
||||
// Sanity: outside bands and with agreeing history, seedFrom degenerates to seed.
|
||||
const table = microTraceTable()
|
||||
for (const zoom of [0.5, 2, 8]) {
|
||||
const fresh = createClusterRuntime(table)
|
||||
fresh.seed(zoom)
|
||||
const carried = createClusterRuntime(table)
|
||||
carried.seedFrom(zoom, fresh.getVisible())
|
||||
expect(carried.k).toBe(fresh.k)
|
||||
expect(visibleIds(carried)).toEqual(visibleIds(fresh))
|
||||
}
|
||||
})
|
||||
|
||||
it('throws on non-positive or non-finite zoom', () => {
|
||||
const rt = createClusterRuntime(microTraceTable())
|
||||
expect(() => rt.seedFrom(0, new Map())).toThrow()
|
||||
expect(() => rt.seedFrom(NaN, new Map())).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createClusterRuntime detachLeaf (local partition edits)', () => {
|
||||
it('shrinks the containing badge in place: members, count, centroid', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4) // P = a+b+c visible, d separate
|
||||
const versionBefore = rt.version
|
||||
rt.detachLeaf('a')
|
||||
expect(rt.version).toBeGreaterThan(versionBefore)
|
||||
expect(rt.getDetachedCount()).toBe(1)
|
||||
const patchedP = rt.getVisible().get(P.id)!
|
||||
expect(patchedP.members).toEqual(['b', 'c'])
|
||||
expect(patchedP.count).toBe(2)
|
||||
// centroid recomputed from the remaining leaves: B (10,0), C (20,0)
|
||||
expect(patchedP.centroid).toEqual({ x: 15, y: 0 })
|
||||
// keeps the structural id so cursor events keep addressing it
|
||||
expect(patchedP.id).toBe(P.id)
|
||||
// everything else untouched
|
||||
expect(rt.getVisible().get('d')).toEqual(D)
|
||||
expect(rt.getVisible().size).toBe(2)
|
||||
})
|
||||
|
||||
it('is idempotent and ignores unknown ids', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
const version = rt.version
|
||||
rt.detachLeaf('a')
|
||||
rt.detachLeaf('nonexistent')
|
||||
expect(rt.version).toBe(version)
|
||||
expect(rt.getDetachedCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('collapses a badge to its surviving leaf, and to nothing', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
rt.detachLeaf('b')
|
||||
// P = {a,b,c} minus a,b → the leaf node c, keyed by its own id
|
||||
expect(rt.getVisible().get('c')).toEqual(C)
|
||||
expect(rt.getVisible().has(P.id)).toBe(false)
|
||||
rt.detachLeaf('c')
|
||||
expect(visibleIds(rt)).toEqual(['d'])
|
||||
})
|
||||
|
||||
it('removes a leaf that is visible as its own pin', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(8) // everything split
|
||||
rt.detachLeaf('a')
|
||||
expect(visibleIds(rt)).toEqual(['b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('zoom walks stay correct while patches are active (split)', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
rt.onCamera(6.5) // past P's zSplit (6): structural split of a+b+c
|
||||
expect(rt.k).toBe(0)
|
||||
// resolved view: a stays gone, b/c/d as pins — the split itself changed nothing for a
|
||||
expect(visibleIds(rt)).toEqual(['b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('zoom walks stay correct while patches are active (merge)', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
rt.onCamera(0.9) // below E1's zMerge (1): P+D merge into Q
|
||||
expect(rt.k).toBe(2)
|
||||
const patchedQ = rt.getVisible().get(Q.id)!
|
||||
expect(patchedQ.members).toEqual(['b', 'c', 'd'])
|
||||
expect(patchedQ.count).toBe(3)
|
||||
// mean of B (10,0), C (20,0), D (100,0)
|
||||
expect(patchedQ.centroid.x).toBeCloseTo(130 / 3)
|
||||
expect(rt.getVisible().size).toBe(1)
|
||||
})
|
||||
|
||||
it('seed and seedFrom clear detaches', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
rt.seed(4)
|
||||
expect(rt.getDetachedCount()).toBe(0)
|
||||
expect(rt.getVisible().get(P.id)).toEqual(P)
|
||||
rt.detachLeaf('a')
|
||||
rt.seedFrom(
|
||||
4,
|
||||
new Map([
|
||||
[P.id, P],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.getDetachedCount()).toBe(0)
|
||||
expect(rt.getVisible().get(P.id)).toEqual(P)
|
||||
})
|
||||
|
||||
it('detachLeaves batches a whole set into one version bump', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4) // P = a+b+c visible, d separate
|
||||
const versionBefore = rt.version
|
||||
rt.detachLeaves(['a', 'b'])
|
||||
expect(rt.version).toBe(versionBefore + 1)
|
||||
expect(rt.getDetachedCount()).toBe(2)
|
||||
// P = {a,b,c} minus a,b → the leaf node c, keyed by its own id
|
||||
expect(rt.getVisible().get('c')).toEqual(C)
|
||||
expect(rt.getVisible().has(P.id)).toBe(false)
|
||||
expect(visibleIds(rt)).toEqual(['c', 'd'])
|
||||
})
|
||||
|
||||
it('detachLeaves matches the equivalent one-at-a-time detaches', () => {
|
||||
const makeDetached = (detach: (rt: ReturnType<typeof createClusterRuntime>) => void) => {
|
||||
const rt = createClusterRuntime(microTraceTable())
|
||||
rt.seed(4)
|
||||
detach(rt)
|
||||
return rt
|
||||
}
|
||||
const batched = makeDetached((rt) => rt.detachLeaves(['a', 'd']))
|
||||
const oneByOne = makeDetached((rt) => {
|
||||
rt.detachLeaf('a')
|
||||
rt.detachLeaf('d')
|
||||
})
|
||||
expect(visibleIds(batched)).toEqual(visibleIds(oneByOne))
|
||||
for (const id of visibleIds(batched)) {
|
||||
expect(batched.getVisible().get(id)).toEqual(oneByOne.getVisible().get(id))
|
||||
}
|
||||
})
|
||||
|
||||
it('detachLeaves skips unknown and already-detached ids, and no-ops on an empty batch', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
const version = rt.version
|
||||
const visible = rt.getVisible()
|
||||
rt.detachLeaves([])
|
||||
rt.detachLeaves(['a', 'nonexistent'])
|
||||
expect(rt.version).toBe(version)
|
||||
expect(rt.getDetachedCount()).toBe(1)
|
||||
expect(rt.getVisible()).toBe(visible)
|
||||
// a mixed batch still applies the new id in a single bump
|
||||
rt.detachLeaves(['a', 'b', 'nonexistent'])
|
||||
expect(rt.version).toBe(version + 1)
|
||||
expect(rt.getDetachedCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('getVisible returns a stable reference until the partition changes', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
const first = rt.getVisible()
|
||||
expect(rt.getVisible()).toBe(first)
|
||||
rt.detachLeaf('b')
|
||||
expect(rt.getVisible()).not.toBe(first)
|
||||
})
|
||||
})
|
||||
304
packages/commenting/src/clustering/runtime.ts
Normal file
304
packages/commenting/src/clustering/runtime.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import type { ClusterNode, ClusterTable, MergeEvent } from './types'
|
||||
|
||||
/** @internal */
|
||||
export interface ClusterRuntime {
|
||||
/** Events[0..k) are in the cursor (applied unless suppressed). Exposed for tests/debugging. */
|
||||
readonly k: number
|
||||
/**
|
||||
* Increments whenever the displayed partition changes. Subscribe to this, not `k`: detaches
|
||||
* and suppressed-event healing change the partition without moving the cursor.
|
||||
*/
|
||||
readonly version: number
|
||||
/** Number of suppressed band events currently inside the cursor. Exposed for tests. */
|
||||
getSuppressedCount(): number
|
||||
/** Number of leaves currently detached from the displayed partition. Exposed for tests. */
|
||||
getDetachedCount(): number
|
||||
/** Reset state from scratch for the given zoom (cold start / after rebuild). Clears detaches. */
|
||||
seed(zoom: number): void
|
||||
/**
|
||||
* Reset state for the given zoom, carrying hysteresis state over from a previous partition.
|
||||
* Threshold-forced events ignore history: zoom \<= zMerge is always merged, zoom \>= zSplit always
|
||||
* split. An event inside its band keeps its previous state; one that was unmerged (or has no
|
||||
* history) stays suppressed inside the cursor until a zoom-out crosses its own zMerge. Carryover is
|
||||
* exact — no group changes state because of the swap alone. Clears detaches.
|
||||
*/
|
||||
seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void
|
||||
/** Advance/retreat the cursor for a camera change. No-op if zoom sits inside all bands. */
|
||||
onCamera(zoom: number): void
|
||||
/**
|
||||
* Remove one leaf from the displayed partition without touching the event table. Local by
|
||||
* construction: only the nodes containing the leaf change, so a deletion, pop-out, or thread-open
|
||||
* never re-flows the rest of the document. The table's thresholds around the detached leaf go
|
||||
* stale; the caller is expected to adopt a corrected rebuild (with seedFrom) at the next zoom-out.
|
||||
* Unknown or already-detached ids are no-ops.
|
||||
*/
|
||||
detachLeaf(leafId: string): void
|
||||
/**
|
||||
* Batch form of {@link ClusterRuntime.detachLeaf}: detach every given leaf with a single
|
||||
* patch rebuild and a single version bump. Ids that are unknown or already detached are
|
||||
* skipped; if nothing new detaches, nothing changes.
|
||||
*/
|
||||
detachLeaves(leafIds: Iterable<string>): void
|
||||
/** The displayed partition: cluster id → node, with detaches applied. Do not mutate. */
|
||||
getVisible(): ReadonlyMap<string, ClusterNode>
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function createClusterRuntime(table: ClusterTable): ClusterRuntime {
|
||||
return new ClusterRuntimeImpl(table)
|
||||
}
|
||||
|
||||
class ClusterRuntimeImpl implements ClusterRuntime {
|
||||
// mutable internally; readonly through the ClusterRuntime interface
|
||||
k = 0
|
||||
version = 0
|
||||
// Structural partition: exactly leaves + applied events, never patched. The cursor invariants
|
||||
// live here, untouched by detaches — resolution to the displayed partition happens at read
|
||||
// time in getVisible().
|
||||
private visible = new Map<string, ClusterNode>()
|
||||
private seeded = false
|
||||
// Indices (< k) of band events held unmerged by seedFrom carryover. The single cursor can only
|
||||
// express "merged up to here", but a carried-over partition can be "merged except these". Self-
|
||||
// draining: an entry leaves via onCamera when the zoom crosses its zMerge or its zSplit.
|
||||
private suppressed = new Set<number>()
|
||||
// Leaves removed from the displayed partition (deleted / popped out / opened). Patches map
|
||||
// each structural node containing a detached leaf to its displayed replacement (or null to
|
||||
// drop it). Rebuilt from the detached set; cleared by seed/seedFrom.
|
||||
private detached = new Set<string>()
|
||||
private patched = new Map<string, ClusterNode | null>()
|
||||
private leafById: Map<string, ClusterNode> | null = null
|
||||
private resolvedCache: { version: number; map: Map<string, ClusterNode> } | null = null
|
||||
|
||||
constructor(private readonly table: ClusterTable) {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
getSuppressedCount(): number {
|
||||
return this.suppressed.size
|
||||
}
|
||||
|
||||
getDetachedCount(): number {
|
||||
return this.detached.size
|
||||
}
|
||||
|
||||
seed(zoom: number): void {
|
||||
validateZoom(zoom)
|
||||
this.k = seedCount(this.table.events, zoom)
|
||||
this.reset()
|
||||
for (let i = 0; i < this.k; i++) {
|
||||
applyEvent(this.visible, this.table.events[i])
|
||||
}
|
||||
this.seeded = true
|
||||
this.version++
|
||||
}
|
||||
|
||||
seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void {
|
||||
validateZoom(zoom)
|
||||
const ownerByMember = new Map<string, string>()
|
||||
for (const node of previous.values()) {
|
||||
for (const member of node.members) {
|
||||
ownerByMember.set(member, node.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Cut the cursor purely on split thresholds. zSplit is non-increasing down the table, so
|
||||
// everything past the first mandatory split (zoom >= zSplit) is also mandatorily split —
|
||||
// this cut can never conflict with an event that should be merged.
|
||||
const events = this.table.events
|
||||
let k = 0
|
||||
while (k < events.length && zoom < events[k].zSplit) {
|
||||
k++
|
||||
}
|
||||
|
||||
this.k = k
|
||||
this.reset()
|
||||
for (let i = 0; i < k; i++) {
|
||||
const event = events[i]
|
||||
if (zoom > event.zMerge && !wasMergedTogether(event.result.members, ownerByMember)) {
|
||||
// In its band and previously unmerged: keep it unmerged, as an exception inside the cursor.
|
||||
// Dependency-safe, since an applied event can never consume a suppressed result.
|
||||
this.suppressed.add(i)
|
||||
} else {
|
||||
applyEvent(this.visible, event)
|
||||
}
|
||||
}
|
||||
this.seeded = true
|
||||
this.version++
|
||||
}
|
||||
|
||||
onCamera(zoom: number): void {
|
||||
validateZoom(zoom)
|
||||
if (!this.seeded) {
|
||||
throw new Error('Cluster runtime must be seeded before onCamera')
|
||||
}
|
||||
|
||||
let changed = false
|
||||
// Heal suppressed events at their own merge threshold BEFORE the merge walk, so a zoom-out past
|
||||
// zMerge merges a held-out band event as if it had still been ahead of the cursor. Healing must come
|
||||
// first: one zoom jump can cross both a suppressed event and an event consuming its result, and
|
||||
// applying the consumer first would double-count the producer's leaves. Iterated live — deleting only
|
||||
// the entry being visited is safe, and Set iteration still yields the rest in insertion order.
|
||||
if (this.suppressed.size > 0) {
|
||||
for (const i of this.suppressed) {
|
||||
if (zoom <= this.table.events[i].zMerge) {
|
||||
this.suppressed.delete(i)
|
||||
applyEvent(this.visible, this.table.events[i])
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
while (this.k < this.table.events.length && zoom <= this.table.events[this.k].zMerge) {
|
||||
applyEvent(this.visible, this.table.events[this.k])
|
||||
this.k++
|
||||
changed = true
|
||||
}
|
||||
while (this.k > 0 && zoom >= this.table.events[this.k - 1].zSplit) {
|
||||
this.k--
|
||||
// A suppressed event was never applied — retreating past it is bookkeeping only.
|
||||
if (!this.suppressed.delete(this.k)) {
|
||||
unapplyEvent(this.visible, this.table.events[this.k])
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) this.version++
|
||||
}
|
||||
|
||||
detachLeaf(leafId: string): void {
|
||||
this.detachLeaves([leafId])
|
||||
}
|
||||
|
||||
detachLeaves(leafIds: Iterable<string>): void {
|
||||
const leafById = this.getLeafById()
|
||||
let changed = false
|
||||
for (const leafId of leafIds) {
|
||||
if (this.detached.has(leafId)) continue
|
||||
if (!leafById.has(leafId)) continue
|
||||
this.detached.add(leafId)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return
|
||||
this.rebuildPatches()
|
||||
this.version++
|
||||
}
|
||||
|
||||
getVisible(): ReadonlyMap<string, ClusterNode> {
|
||||
if (this.resolvedCache && this.resolvedCache.version === this.version) {
|
||||
return this.resolvedCache.map
|
||||
}
|
||||
let map: Map<string, ClusterNode>
|
||||
if (this.patched.size === 0) {
|
||||
map = this.visible
|
||||
} else {
|
||||
map = new Map()
|
||||
for (const node of this.visible.values()) {
|
||||
const resolved = this.patched.has(node.id) ? this.patched.get(node.id)! : node
|
||||
if (resolved) map.set(resolved.id, resolved)
|
||||
}
|
||||
}
|
||||
this.resolvedCache = { version: this.version, map }
|
||||
return map
|
||||
}
|
||||
|
||||
private getLeafById(): Map<string, ClusterNode> {
|
||||
if (!this.leafById) {
|
||||
this.leafById = new Map(this.table.leaves.map((leaf) => [leaf.id, leaf]))
|
||||
}
|
||||
return this.leafById
|
||||
}
|
||||
|
||||
/** Recompute the patch map from the detached set. A node is patched iff it contains a detached
|
||||
* member; the patch drops those members and recomputes count/centroid, collapsing to the surviving
|
||||
* leaf at count 1 and to nothing at count 0. Patched nodes keep their structural id. */
|
||||
private rebuildPatches() {
|
||||
this.patched.clear()
|
||||
const leafById = this.getLeafById()
|
||||
for (const id of this.detached) {
|
||||
this.patched.set(id, null)
|
||||
}
|
||||
const patch = (node: ClusterNode) => {
|
||||
const members = node.members.filter((m) => !this.detached.has(m))
|
||||
if (members.length === node.members.length) return
|
||||
if (members.length === 0) {
|
||||
this.patched.set(node.id, null)
|
||||
} else if (members.length === 1) {
|
||||
this.patched.set(node.id, leafById.get(members[0])!)
|
||||
} else {
|
||||
let x = 0
|
||||
let y = 0
|
||||
for (const m of members) {
|
||||
const leaf = leafById.get(m)!
|
||||
x += leaf.centroid.x
|
||||
y += leaf.centroid.y
|
||||
}
|
||||
this.patched.set(node.id, {
|
||||
id: node.id,
|
||||
centroid: { x: x / members.length, y: y / members.length },
|
||||
count: members.length,
|
||||
members,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const event of this.table.events) {
|
||||
patch(event.result)
|
||||
for (const child of event.children) patch(child)
|
||||
}
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.suppressed.clear()
|
||||
this.detached.clear()
|
||||
this.patched.clear()
|
||||
this.visible = new Map(this.table.leaves.map((leaf) => [leaf.id, leaf]))
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff every member id belonged to the same node of the previous partition. */
|
||||
function wasMergedTogether(
|
||||
members: readonly string[],
|
||||
ownerByMember: ReadonlyMap<string, string>
|
||||
): boolean {
|
||||
const owner = ownerByMember.get(members[0])
|
||||
if (owner === undefined) return false
|
||||
for (let i = 1; i < members.length; i++) {
|
||||
if (ownerByMember.get(members[i]) !== owner) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function seedCount(events: readonly MergeEvent[], zoom: number): number {
|
||||
let lo = 0
|
||||
let hi = events.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (zoom <= seedThreshold(events[mid])) {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
function seedThreshold(event: MergeEvent): number {
|
||||
return Math.sqrt(event.zMerge * event.zSplit)
|
||||
}
|
||||
|
||||
function applyEvent(visible: Map<string, ClusterNode>, event: MergeEvent) {
|
||||
for (const child of event.children) {
|
||||
visible.delete(child.id)
|
||||
}
|
||||
visible.set(event.result.id, event.result)
|
||||
}
|
||||
|
||||
function unapplyEvent(visible: Map<string, ClusterNode>, event: MergeEvent) {
|
||||
visible.delete(event.result.id)
|
||||
for (const child of event.children) {
|
||||
visible.set(child.id, child)
|
||||
}
|
||||
}
|
||||
|
||||
function validateZoom(zoom: number) {
|
||||
if (!Number.isFinite(zoom) || zoom <= 0) {
|
||||
throw new Error('zoom must be finite and greater than 0')
|
||||
}
|
||||
}
|
||||
508
packages/commenting/src/clustering/schedule.test.ts
Normal file
508
packages/commenting/src/clustering/schedule.test.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mstEdges } from './mst'
|
||||
import { cappedReplay } from './replay'
|
||||
// These imports are red until step 3 is implemented — that is intentional.
|
||||
// Implement `schedule.ts` (and the step 3 additions to `types.ts`) per
|
||||
// CLUSTERING-STEPS.md until this suite passes, without modifying this file.
|
||||
import { contract, finalize } from './schedule'
|
||||
import { ClusterNode, ContractedEvent, LeafInput, MergeEvent, RawMergeEvent } from './types'
|
||||
|
||||
// `contract` is specified over plain RawMergeEvent[] input precisely so it can
|
||||
// be tested with hand-built events, without running the replay.
|
||||
|
||||
/** Build a ClusterNode from member ids (leaf node for one id, cluster node for more). */
|
||||
function node(ids: string[], x = 0, y = 0): ClusterNode {
|
||||
const members = ids.slice().sort()
|
||||
return {
|
||||
id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
|
||||
centroid: { x, y },
|
||||
count: members.length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a RawMergeEvent merging two nodes, with contract-conformant ordering. */
|
||||
function raw(z: number, a: ClusterNode, b: ClusterNode): RawMergeEvent {
|
||||
const [first, second] = a.members[0] < b.members[0] ? [a, b] : [b, a]
|
||||
return { z, children: [first, second], result: node([...a.members, ...b.members]) }
|
||||
}
|
||||
|
||||
/** Every cluster id referenced anywhere in the output (children and results). */
|
||||
function collectIds(events: readonly ContractedEvent[]): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const ev of events) {
|
||||
for (const c of ev.children) ids.add(c.id)
|
||||
ids.add(ev.result.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function contractedShape(ev: ContractedEvent) {
|
||||
return {
|
||||
zMerge: ev.zMerge,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
}
|
||||
}
|
||||
|
||||
const A = node(['a'])
|
||||
const B = node(['b'])
|
||||
const C = node(['c'])
|
||||
const D = node(['d'])
|
||||
const E = node(['e'])
|
||||
const F = node(['f'])
|
||||
const X = node(['x'])
|
||||
const Y = node(['y'])
|
||||
|
||||
describe('contract fixtures', () => {
|
||||
it('returns [] for empty input', () => {
|
||||
expect(contract([], 0.12)).toEqual([])
|
||||
})
|
||||
|
||||
it('passes a single event through unchanged (apart from the type)', () => {
|
||||
const e1 = raw(4, A, B)
|
||||
expect(contract([e1], 0.12)).toEqual([{ zMerge: 4, children: [A, B], result: e1.result }])
|
||||
})
|
||||
|
||||
it('fuses a two-event chain within eps into one 3-way event at the anchor z', () => {
|
||||
const e1 = raw(4, A, B) // result P = cluster:2:a
|
||||
const e2 = raw(3.7, e1.result, C) // 3.7 >= 4/1.12 ≈ 3.571 → same window, chained via P
|
||||
const out = contract([e1, e2], 0.12)
|
||||
expect(out).toEqual([{ zMerge: 4, children: [A, B, C], result: e2.result }])
|
||||
// the intermediate P is erased — representable nowhere
|
||||
expect(collectIds(out).has(e1.result.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the same chain separate when the second event falls outside eps', () => {
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(3.5, e1.result, C) // 3.5 < 4/1.12 ≈ 3.571 → next window
|
||||
const out = contract([e1, e2], 0.12)
|
||||
expect(out.map(contractedShape)).toEqual([
|
||||
{ zMerge: 4, children: ['a', 'b'], result: e1.result.id },
|
||||
{ zMerge: 3.5, children: [e1.result.id, 'c'], result: e2.result.id },
|
||||
])
|
||||
// the intermediate survives: it is genuinely visible between 3.5 and 4
|
||||
expect(collectIds(out).has(e1.result.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('synchronizes unrelated same-window merges without fusing them', () => {
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(3.9, X, Y) // within eps of the anchor, but disjoint
|
||||
const out = contract([e1, e2], 0.12)
|
||||
// two events, BOTH at the anchor z, ordered by ascending min-member id of result
|
||||
expect(out.map(contractedShape)).toEqual([
|
||||
{ zMerge: 4, children: ['a', 'b'], result: e1.result.id },
|
||||
{ zMerge: 4, children: ['x', 'y'], result: e2.result.id },
|
||||
])
|
||||
})
|
||||
|
||||
it('separates interleaved chains within one window by connectivity', () => {
|
||||
const e1 = raw(4, A, B) // chain 1
|
||||
const e2 = raw(3.95, X, Y) // chain 2, interleaved between chain 1's events
|
||||
const e3 = raw(3.9, e1.result, C) // chain 1 again, via e1.result
|
||||
const out = contract([e1, e2, e3], 0.12)
|
||||
expect(out.map(contractedShape)).toEqual([
|
||||
{ zMerge: 4, children: ['a', 'b', 'c'], result: e3.result.id },
|
||||
{ zMerge: 4, children: ['x', 'y'], result: e2.result.id },
|
||||
])
|
||||
expect(collectIds(out).has(e1.result.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('fuses branched chains (two results consumed by one later event)', () => {
|
||||
const e1 = raw(4, A, B) // → cluster:2:a
|
||||
const e2 = raw(3.9, C, D) // → cluster:2:c
|
||||
const e3 = raw(3.85, e1.result, e2.result) // consumes both → one connected chain
|
||||
const out = contract([e1, e2, e3], 0.12)
|
||||
expect(out).toEqual([{ zMerge: 4, children: [A, B, C, D], result: e3.result }])
|
||||
const ids = collectIds(out)
|
||||
expect(ids.has(e1.result.id)).toBe(false)
|
||||
expect(ids.has(e2.result.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('anchors the window to its first event, never the previous one', () => {
|
||||
// 3.8 is within eps of the 4.0 anchor; 3.55 is within eps of 3.8 but NOT of
|
||||
// the anchor (3.55 < 4/1.12 ≈ 3.571) — a gradient must break into windows.
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(3.8, X, Y)
|
||||
const e3 = raw(3.55, C, D)
|
||||
const out = contract([e1, e2, e3], 0.12)
|
||||
expect(out.map((ev) => ev.zMerge)).toEqual([4, 4, 3.55])
|
||||
})
|
||||
|
||||
it('includes an event exactly on the window boundary (>=, not >)', () => {
|
||||
// eps = 0.25 → boundary at exactly 4/1.25 = 3.2
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(3.2, X, Y)
|
||||
const out = contract([e1, e2], 0.25)
|
||||
expect(out.map((ev) => ev.zMerge)).toEqual([4, 4])
|
||||
})
|
||||
|
||||
it('fuses a chain that spans windows only up to the boundary, keeping the seam intact', () => {
|
||||
// One chain across five events; eps = 0.12 splits it into windows
|
||||
// {4.0, 3.8, 3.6} and {3.4, 3.2}. The second window's fused event must
|
||||
// reference the first window's fused RESULT (cluster:4:a), not an erased
|
||||
// intermediate.
|
||||
const e1 = raw(4, A, B) // → cluster:2:a
|
||||
const e2 = raw(3.8, e1.result, C) // → cluster:3:a
|
||||
const e3 = raw(3.6, e2.result, D) // → cluster:4:a
|
||||
const e4 = raw(3.4, e3.result, E) // → cluster:5:a
|
||||
const e5 = raw(3.2, e4.result, F) // → cluster:6:a
|
||||
const out = contract([e1, e2, e3, e4, e5], 0.12)
|
||||
expect(out.map(contractedShape)).toEqual([
|
||||
{ zMerge: 4, children: ['a', 'b', 'c', 'd'], result: 'cluster:4:a' },
|
||||
{ zMerge: 3.4, children: ['cluster:4:a', 'e', 'f'], result: 'cluster:6:a' },
|
||||
])
|
||||
const ids = collectIds(out)
|
||||
for (const erased of [e1.result.id, e2.result.id, e4.result.id]) {
|
||||
expect(ids.has(erased)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('groups exact ties with eps = 0, and nothing else', () => {
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(4, e1.result, C) // exact tie, chained → fuses even at eps = 0
|
||||
expect(contract([e1, e2], 0)).toEqual([{ zMerge: 4, children: [A, B, C], result: e2.result }])
|
||||
|
||||
const e3 = raw(4, A, B)
|
||||
const e4 = raw(3.9999999, e3.result, C) // any difference → separate windows
|
||||
expect(contract([e3, e4], 0).map((ev) => ev.zMerge)).toEqual([4, 3.9999999])
|
||||
})
|
||||
|
||||
it('lets an Infinity anchor admit only other Infinity events', () => {
|
||||
const e1 = raw(Infinity, A, B)
|
||||
const e2 = raw(4, e1.result, C) // finite: must NOT join the Infinity window
|
||||
expect(contract([e1, e2], 0.12).map(contractedShape)).toEqual([
|
||||
{ zMerge: Infinity, children: ['a', 'b'], result: e1.result.id },
|
||||
{ zMerge: 4, children: [e1.result.id, 'c'], result: e2.result.id },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps two coincident groups separate inside one Infinity window', () => {
|
||||
const e1 = raw(Infinity, A, B)
|
||||
const e2 = raw(Infinity, X, Y)
|
||||
expect(contract([e1, e2], 0.12).map(contractedShape)).toEqual([
|
||||
{ zMerge: Infinity, children: ['a', 'b'], result: e1.result.id },
|
||||
{ zMerge: Infinity, children: ['x', 'y'], result: e2.result.id },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('contract validation and purity', () => {
|
||||
it('throws on negative or non-finite eps', () => {
|
||||
const events = [raw(4, A, B)]
|
||||
expect(() => contract(events, -0.1)).toThrow()
|
||||
expect(() => contract(events, NaN)).toThrow()
|
||||
expect(() => contract(events, Infinity)).toThrow()
|
||||
})
|
||||
|
||||
it('is pure: identical repeat output, no input mutation', () => {
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(3.7, e1.result, C)
|
||||
const input = [e1, e2]
|
||||
const snapshot = JSON.parse(JSON.stringify(input))
|
||||
const first = contract(input, 0.12)
|
||||
const second = contract(input, 0.12)
|
||||
expect(first).toEqual(second)
|
||||
expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
const FIN_OPTS = { Tc: 40, Tu: 60, minZoom: 0.1, maxZoom: 8, maxSplitZoom: 1e9 } // r = 1.5
|
||||
|
||||
function contracted(zMerge: number, children: ClusterNode[], result: ClusterNode): ContractedEvent {
|
||||
return { zMerge, children, result }
|
||||
}
|
||||
|
||||
describe('finalize fixtures', () => {
|
||||
it('returns [] for empty input', () => {
|
||||
expect(finalize([], FIN_OPTS)).toEqual([])
|
||||
})
|
||||
|
||||
it('applies zSplit = r · zMerge and preserves everything else', () => {
|
||||
const ev1 = contracted(4, [A, B], node(['a', 'b']))
|
||||
const ev2 = contracted(1, [node(['a', 'b']), C], node(['a', 'b', 'c']))
|
||||
expect(finalize([ev1, ev2], FIN_OPTS)).toEqual([
|
||||
{ zMerge: 4, zSplit: 6, children: ev1.children, result: ev1.result },
|
||||
{ zMerge: 1, zSplit: 1.5, children: ev2.children, result: ev2.result },
|
||||
])
|
||||
})
|
||||
|
||||
it('clamps a band that straddles maxZoom (the stuck-merged trap)', () => {
|
||||
// zMerge 6, r 1.5 → raw zSplit 9 > maxZoom 8: without the clamp this pair
|
||||
// would merge on zoom-out and never split again within reachable zoom.
|
||||
const ev = contracted(6, [A, B], node(['a', 'b']))
|
||||
const [out] = finalize([ev], FIN_OPTS)
|
||||
expect(out.zSplit).toBe(8)
|
||||
expect(out.zMerge).toBe(6)
|
||||
})
|
||||
|
||||
it('does not clamp bands that fit under maxZoom', () => {
|
||||
const ev = contracted(2, [A, B], node(['a', 'b']))
|
||||
expect(finalize([ev], FIN_OPTS)[0].zSplit).toBe(3)
|
||||
})
|
||||
|
||||
it('leaves zMerge >= maxZoom events unclamped by maxZoom (band above camera range)', () => {
|
||||
// zMerge exactly at maxZoom: the clamp condition is zMerge < maxZoom, strict
|
||||
const atMax = contracted(8, [A, B], node(['a', 'b']))
|
||||
expect(finalize([atMax], FIN_OPTS)[0].zSplit).toBe(12)
|
||||
// coincident anchors are no longer permanently merged — maxSplitZoom caps them
|
||||
const inf = contracted(Infinity, [X, Y], node(['x', 'y']))
|
||||
expect(finalize([inf], FIN_OPTS)[0]).toMatchObject({ zMerge: 1e9 / 1.5, zSplit: 1e9 })
|
||||
})
|
||||
|
||||
it('caps every split at maxSplitZoom, folding the band below the cap', () => {
|
||||
// r = 1.5, maxSplitZoom 6 -> zMerge cap 4: past zoom 6 nothing stays clustered,
|
||||
// however close (or coincident) the members are.
|
||||
const opts = { ...FIN_OPTS, maxSplitZoom: 6 }
|
||||
const coincident = contracted(Infinity, [A, B], node(['a', 'b']))
|
||||
const tight = contracted(10, [A, B], node(['a', 'b']))
|
||||
const normal = contracted(2, [A, B], node(['a', 'b']))
|
||||
expect(finalize([coincident], opts)[0]).toMatchObject({ zMerge: 4, zSplit: 6 })
|
||||
expect(finalize([tight], opts)[0]).toMatchObject({ zMerge: 4, zSplit: 6 })
|
||||
// events already below the cap are untouched
|
||||
expect(finalize([normal], opts)[0]).toMatchObject({ zMerge: 2, zSplit: 3 })
|
||||
})
|
||||
|
||||
it('capping preserves sort order and the zSplit > zMerge invariant', () => {
|
||||
const opts = { ...FIN_OPTS, maxSplitZoom: 6 }
|
||||
const events = [
|
||||
contracted(Infinity, [A, B], node(['a', 'b'])),
|
||||
contracted(10, [C, X], node(['c', 'x'])),
|
||||
contracted(2, [Y, node(['a', 'b'])], node(['a', 'b', 'y'])),
|
||||
]
|
||||
const out = finalize(events, opts)
|
||||
expect(out.map((e) => e.zMerge)).toEqual([4, 4, 2])
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
expect(out[i].zSplit).toBeGreaterThan(out[i].zMerge)
|
||||
if (i > 0) expect(out[i].zSplit).toBeLessThanOrEqual(out[i - 1].zSplit)
|
||||
}
|
||||
})
|
||||
|
||||
it('throws on non-positive or non-finite maxSplitZoom', () => {
|
||||
const events = [contracted(2, [A, B], node(['a', 'b']))]
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: 0 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: -1 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: NaN })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: Infinity })).toThrow()
|
||||
})
|
||||
|
||||
it('prunes the trailing suffix below minZoom, keeping the boundary value', () => {
|
||||
const keep = contracted(4, [A, B], node(['a', 'b']))
|
||||
const boundary = contracted(0.1, [node(['a', 'b']), C], node(['a', 'b', 'c'])) // == minZoom: kept
|
||||
const prune = contracted(0.0999, [node(['a', 'b', 'c']), D], node(['a', 'b', 'c', 'd']))
|
||||
const out = finalize([keep, boundary, prune], FIN_OPTS)
|
||||
expect(out.map((ev) => ev.zMerge)).toEqual([4, 0.1])
|
||||
})
|
||||
|
||||
it('throws on every invalid option combination', () => {
|
||||
const events = [contracted(4, [A, B], node(['a', 'b']))]
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tc: 0 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tc: -1 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tu: 40 })).toThrow() // Tu must be > Tc
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tu: 30 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, minZoom: 0 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, minZoom: -1 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxZoom: 0.1 })).toThrow() // must be > minZoom
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxZoom: 0.05 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tc: NaN })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tu: Infinity })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxZoom: Infinity })).toThrow()
|
||||
})
|
||||
|
||||
it('is pure: identical repeat output, no input mutation', () => {
|
||||
const input = [contracted(4, [A, B], node(['a', 'b']))]
|
||||
const snapshot = JSON.parse(JSON.stringify(input))
|
||||
expect(finalize(input, FIN_OPTS)).toEqual(finalize(input, FIN_OPTS))
|
||||
expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
// finalize(contract(cappedReplay(mstEdges(leaves)))) must satisfy every table
|
||||
// invariant of CLUSTERING.md §7.6.
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function shuffled<T>(items: readonly T[], rand: () => number): T[] {
|
||||
const out = items.slice()
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[out[i], out[j]] = [out[j], out[i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
const PIPE_OPTS = {
|
||||
Tc: 40,
|
||||
Tu: 60,
|
||||
eps: 0.12,
|
||||
Dmax: 120,
|
||||
minZoom: 1e-6,
|
||||
maxZoom: 8,
|
||||
maxSplitZoom: 1e9,
|
||||
}
|
||||
|
||||
function buildTableEvents(
|
||||
leaves: readonly LeafInput[],
|
||||
opts: typeof PIPE_OPTS = PIPE_OPTS
|
||||
): MergeEvent[] {
|
||||
const rawEvents = cappedReplay(leaves, mstEdges(leaves), { Tc: opts.Tc, Dmax: opts.Dmax })
|
||||
return finalize(contract(rawEvents, opts.eps), {
|
||||
Tc: opts.Tc,
|
||||
Tu: opts.Tu,
|
||||
minZoom: opts.minZoom,
|
||||
maxZoom: opts.maxZoom,
|
||||
maxSplitZoom: opts.maxSplitZoom,
|
||||
})
|
||||
}
|
||||
|
||||
/** All §7.6 invariants over a finalized event list. */
|
||||
function expectTableInvariants(
|
||||
events: readonly MergeEvent[],
|
||||
leaves: readonly LeafInput[],
|
||||
opts: typeof PIPE_OPTS
|
||||
) {
|
||||
// 1–3: threshold ordering and band validity
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i].zMerge).toBeLessThanOrEqual(events[i - 1].zMerge)
|
||||
expect(events[i].zSplit).toBeLessThanOrEqual(events[i - 1].zSplit)
|
||||
}
|
||||
for (const ev of events) {
|
||||
// coincident clusters are the one exception: zMerge = zSplit = Infinity (never split)
|
||||
if (Number.isFinite(ev.zMerge)) {
|
||||
expect(ev.zSplit).toBeGreaterThan(ev.zMerge)
|
||||
} else {
|
||||
expect(ev.zSplit).toBe(Infinity)
|
||||
}
|
||||
if (ev.zMerge < opts.maxZoom) expect(ev.zSplit).toBeLessThanOrEqual(opts.maxZoom)
|
||||
expect(ev.zMerge).toBeGreaterThanOrEqual(opts.minZoom)
|
||||
}
|
||||
// 4: dendrogram integrity — replaying all events over the leaves is well
|
||||
// formed (children exist when consumed ⇒ no erased intermediate is referenced)
|
||||
const live = new Map<string, ClusterNode>()
|
||||
for (const l of leaves) {
|
||||
live.set(l.id, { id: l.id, centroid: { ...l.point }, count: 1, members: [l.id] })
|
||||
}
|
||||
for (const ev of events) {
|
||||
expect(ev.children.length).toBeGreaterThanOrEqual(2)
|
||||
for (const child of ev.children) {
|
||||
expect(live.get(child.id)).toBeDefined()
|
||||
live.delete(child.id)
|
||||
}
|
||||
for (let i = 1; i < ev.children.length; i++) {
|
||||
expect(ev.children[i - 1].members[0] < ev.children[i].members[0]).toBe(true)
|
||||
}
|
||||
const union = ev.children.flatMap((c) => c.members).sort()
|
||||
expect(ev.result.members).toEqual(union)
|
||||
expect(ev.result.count).toBe(union.length)
|
||||
expect(ev.result.id).toBe(`cluster:${union.length}:${union[0]}`)
|
||||
live.set(ev.result.id, ev.result)
|
||||
}
|
||||
// with minZoom = 1e-6 nothing prunes, so the dendrogram completes to one root
|
||||
if (leaves.length >= 2) {
|
||||
expect(live.size).toBe(1)
|
||||
expect([...live.values()][0].members).toEqual(leaves.map((l) => l.id).sort())
|
||||
}
|
||||
}
|
||||
|
||||
describe('composed pipeline (replay → contract → finalize)', () => {
|
||||
it('satisfies all table invariants on random inputs', () => {
|
||||
for (const seed of [3, 42]) {
|
||||
for (const n of [2, 7, 30, 80]) {
|
||||
const leaves = randomLeaves(n, seed * 17 + n)
|
||||
expectTableInvariants(buildTableEvents(leaves), leaves, PIPE_OPTS)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('satisfies all table invariants with ties and coincident anchors (lattice)', () => {
|
||||
const rand = mulberry32(11)
|
||||
const leaves = Array.from({ length: 40 }, (_, i) =>
|
||||
leaf(`lat-${i}`, Math.floor(rand() * 8) * 10, Math.floor(rand() * 8) * 10)
|
||||
)
|
||||
expectTableInvariants(buildTableEvents(leaves), leaves, PIPE_OPTS)
|
||||
})
|
||||
|
||||
it('reduces to the identity at eps = 0 on tie-free input', () => {
|
||||
// Centroid pricing plus the monotonicity clamp can produce exact ties even
|
||||
// on random input (a clamped threshold lands exactly on its predecessor),
|
||||
// and eps = 0 legitimately fuses chained ties. Identity is only promised
|
||||
// for tie-free tables — so probe seeds until one produces distinct
|
||||
// thresholds, and assert the identity there.
|
||||
let verified = false
|
||||
for (const seed of [5, 6, 7, 8, 9, 10]) {
|
||||
const leaves = randomLeaves(40, seed)
|
||||
const rawEvents = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
if (new Set(rawEvents.map((ev) => ev.z)).size !== rawEvents.length) continue
|
||||
const contractedEvents = contract(rawEvents, 0)
|
||||
expect(
|
||||
contractedEvents.map((ev) => ({ z: ev.zMerge, c: ev.children, r: ev.result }))
|
||||
).toEqual(rawEvents.map((ev) => ({ z: ev.z, c: ev.children, r: ev.result })))
|
||||
verified = true
|
||||
}
|
||||
expect(verified).toBe(true)
|
||||
})
|
||||
|
||||
it('schedules the uniform chain as synchronized pairs, quads, then the octet', () => {
|
||||
// CLUSTERING.md appendix A.2 with centroid pricing: leaf pairs all fire at
|
||||
// z = 4 (one contraction window, four disconnected chains → four events at
|
||||
// the shared anchor), quads at centroid distance 20 → z = 2, and the
|
||||
// quad-quad bridge at centroid distance 40 → z = 1.
|
||||
const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
|
||||
const events = buildTableEvents(leaves)
|
||||
expect(
|
||||
events.map((ev) => ({
|
||||
zMerge: ev.zMerge,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
}))
|
||||
).toEqual([
|
||||
{ zMerge: 4, children: ['p1', 'p2'], result: 'cluster:2:p1' },
|
||||
{ zMerge: 4, children: ['p3', 'p4'], result: 'cluster:2:p3' },
|
||||
{ zMerge: 4, children: ['p5', 'p6'], result: 'cluster:2:p5' },
|
||||
{ zMerge: 4, children: ['p7', 'p8'], result: 'cluster:2:p7' },
|
||||
{ zMerge: 2, children: ['cluster:2:p1', 'cluster:2:p3'], result: 'cluster:4:p1' },
|
||||
{ zMerge: 2, children: ['cluster:2:p5', 'cluster:2:p7'], result: 'cluster:4:p5' },
|
||||
{ zMerge: 1, children: ['cluster:4:p1', 'cluster:4:p5'], result: 'cluster:8:p1' },
|
||||
])
|
||||
expect(events.map((ev) => ev.zSplit)).toEqual([6, 6, 6, 6, 3, 3, 1.5])
|
||||
})
|
||||
|
||||
it('is deterministic end to end under input permutation', () => {
|
||||
const base = randomLeaves(50, 23)
|
||||
const expected = buildTableEvents(base).map((ev) => ({
|
||||
zMerge: ev.zMerge,
|
||||
zSplit: ev.zSplit,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
}))
|
||||
for (const shuffleSeed of [7, 29]) {
|
||||
const perm = shuffled(base, mulberry32(shuffleSeed))
|
||||
expect(
|
||||
buildTableEvents(perm).map((ev) => ({
|
||||
zMerge: ev.zMerge,
|
||||
zSplit: ev.zSplit,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
}))
|
||||
).toEqual(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
184
packages/commenting/src/clustering/schedule.ts
Normal file
184
packages/commenting/src/clustering/schedule.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { ClusterNode, ContractedEvent, MergeEvent, RawMergeEvent } from './types'
|
||||
|
||||
export function contract(raw: readonly RawMergeEvent[], eps: number): ContractedEvent[] {
|
||||
if (!Number.isFinite(eps) || eps < 0) {
|
||||
throw new Error('eps must be finite and greater than or equal to 0')
|
||||
}
|
||||
|
||||
const out: ContractedEvent[] = []
|
||||
let i = 0
|
||||
while (i < raw.length) {
|
||||
const anchor = raw[i].z
|
||||
let j = i
|
||||
while (j + 1 < raw.length && raw[j + 1].z >= anchor / (1 + eps)) {
|
||||
j++
|
||||
}
|
||||
|
||||
const chains = contractWindow(raw.slice(i, j + 1), anchor)
|
||||
out.push(...chains)
|
||||
i = j + 1
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function finalize(
|
||||
events: readonly ContractedEvent[],
|
||||
opts: { Tc: number; Tu: number; minZoom: number; maxZoom: number; maxSplitZoom: number }
|
||||
): MergeEvent[] {
|
||||
validateFinalizeOptions(opts)
|
||||
|
||||
const r = opts.Tu / opts.Tc
|
||||
// Every cluster must have split by maxSplitZoom, however close (or coincident) its members.
|
||||
// Capping zMerge at maxSplitZoom / r keeps the whole band below the cap, preserving
|
||||
// zSplit > zMerge and the table's sort order (min with a constant is order-preserving).
|
||||
const zMergeCap = opts.maxSplitZoom / r
|
||||
const out: MergeEvent[] = []
|
||||
for (const event of events) {
|
||||
const zMerge = Math.min(event.zMerge, zMergeCap)
|
||||
if (zMerge < opts.minZoom) break
|
||||
let zSplit = zMerge * r
|
||||
if (zMerge < opts.maxZoom) {
|
||||
zSplit = Math.min(zSplit, opts.maxZoom)
|
||||
}
|
||||
out.push({
|
||||
zMerge,
|
||||
zSplit,
|
||||
children: event.children,
|
||||
result: event.result,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function contractWindow(events: readonly RawMergeEvent[], zMerge: number): ContractedEvent[] {
|
||||
const uf = new ComponentUnionFind(events.length)
|
||||
const resultToIndex = new Map<string, number>()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
resultToIndex.set(events[i].result.id, i)
|
||||
}
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
for (const child of events[i].children) {
|
||||
const producer = resultToIndex.get(child.id)
|
||||
if (producer !== undefined) {
|
||||
uf.union(i, producer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const byRoot = new Map<number, number[]>()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const root = uf.find(i)
|
||||
const list = byRoot.get(root)
|
||||
if (list) {
|
||||
list.push(i)
|
||||
} else {
|
||||
byRoot.set(root, [i])
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byRoot.values())
|
||||
.map((indices) => contractChain(events, indices, zMerge))
|
||||
.sort((a, b) => compareNodesByMinMember(a.result, b.result))
|
||||
}
|
||||
|
||||
function contractChain(
|
||||
events: readonly RawMergeEvent[],
|
||||
indices: readonly number[],
|
||||
zMerge: number
|
||||
): ContractedEvent {
|
||||
const produced = new Set<string>()
|
||||
const consumedProduced = new Set<string>()
|
||||
for (const index of indices) {
|
||||
produced.add(events[index].result.id)
|
||||
}
|
||||
for (const index of indices) {
|
||||
for (const child of events[index].children) {
|
||||
if (produced.has(child.id)) consumedProduced.add(child.id)
|
||||
}
|
||||
}
|
||||
|
||||
const childrenById = new Map<string, ClusterNode>()
|
||||
for (const index of indices) {
|
||||
for (const child of events[index].children) {
|
||||
if (!produced.has(child.id)) childrenById.set(child.id, child)
|
||||
}
|
||||
}
|
||||
|
||||
let result: ClusterNode | undefined
|
||||
for (const index of indices) {
|
||||
const candidate = events[index].result
|
||||
if (!consumedProduced.has(candidate.id)) {
|
||||
if (!result || compareNodesByMinMember(candidate, result) < 0) result = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
zMerge,
|
||||
children: Array.from(childrenById.values()).sort(compareNodesByMinMember),
|
||||
result: result!,
|
||||
}
|
||||
}
|
||||
|
||||
function validateFinalizeOptions(opts: {
|
||||
Tc: number
|
||||
Tu: number
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
maxSplitZoom: number
|
||||
}) {
|
||||
if (!Number.isFinite(opts.maxSplitZoom) || opts.maxSplitZoom <= 0) {
|
||||
throw new Error('maxSplitZoom must be finite and greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(opts.Tc) || opts.Tc <= 0) {
|
||||
throw new Error('Tc must be finite and greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(opts.Tu) || opts.Tu <= opts.Tc) {
|
||||
throw new Error('Tu must be finite and greater than Tc')
|
||||
}
|
||||
if (!Number.isFinite(opts.minZoom) || opts.minZoom <= 0) {
|
||||
throw new Error('minZoom must be finite and greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(opts.maxZoom) || opts.maxZoom <= opts.minZoom) {
|
||||
throw new Error('maxZoom must be finite and greater than minZoom')
|
||||
}
|
||||
}
|
||||
|
||||
function compareNodesByMinMember(a: ClusterNode, b: ClusterNode): number {
|
||||
const aMin = a.members[0]
|
||||
const bMin = b.members[0]
|
||||
if (aMin < bMin) return -1
|
||||
if (aMin > bMin) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
class ComponentUnionFind {
|
||||
private readonly parent: Int32Array
|
||||
|
||||
constructor(n: number) {
|
||||
this.parent = new Int32Array(n)
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.parent[i] = i
|
||||
}
|
||||
}
|
||||
|
||||
find(index: number): number {
|
||||
let root = index
|
||||
while (this.parent[root] !== root) {
|
||||
root = this.parent[root]
|
||||
}
|
||||
while (this.parent[index] !== index) {
|
||||
const next = this.parent[index]
|
||||
this.parent[index] = root
|
||||
index = next
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
union(a: number, b: number) {
|
||||
const rootA = this.find(a)
|
||||
const rootB = this.find(b)
|
||||
if (rootA !== rootB) this.parent[rootB] = rootA
|
||||
}
|
||||
}
|
||||
171
packages/commenting/src/clustering/screen-offsets.test.ts
Normal file
171
packages/commenting/src/clustering/screen-offsets.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { VecLike } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeClusterTable } from './computeClusterTable'
|
||||
import type { LeafInput } from './types'
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
const ZOOM_BOUNDS = { minZoom: 0.05, maxZoom: 8 }
|
||||
// eps 0 keeps every merge as its own solo event, so the exact thresholds under test aren't
|
||||
// snapped to a contraction window's anchor.
|
||||
const EXACT_OPTS = { Tc: 22, Tu: 26.4, eps: 0, Dmax: 82.5, ...ZOOM_BOUNDS }
|
||||
|
||||
function offsets(entries: Record<string, VecLike>): Map<string, VecLike> {
|
||||
return new Map(Object.entries(entries))
|
||||
}
|
||||
|
||||
describe('computeClusterTable without screen offsets (the guarantee)', () => {
|
||||
it('is deep-equal across omitted, undefined, and empty offset arguments', () => {
|
||||
for (const seed of [3, 21]) {
|
||||
const leaves = randomLeaves(40, seed)
|
||||
const plain = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS }, undefined)).toEqual(plain)
|
||||
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS }, new Map())).toEqual(plain)
|
||||
}
|
||||
})
|
||||
|
||||
it('is deep-equal when every leaf carries the same offset (Δō is always zero)', () => {
|
||||
const leaves = randomLeaves(30, 11)
|
||||
const uniform = new Map(leaves.map((l) => [l.id, { x: -20, y: 20 }]))
|
||||
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS }, uniform)).toEqual(
|
||||
computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
)
|
||||
})
|
||||
|
||||
it('never emits a zSplit override on the offset-free path', () => {
|
||||
const table = computeClusterTable(randomLeaves(20, 5), { ...ZOOM_BOUNDS }, new Map())
|
||||
for (const event of table.events) {
|
||||
// finalize always derives ratio splits when no override was carried
|
||||
expect(event.zSplit).toBeCloseTo(event.zMerge * 1.2, 10)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('offset-aware pricing', () => {
|
||||
const A = leaf('a', 0, 0)
|
||||
const B = leaf('b', 100, 0)
|
||||
|
||||
it('merges earlier when the insets point toward each other, at the exact visual crossing', () => {
|
||||
// Visual positions: a at 100·0·z + 20, b at 100z − 20 → distance |100z − 40|. Tc = 22
|
||||
// crosses at z = 0.62 (raw anchors would say 0.22); the split derives from the Tu/Tc
|
||||
// ratio, as for every event.
|
||||
const table = computeClusterTable(
|
||||
[A, B],
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: 20, y: 0 }, b: { x: -20, y: 0 } })
|
||||
)
|
||||
expect(table.events).toHaveLength(1)
|
||||
expect(table.events[0].zMerge).toBeCloseTo(0.62, 10)
|
||||
expect(table.events[0].zSplit).toBeCloseTo(0.62 * 1.2, 10)
|
||||
})
|
||||
|
||||
it('merges later when the insets point apart, at the exact visual crossing', () => {
|
||||
// Distance 100z + 10: Tc at z = 0.12 (raw: 0.22); ratio split at 0.144.
|
||||
const table = computeClusterTable(
|
||||
[A, B],
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: -5, y: 0 }, b: { x: 5, y: 0 } })
|
||||
)
|
||||
expect(table.events).toHaveLength(1)
|
||||
expect(table.events[0].zMerge).toBeCloseTo(0.12, 10)
|
||||
expect(table.events[0].zSplit).toBeCloseTo(0.144, 10)
|
||||
})
|
||||
|
||||
it('never merges a pair whose visual distance never reaches Tc', () => {
|
||||
// Distance 100z + 40 ≥ 40 > Tc at every zoom: priced 0 and pruned by the minZoom cut.
|
||||
const table = computeClusterTable(
|
||||
[A, B],
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: -20, y: 0 }, b: { x: 20, y: 0 } })
|
||||
)
|
||||
expect(table.events).toEqual([])
|
||||
})
|
||||
|
||||
it('still applies the Dmax fit cap to a corrected merge', () => {
|
||||
// Exaggerated inward offsets put the Tc crossing at z = 1.42, above the spread cap
|
||||
// Dmax/diag = 82.5/100 = 0.825, so the cap wins the min.
|
||||
const table = computeClusterTable(
|
||||
[A, B],
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: 60, y: 0 }, b: { x: -60, y: 0 } })
|
||||
)
|
||||
expect(table.events).toHaveLength(1)
|
||||
expect(table.events[0].zMerge).toBeCloseTo(0.825, 10)
|
||||
expect(table.events[0].zSplit).toBeCloseTo(0.825 * 1.2, 10)
|
||||
})
|
||||
|
||||
it('treats coincident anchors with differing offsets as a constant visual distance', () => {
|
||||
// Same anchor point, one pin tucked: constant 20px apart < Tc → merged at every zoom,
|
||||
// entering the same maxSplitZoom band as a coincident pair (zMerge 6/1.2 = 5, zSplit 6).
|
||||
const table = computeClusterTable(
|
||||
[leaf('a', 50, 50), leaf('b', 50, 50)],
|
||||
{ ...EXACT_OPTS, maxSplitZoom: 6 },
|
||||
offsets({ a: { x: 20, y: 0 } })
|
||||
)
|
||||
expect(table.events).toHaveLength(1)
|
||||
expect(table.events[0].zMerge).toBe(5)
|
||||
expect(table.events[0].zSplit).toBe(6)
|
||||
})
|
||||
|
||||
it("dilutes a cluster's mean offset as precise members fold in", () => {
|
||||
// a (tucked 20px) and b (precise) share an anchor: constant 20px apart, always merged.
|
||||
// Their cluster's mean offset is (10, 0); against precise c 100 units right the visual
|
||||
// distance is |100z − 10| → Tc at z = 0.32 — half the lone pin's correction.
|
||||
const table = computeClusterTable(
|
||||
[leaf('a', 0, 0), leaf('b', 0, 0), leaf('c', 100, 0)],
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: 20, y: 0 } })
|
||||
)
|
||||
expect(table.events).toHaveLength(2)
|
||||
const outer = table.events.find((e) => e.result.count === 3)!
|
||||
expect(outer.zMerge).toBeCloseTo(0.32, 10)
|
||||
expect(outer.zSplit).toBeCloseTo(0.32 * 1.2, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('table ordering with offsets', () => {
|
||||
it('keeps zMerge and zSplit non-increasing (splits are always ratio-derived)', () => {
|
||||
// Mixed offset and plain leaves at varied spacings: every split is zMerge · (Tu/Tc), so
|
||||
// both threshold sequences inherit the table's non-increasing order — the invariant the
|
||||
// runtime's prefix cursor, split walk, and seed bisection rely on.
|
||||
const leaves = [
|
||||
leaf('a', 0, 0),
|
||||
leaf('b', 100, 0),
|
||||
leaf('c', 130, 0),
|
||||
leaf('d', 400, 0),
|
||||
leaf('e', 470, 0),
|
||||
leaf('f', 1000, 300),
|
||||
]
|
||||
const table = computeClusterTable(
|
||||
leaves,
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: -20, y: 0 }, b: { x: 14, y: 0 }, d: { x: 20, y: 14 } })
|
||||
)
|
||||
expect(table.events.length).toBeGreaterThan(2)
|
||||
for (let i = 1; i < table.events.length; i++) {
|
||||
expect(table.events[i].zMerge).toBeLessThanOrEqual(table.events[i - 1].zMerge)
|
||||
expect(table.events[i].zSplit).toBeLessThanOrEqual(table.events[i - 1].zSplit)
|
||||
}
|
||||
for (const event of table.events) {
|
||||
expect(event.zSplit).toBeCloseTo(event.zMerge * 1.2, 10)
|
||||
}
|
||||
})
|
||||
})
|
||||
112
packages/commenting/src/clustering/types.ts
Normal file
112
packages/commenting/src/clustering/types.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { VecLike } from 'tldraw'
|
||||
|
||||
/**
|
||||
* One comment thread's pin, already resolved to a page-space anchor point.
|
||||
* @internal
|
||||
*/
|
||||
export interface LeafInput {
|
||||
/** Unique. Thread id. Uniqueness is a precondition — throw on duplicates. */
|
||||
id: string
|
||||
/** Page-space coordinates. Must be finite — throw on NaN/Infinity. */
|
||||
point: VecLike
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant screen-px render offsets for markers that don't draw exactly on their anchor —
|
||||
* imprecise shape pins, which tuck ~20px into their shape. Keyed by leaf id; leaves absent from
|
||||
* the map render on their anchor. A marker's visual position at zoom z is `z · point + offset`.
|
||||
* Passed as the optional third argument to `computeClusterTable`; when omitted or empty the
|
||||
* algorithm's behavior (and output) is identical to an offset-unaware run.
|
||||
* @internal
|
||||
*/
|
||||
export type LeafScreenOffsets = ReadonlyMap<string, VecLike>
|
||||
|
||||
/** An edge of the Euclidean MST over the leaf anchor points. */
|
||||
export interface MstEdge {
|
||||
/** Index into the input leaves array. Normalized: leaves[a].id < leaves[b].id (lexicographic). */
|
||||
a: number
|
||||
/** Index into the input leaves array. */
|
||||
b: number
|
||||
/** Exact Euclidean page-space distance between the two anchors. May be 0 (coincident). */
|
||||
d: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A cluster in the merge tree: a leaf (one thread) or a merged group.
|
||||
* @internal
|
||||
*/
|
||||
export interface ClusterNode {
|
||||
/** Leaves: the thread id verbatim. Merged nodes: `cluster:${count}:${minMemberId}`. */
|
||||
id: string
|
||||
/** Page space; count-weighted mean of all member leaf anchors. */
|
||||
centroid: VecLike
|
||||
/** Number of member leaves. Leaves = 1. */
|
||||
count: number
|
||||
/** All member thread ids, sorted lexicographically ascending. */
|
||||
members: string[]
|
||||
}
|
||||
|
||||
/** One merge produced by the capped replay, before contraction. */
|
||||
export interface RawMergeEvent {
|
||||
/** Effective merge threshold zEff = min(Tc/d, Dmax/unionBboxDiag). +Infinity for coincident anchors. */
|
||||
z: number
|
||||
/** The two clusters consumed, ordered by ascending min-member id. */
|
||||
children: [ClusterNode, ClusterNode]
|
||||
/** The cluster produced. */
|
||||
result: ClusterNode
|
||||
}
|
||||
|
||||
/** A merge event after contraction: possibly multi-way, before hysteresis. */
|
||||
export interface ContractedEvent {
|
||||
/** Fires (merges) when zoom <= zMerge. May be +Infinity. */
|
||||
zMerge: number
|
||||
/** Clusters consumed — 2 or more, ordered by ascending min-member id. */
|
||||
children: ClusterNode[]
|
||||
/** Cluster produced. */
|
||||
result: ClusterNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A finalized merge event, ready for the runtime.
|
||||
* @internal
|
||||
*/
|
||||
export interface MergeEvent {
|
||||
zMerge: number
|
||||
/** Reverses (splits) when zoom \>= zSplit. Always \> zMerge. May be +Infinity. */
|
||||
zSplit: number
|
||||
children: ClusterNode[]
|
||||
result: ClusterNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The precomputed clustering schedule for one page's comments.
|
||||
* @internal
|
||||
*/
|
||||
export interface ClusterTable {
|
||||
/** Sorted non-increasing by zMerge; satisfies the invariants of CLUSTERING.md §7.6. */
|
||||
events: readonly MergeEvent[]
|
||||
/** One node per input leaf, in input order. */
|
||||
leaves: readonly ClusterNode[]
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface ClusterOptions {
|
||||
/** Cluster (merge) distance, screen px. Default 22. */
|
||||
Tc?: number
|
||||
/** Uncluster (split) distance, screen px. Must be \> Tc. Default 1.2 · Tc. */
|
||||
Tu?: number
|
||||
/** Contraction window ratio. Default 0.7. */
|
||||
eps?: number
|
||||
/** Max cluster screen extent at birth, screen px. Must be \>= Tc. Default 3.75 · Tc. */
|
||||
Dmax?: number
|
||||
/** Camera zoom bounds — pass the editor's camera constraints. Required. */
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
/**
|
||||
* Zoom by which every cluster has split, no matter how close its members are — including
|
||||
* coincident anchors, which otherwise never split. Merge thresholds are capped at
|
||||
* `maxSplitZoom / (Tu/Tc)` so the hysteresis band keeps its shape below the cap.
|
||||
* Default 6 (600%).
|
||||
*/
|
||||
maxSplitZoom?: number
|
||||
}
|
||||
145
packages/commenting/src/index.ts
Normal file
145
packages/commenting/src/index.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { registerTldrawLibraryVersion } from '@tldraw/utils'
|
||||
|
||||
// Presentational commenting components. These are tldraw-independent and can be used to build
|
||||
// custom commenting UI. The mention picker, pill, and avatar are re-exported from @tldraw/mentions
|
||||
// so the commenting public API is unchanged.
|
||||
export {
|
||||
Avatar,
|
||||
type AvatarProps,
|
||||
type CommentAuthor,
|
||||
createMentionSuggestion,
|
||||
filterMentionMembers,
|
||||
Mention,
|
||||
MentionList,
|
||||
type MentionListProps,
|
||||
type MentionMember,
|
||||
type MentionProps,
|
||||
type MentionSuggestionOptions,
|
||||
} from '@tldraw/mentions'
|
||||
export { Byline, type BylineProps } from './ui/byline'
|
||||
export { CommentCard, type CommentCardProps } from './ui/comment-card'
|
||||
export { CommentComposer, type CommentComposerProps } from './ui/comment-composer'
|
||||
export { CountBadge, type CountBadgeProps } from './ui/count-badge'
|
||||
export { CommentPin, type CommentPinProps } from './ui/comment-pin'
|
||||
export { CommentThread, type CommentThreadProps } from './ui/comment-thread'
|
||||
export {
|
||||
CommentListItem,
|
||||
CommentsList,
|
||||
isOpenInNewTabClick,
|
||||
type CommentListItemProps,
|
||||
type CommentListItemRenderProps,
|
||||
type CommentsListProps,
|
||||
} from './ui/comments-list'
|
||||
export { EmptyState, type EmptyStateProps } from './ui/empty-state'
|
||||
export { formatFullDateTime, formatRelativeTime } from './ui/format-time'
|
||||
export {
|
||||
DEFAULT_REACTION_EMOJI,
|
||||
EmojiPicker,
|
||||
type EmojiPickerProps,
|
||||
isAllowedReactionEmoji,
|
||||
} from './ui/emoji-picker'
|
||||
export {
|
||||
Reaction,
|
||||
type ReactionProps,
|
||||
type RenderReaction,
|
||||
defaultRenderReaction,
|
||||
DefaultReactionTooltip,
|
||||
DefaultReactionTooltipContent,
|
||||
type ReactionTooltipProps,
|
||||
} from './ui/reaction'
|
||||
export { ReactionPicker, type ReactionPickerProps } from './ui/reaction-picker'
|
||||
export {
|
||||
Reactions,
|
||||
type ReactionReactor,
|
||||
type ReactionsProps,
|
||||
type ReactionSummary,
|
||||
} from './ui/reactions'
|
||||
export { SendButton, type SendButtonProps } from './ui/send-button'
|
||||
|
||||
// The tldraw-coupled commenting layer: the comment tool, reactive hooks over the comment
|
||||
// records, a rich-text body renderer, and a batteries-included <CanvasComments> overlay. Pairs
|
||||
// with the presentational components above.
|
||||
export { registerCommentAnchorLifecycle } from './canvas/anchor-lifecycle'
|
||||
export { CommentBody, type CommentBodyProps } from './canvas/comment-body'
|
||||
export {
|
||||
deleteComment,
|
||||
deleteThread,
|
||||
editComment,
|
||||
putCommentRecords,
|
||||
removeCommentRecords,
|
||||
reopenThread,
|
||||
resolveThread,
|
||||
} from './canvas/comment-mutations'
|
||||
export {
|
||||
CommentReactionPicker,
|
||||
type CommentReactionPickerProps,
|
||||
CommentReactions,
|
||||
type CommentReactionsProps,
|
||||
type ReactionSummaryInput,
|
||||
summarizeReactions,
|
||||
toggleCommentReaction,
|
||||
useCommentReactions,
|
||||
} from './canvas/comment-reactions'
|
||||
export { CommentTool, commentToolOverrides, commentTools } from './canvas/comment-tool'
|
||||
export {
|
||||
getCommentReactions,
|
||||
getCommentRecord,
|
||||
getComments,
|
||||
getCommentThreads,
|
||||
getLiveComments,
|
||||
getLiveCommentThreads,
|
||||
type TLCommentRecord,
|
||||
} from './canvas/comment-store'
|
||||
export { type CommentingContext } from './canvas/context'
|
||||
export { CommentsFilterMenu, type CommentsFilterMenuProps } from './canvas/comments-filter-menu'
|
||||
export { CommentsMenuItem } from './canvas/comments-menu-item'
|
||||
export { CanvasComments, type CanvasCommentsProps } from './canvas/comments-overlay'
|
||||
export {
|
||||
type CommentingComponents,
|
||||
type CommentingOptions,
|
||||
type CommentModification,
|
||||
type CommentModificationContext,
|
||||
defaultCanModifyComment,
|
||||
defaultCommentingOptions,
|
||||
getCanComment,
|
||||
getCanModifyComment,
|
||||
getCommentingOptions,
|
||||
type ShapeCommentPrecisionContext,
|
||||
useCanComment,
|
||||
useCanModifyComment,
|
||||
useCommentingOptions,
|
||||
} from './canvas/options'
|
||||
export { CommentsOverflowMenu } from './canvas/comments-overflow-menu'
|
||||
export { CommentsVisibilityToggle } from './canvas/comments-visibility-toggle'
|
||||
export {
|
||||
CanvasCommentsSidebar,
|
||||
sortSidebarRows,
|
||||
type CanvasCommentsSidebarProps,
|
||||
type SidebarRow,
|
||||
} from './canvas/comments-sidebar'
|
||||
export { useComments, useCommentThreads, useThreadComments } from './canvas/hooks'
|
||||
export { useCommentingEnabled } from './canvas/license'
|
||||
export { richTextToPlaintext } from './canvas/rich-text'
|
||||
export { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './canvas/sidebar-filters'
|
||||
export {
|
||||
commentsHidden,
|
||||
commentsSidebarOpen,
|
||||
getRevealThreadPending,
|
||||
openThreadId,
|
||||
revealThread,
|
||||
sidebarFilters,
|
||||
toggleCommentsHidden,
|
||||
toggleCommentsSidebar,
|
||||
useCommentsHidden,
|
||||
useCommentsSidebarOpen,
|
||||
useOpenThreadId,
|
||||
useRevealThreadPending,
|
||||
useSidebarFilters,
|
||||
} from './canvas/state'
|
||||
export { anchorPagePoint, focusThread, shapeAnchorAt } from './canvas/thread-state'
|
||||
|
||||
registerTldrawLibraryVersion(
|
||||
(globalThis as any).TLDRAW_LIBRARY_NAME,
|
||||
(globalThis as any).TLDRAW_LIBRARY_VERSION,
|
||||
(globalThis as any).TLDRAW_LIBRARY_MODULES
|
||||
)
|
||||
35
packages/commenting/src/ui/byline.tsx
Normal file
35
packages/commenting/src/ui/byline.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Avatar, type CommentAuthor } from '@tldraw/mentions'
|
||||
import { TldrawUiTooltip, useCurrentTranslation } from 'tldraw'
|
||||
import { formatFullDateTime, formatRelativeTime } from './format-time'
|
||||
|
||||
/** @public */
|
||||
export interface BylineProps {
|
||||
author: CommentAuthor
|
||||
/** ISO datetime; formatted to relative time by the component. */
|
||||
date: string
|
||||
/** Shows an "edited" marker when the comment has been edited. */
|
||||
edited?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A comment's metadata line: author name, relative time, and an edited marker. Hovering the time
|
||||
* shows the full date and time in a tooltip.
|
||||
* @public @react
|
||||
*/
|
||||
export function Byline({ author, date, edited }: BylineProps) {
|
||||
// The relative time is the one piece of a byline that isn't the host's data, so it follows the
|
||||
// same translation context as every other string in the UI rather than a prop of its own.
|
||||
const { locale } = useCurrentTranslation()
|
||||
return (
|
||||
<div className="tlui-cmt-head">
|
||||
<Avatar author={author} />
|
||||
<span className="tlui-cmt-author">{author.name}</span>
|
||||
<TldrawUiTooltip content={formatFullDateTime(date, locale)}>
|
||||
<span className="tlui-cmt-time">
|
||||
{formatRelativeTime(date, locale)}
|
||||
{edited && <span className="tlui-cmt-edited"> · edited</span>}
|
||||
</span>
|
||||
</TldrawUiTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user