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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
import { Hit } from 'instantsearch.js'
import { getHighlightedParts, getPropertyByPath, unescape } from 'instantsearch.js/es/lib/utils'
import { memo } from 'react'
import { SearchEntry } from '@/utils/algolia'
export const ContentHighlight = memo(function ContentHighlight({ hit }: { hit: Hit<SearchEntry> }) {
const descriptionHighlight = getHighlights(hit, 'description', 40)
const contentHighlight = getHighlights(hit, 'content', 40)
let firstItem = null
let secondItem = null
if (descriptionHighlight.didMatch === contentHighlight.didMatch) {
// if both either matched or didn't, show whatever has actual content:
if (descriptionHighlight.parts.length && contentHighlight.parts.length) {
firstItem = descriptionHighlight
secondItem = contentHighlight
} else if (descriptionHighlight.parts.length) {
firstItem = descriptionHighlight
} else if (contentHighlight.parts.length) {
firstItem = contentHighlight
}
} else if (descriptionHighlight.didMatch && !contentHighlight.didMatch) {
// if only the description matched, show both because the description is short:
firstItem = descriptionHighlight
secondItem = contentHighlight
} else {
// otherwise, only the content matched. show just the content:
firstItem = contentHighlight
}
if (firstItem && !secondItem) {
// if we only have the first item, show it over two lines:
return (
<HighlightContent
didTruncateStart={firstItem.didTruncateStart}
parts={firstItem.parts}
className="line-clamp-2 text-sm"
/>
)
} else {
// otherwise, show both items on one line each:
return (
<>
{firstItem && (
<HighlightContent
didTruncateStart={firstItem.didTruncateStart}
parts={firstItem.parts}
className="line-clamp-1 text-sm"
/>
)}
{secondItem && (
<HighlightContent
didTruncateStart={secondItem.didTruncateStart}
parts={secondItem.parts}
className="line-clamp-1 text-sm"
/>
)}
</>
)
}
})
function HighlightContent({
parts,
didTruncateStart,
className,
}: {
parts: { value: string; isHighlighted: boolean }[]
didTruncateStart: boolean
className: string
}) {
return (
<div className={className}>
{didTruncateStart && '…'}
{parts.map((part, index) =>
part.isHighlighted ? <mark key={index}>{part.value}</mark> : part.value
)}
</div>
)
}
function getHighlights(
hit: Hit<SearchEntry>,
attribute: 'description' | 'content',
maxUnhighlightedPrefix: number
): {
didTruncateStart: boolean
didMatch: boolean
parts: { value: string; isHighlighted: boolean }[]
} {
const originalProperty = hit[attribute]?.trim()
const highlightedProperty = getPropertyByPath(hit._highlightResult, attribute as string)
if (!highlightedProperty) {
return {
didMatch: false,
didTruncateStart: false,
parts: originalProperty ? [{ value: originalProperty, isHighlighted: false }] : [],
}
}
const parts = getHighlightedParts(unescape(highlightedProperty.value || ''))
let didTruncateStart = false
const didMatch = parts.some((part) => part.isHighlighted)
const initialPart = parts[0]
// if the first part is unhighlighted and too long, we should cut it short
if (initialPart && !initialPart.isHighlighted && didMatch) {
const words = initialPart.value.split(' ').reverse()
let lengthSoFar = 0
const wordsToInclude: string[] = []
for (const word of words) {
lengthSoFar += word.length
if (lengthSoFar > maxUnhighlightedPrefix) {
break
}
wordsToInclude.push(word)
}
if (wordsToInclude.length < words.length) {
didTruncateStart = true
parts[0].value = `${wordsToInclude.reverse().join(' ')}`
}
}
return { didTruncateStart, didMatch, parts }
}

View File

@@ -0,0 +1,193 @@
'use client'
import { Combobox, ComboboxItem, ComboboxProvider } from '@ariakit/react'
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
import { debounce } from '@tldraw/utils'
import { Hit } from 'instantsearch.js'
import { SendEventForHits } from 'instantsearch.js/es/lib/utils'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { createRef, Fragment, useCallback, useEffect, useState } from 'react'
import { Configure, Highlight, InstantSearch, useHits, useSearchBox } from 'react-instantsearch'
import { twJoin } from 'tailwind-merge'
import { getSearchIndexName, SearchEntry } from '@/utils/algolia'
import { searchClient } from '@/utils/search-api'
import { ContentHighlight } from './ContentHighlight'
export function FullPageSearch({ indexName }: { indexName: 'docs' | 'blog' }) {
return (
<InstantSearch
indexName={getSearchIndexName(indexName)}
searchClient={searchClient}
insights={true}
future={{ preserveSharedStateOnUnmount: true }}
>
<Configure distinct={4} hitsPerPage={40} />
<InstantSearchInner indexName={indexName} />
</InstantSearch>
)
}
function InstantSearchInner({ indexName }: { indexName: 'docs' | 'blog' }) {
const { items, sendEvent } = useHits<SearchEntry>()
const { refine } = useSearchBox()
const router = useRouter()
const [open, setOpen] = useState(true)
const searchParams = useSearchParams()
const urlQuery = searchParams.get('query') || ''
const handleChange = (path: string) => {
setOpen(false)
router.push(path)
}
const updateSearchQuery = debounce((value: string) => refine(value), 500)
useEffect(() => {
refine(urlQuery)
}, [urlQuery, refine])
return (
open && (
<SearchAutocomplete
items={items}
onInputChange={updateSearchQuery}
onChange={handleChange}
sendEvent={sendEvent}
defaultValue={urlQuery}
indexName={indexName}
/>
)
)
}
interface AutocompleteProps {
items: Hit<SearchEntry>[]
onChange(value: string): void
onInputChange(value: string): void
defaultValue: string
sendEvent: SendEventForHits
indexName: 'docs' | 'blog'
}
function SearchAutocomplete({
items,
defaultValue,
onInputChange,
onChange,
sendEvent,
indexName,
}: AutocompleteProps) {
return (
<ComboboxProvider<string>
defaultSelectedValue=""
defaultValue={defaultValue}
includesBaseElement={true}
setValue={(newValue) => onInputChange(newValue)}
setSelectedValue={(newValue) => onChange(newValue)}
>
<div className="w-full mb-12 h-full">
<div className="pointer-events-auto min-h-full">
<SearchInput indexName={indexName} />
<Results items={items} sendEvent={sendEvent} />
</div>
</div>
</ComboboxProvider>
)
}
function SearchInput({ indexName }: { indexName: 'docs' | 'blog' }) {
const comboboxRef = createRef<HTMLInputElement>()
const router = useRouter()
const pathname = usePathname()
const capitalizedIndexName = indexName.charAt(0).toUpperCase() + indexName.slice(1)
const updateUrl = useCallback(() => {
const queryParam = comboboxRef.current?.value || ''
router.push(`${pathname}?query=${encodeURIComponent(queryParam.trim())}`)
}, [comboboxRef, router, pathname])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Enter') return
updateUrl()
},
[updateUrl]
)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
comboboxRef.current?.focus()
comboboxRef.current?.setSelectionRange(0, comboboxRef.current.value.length)
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [comboboxRef])
return (
<div className="md:sticky bg-white dark:bg-zinc-950 w-full md:top-4 flex items-center z-20 md:pb-4">
<div className="flex h-10 grow items-center gap-3 bg-zinc-50 dark:bg-zinc-900 rounded-lg px-4 focus-within:ring-blue-500 focus-within:ring-2 min-w-full">
<MagnifyingGlassIcon className="h-4 shrink-0 text-black dark:text-white" />
<Combobox
ref={comboboxRef}
className="h-full w-full mr-4 focus:outline-none text-black dark:text-white bg-transparent"
placeholder={`Search ${capitalizedIndexName}...`}
onKeyDown={handleKeyDown}
/>
</div>
</div>
)
}
function Results({ items, sendEvent }: { items: Hit<SearchEntry>[]; sendEvent: SendEventForHits }) {
let section = ''
const renderedItems = items.map((hit) => {
const showChapter = hit.section !== section
section = hit.section
const href = hit.headingSlug ? `${hit.path}#${hit.headingSlug}` : hit.path
return (
<Fragment key={hit.objectID}>
{showChapter && (
<div className="text-black dark:text-white font-semibold mt-6 mb-4">{section}</div>
)}
<ComboboxItem
className={twJoin(
'px-4 pt-2.5 pb-3 bg-zinc-100 dark:bg-zinc-800 mt-2',
'rounded-md cursor-pointer data-[active-item=true]:bg-blue-500',
'dark:data-[active-item=true]:bg-blue-500 data-[active-item=true]:text-blue-200',
'[&_mark]:bg-transparent [&_mark]:font-bold',
'[&_mark]:text-black [&_mark]:data-[active-item=true]:text-white',
'dark:[&_mark]:text-white',
'[&_.ais-Highlight-nonHighlighted]:data-[active-item=true]:text-white',
'hover:text-zinc-800 dark:hover:text-zinc-200'
)}
value={href}
onKeyDown={(e) => {
if (e.key === 'Enter') {
sendEvent('click', hit, 'Hit clicked via keyboard')
}
}}
>
<Link href={href} onClick={() => sendEvent('click', hit, 'Hit clicked')}>
<Highlight attribute="title" hit={hit} />
<ContentHighlight hit={hit} />
</Link>
</ComboboxItem>
</Fragment>
)
})
return items.length === 0 ? (
<div className="sticky top-[4.5rem] text-center py-8 text-zinc-400 dark:text-zinc-600">
No results found.
</div>
) : (
renderedItems
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import { MagnifyingGlassIcon as MagnifyingGlassIconSmall } from '@heroicons/react/20/solid'
import {
MagnifyingGlassIcon as MagnifyingGlassIconLarge,
XMarkIcon,
} from '@heroicons/react/24/solid'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { twJoin } from 'tailwind-merge'
import { cn } from '@/utils/cn'
import { Search } from '.'
function isEditableElement(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
return (
target.isContentEditable ||
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT'
)
}
export function SearchButton({
type,
layout,
className,
}: {
type: 'docs' | 'blog'
layout: 'mobile' | 'desktop' | 'keyboard-shortcut-only'
className?: string
}) {
const [open, setOpen] = useState(false)
const pathname = usePathname()
useEffect(() => {
if (layout !== 'desktop' && layout !== 'keyboard-shortcut-only') return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === '/') {
// A bare "/" is only a shortcut outside text fields, otherwise typing a path into
// the feedback textarea opens the dialog and steals focus mid-word.
if (isEditableElement(e.target)) return
e.preventDefault()
setOpen(true)
} else if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
setOpen((open) => !open)
} else if (e.key === 'Escape') {
setOpen(false)
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [layout])
useEffect(() => {
setOpen(false)
}, [pathname])
if (layout === 'keyboard-shortcut-only') {
return open ? <Search type={type} onClose={() => setOpen(false)} /> : null
}
return (
<div className={cn('group', className)}>
<button
className={cn(
'flex items-center text-black dark:text-white focus:outline-none',
layout === 'desktop' &&
twJoin(
'w-full h-10 justify-between bg-zinc-50 dark:bg-zinc-900 px-4 cursor-text',
'hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg focus:ring-2',
'focus:ring-blue-500'
),
layout === 'mobile' &&
'w-8 h-8 justify-center rounded focus:bg-zinc-100 dark:focus:bg-zinc-800'
)}
onClick={() => setOpen((open) => !open)}
>
<div className="flex items-center gap-3">
{open ? (
<XMarkIcon className="h-6 md:hidden" />
) : (
<>
<MagnifyingGlassIconSmall className={cn('h-4', layout === 'mobile' && 'hidden')} />
<MagnifyingGlassIconLarge className={cn('h-6', layout === 'desktop' && 'hidden')} />
</>
)}
{!open && (
<span
className={cn(
'capitalize text-sm text-zinc-400 dark:text-zinc-600',
layout === 'mobile' && 'hidden'
)}
>
Search {type}…
</span>
)}
</div>
<span className={cn('text-xs', layout === 'mobile' && 'hidden')}>⌘K</span>
</button>
{open && <Search type={type} onClose={() => setOpen(false)} />}
</div>
)
}

View File

@@ -0,0 +1,192 @@
import { Combobox, ComboboxItem, ComboboxProvider, VisuallyHidden } from '@ariakit/react'
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
import { Hit } from 'instantsearch.js'
import { SendEventForHits } from 'instantsearch.js/es/lib/utils'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { Dialog as _Dialog } from 'radix-ui'
import { Fragment, startTransition, useState } from 'react'
import { Highlight } from 'react-instantsearch'
import { twJoin } from 'tailwind-merge'
import { SearchEntry } from '@/utils/algolia'
import { ContentHighlight } from './ContentHighlight'
interface AutocompleteProps {
items: Hit<SearchEntry>[]
onChange(value: string): void
onInputChange(value: string): void
onClose(): void
sendEvent: SendEventForHits
}
export default function SearchDialog({
items,
onInputChange,
onChange,
onClose,
sendEvent,
}: AutocompleteProps) {
const [open, setOpen] = useState(true)
const [value, setValue] = useState('')
const handleOpenChange = (open: boolean) => {
if (!open) {
onClose()
}
setOpen(open)
}
return (
<ComboboxProvider<string>
defaultSelectedValue=""
open={open}
setOpen={setOpen}
resetValueOnHide
includesBaseElement={false}
setValue={(newValue) => {
startTransition(() => {
setValue(newValue)
onInputChange(newValue)
})
}}
setSelectedValue={(newValue) => onChange(newValue)}
>
<div
className={twJoin(
'fixed w-screen h-screen left-0 top-14 sm:top-[6.5rem] md:top-0 z-40',
'bg-white/90 dark:bg-zinc-950/90 pointer-events-none'
)}
>
<SearchDialogWrapper onOpenChange={handleOpenChange}>
<div className="w-full max-w-3xl mx-auto px-5 lg:px-12 pt-[4.5rem]">
<div
className={twJoin(
'pointer-events-auto bg-zinc-50 dark:bg-zinc-900 rounded-lg',
'overflow-hidden focus-within:ring-2 focus-within:ring-blue-500'
)}
>
<SearchInput value={value} />
<Results items={items} sendEvent={sendEvent} />
</div>
</div>
</SearchDialogWrapper>
</div>
</ComboboxProvider>
)
}
function SearchInput({ value }: { value: string }) {
const router = useRouter()
const pathName = usePathname()
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Enter') return
const searchPath = pathName.startsWith('/blog') ? '/search/blog' : '/search'
router.push(`${searchPath}?query=${encodeURIComponent(value.trim())}`)
}
return (
<div className="w-full h-10 flex items-center px-4">
<div className="flex h-full grow items-center gap-3">
<MagnifyingGlassIcon className="h-4 shrink-0" />
<Combobox
className="h-full w-full mr-4 focus:outline-none text-black dark:text-white bg-transparent"
autoFocus
placeholder="Search..."
onKeyDown={handleKeyDown}
/>
</div>
</div>
)
}
function Results({ items, sendEvent }: { items: Hit<SearchEntry>[]; sendEvent: SendEventForHits }) {
let section = ''
const renderedItems = items.map((hit) => {
const showChapter = hit.section !== section
section = hit.section
const href = hit.headingSlug ? `${hit.path}#${hit.headingSlug}` : hit.path
return (
<Fragment key={hit.objectID}>
{showChapter && (
<div className="text-black dark:text-white font-semibold mt-6 mb-4">{section}</div>
)}
<ComboboxItem
className={twJoin(
'px-4 pt-2.5 pb-3 bg-zinc-100 dark:bg-zinc-800 mt-2',
'rounded-md cursor-pointer data-[active-item=true]:bg-blue-500',
'dark:data-[active-item=true]:bg-blue-500 data-[active-item=true]:text-blue-200',
'[&_mark]:bg-transparent [&_mark]:font-bold',
'[&_mark]:text-black [&_mark]:data-[active-item=true]:text-white',
'dark:[&_mark]:text-white',
'[&_.ais-Highlight-nonHighlighted]:data-[active-item=true]:text-white',
'hover:text-zinc-800 dark:hover:text-zinc-200'
)}
value={href}
onKeyDown={(e) => {
if (e.key === 'Enter') {
sendEvent('click', hit, 'Hit clicked via keyboard')
}
}}
>
<Link href={href} onClick={() => sendEvent('click', hit, 'Hit clicked')}>
<Highlight attribute="title" hit={hit} />
<ContentHighlight hit={hit} />
</Link>
</ComboboxItem>
</Fragment>
)
})
return (
<div
className={twJoin(
'max-h-[32rem] overflow-y-auto p-4 pt-0',
'border-t border-zinc-100 dark:border-zinc-800'
)}
>
{items.length === 0 && (
<div className="text-center py-8 text-zinc-400 dark:text-zinc-600">
<p>No results found.</p>
<p className="mt-2">
Will you tell us what you{"'"}re looking for on our{' '}
<a
className="text-blue-500 hover:text-blue-600"
target="_blank"
href="https://discord.tldraw.com/?utm_source=blog&utm_medium=search&utm_campaign=no-results-found"
rel="noreferrer"
>
discord
</a>
?
</p>
</div>
)}
{items.length !== 0 && renderedItems}
</div>
)
}
function SearchDialogWrapper({
children,
onOpenChange,
}: {
children: React.ReactNode
onOpenChange(open: boolean): void
}) {
return (
<_Dialog.Root open onOpenChange={onOpenChange}>
<_Dialog.Portal>
<_Dialog.Overlay />
<_Dialog.Content className="fixed inset-0 z-50" style={{ pointerEvents: 'none' }}>
<VisuallyHidden>
<_Dialog.Title>Search</_Dialog.Title>
<_Dialog.Description>Search dialog</_Dialog.Description>
</VisuallyHidden>
{children}
</_Dialog.Content>
</_Dialog.Portal>
</_Dialog.Root>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import { debounce } from '@tldraw/utils'
import { useRouter } from 'next/navigation'
import { InstantSearch, useHits, useSearchBox } from 'react-instantsearch'
import { SearchEntry, SearchIndexName, getSearchIndexName } from '@/utils/algolia'
import { searchClient } from '@/utils/search-api'
import SearchDialog from './SearchDialog'
export function Search({ type, onClose }: { type: SearchIndexName; onClose(): void }) {
return (
<InstantSearch
indexName={getSearchIndexName(type)}
searchClient={searchClient}
insights={true}
future={{ preserveSharedStateOnUnmount: true }}
>
<InstantSearchInner onClose={onClose} />
</InstantSearch>
)
}
function InstantSearchInner({ onClose }: { onClose(): void }) {
const { items, sendEvent } = useHits<SearchEntry>()
const { refine } = useSearchBox()
const router = useRouter()
const handleChange = (path: string) => {
router.push(path)
onClose()
}
const handleInputChange = debounce((query: string) => refine(query), 500)
return (
<SearchDialog
items={items}
onInputChange={handleInputChange}
onChange={handleChange}
onClose={onClose}
sendEvent={sendEvent}
/>
)
}