Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/web/src/components/block-note/collection-mention.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createReactInlineContentSpec } from '@blocknote/react'
import { IconFolders } from '@tabler/icons-react'

import { Badge } from '@altstack/ui/components/badge'

import { collectionSlugFromPath } from '#/features/public-content/model'

export const CollectionMentionInline = createReactInlineContentSpec(
{
type: 'collectionMention',
propSchema: { slug: { default: '' }, name: { default: '' } },
content: 'none',
},
{
render: ({ inlineContent }) => (
<Badge
variant="secondary"
className="h-auto max-w-full gap-1 px-2 py-1 align-middle"
data-collection-mention={inlineContent.props.slug}
title={`/collections/${inlineContent.props.slug}`}
>
<IconFolders aria-hidden="true" />
<span className="truncate">{inlineContent.props.name}</span>
</Badge>
),
toExternalHTML: ({ inlineContent }) => (
<a href={`/collections/${encodeURIComponent(inlineContent.props.slug)}`}>
{inlineContent.props.name}
</a>
),
parse: (element) => {
if (element.tagName !== 'A' || element.querySelector('img')) return
const slug = collectionSlugFromPath(element.getAttribute('href') ?? '')
if (!slug) return
return { slug, name: element.textContent.trim() || slug }
},
runsBefore: ['link'],
}
)
157 changes: 105 additions & 52 deletions apps/web/src/components/block-note/project-mention-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ import type {
DefaultReactSuggestionItem,
SuggestionMenuProps,
} from '@blocknote/react'
import { IconAt } from '@tabler/icons-react'
import { IconAt, IconFolders } from '@tabler/icons-react'
import { useQueryClient } from '@tanstack/react-query'
import { createElement, useCallback, useEffect, useRef } from 'react'

import { toast } from '@altstack/ui/components/toast'

import type { ContentEditor } from '#/components/block-note/schema'
import { ProjectLogo } from '#/components/project-media'
import { projectORPC } from '#/utils/orpc'
import { orpc, projectORPC } from '#/utils/orpc'

function ProjectSuggestions(
function MentionSuggestions(
props: SuggestionMenuProps<DefaultReactSuggestionItem>
) {
const components = useComponentsContext()
Expand All @@ -35,28 +35,35 @@ function ProjectSuggestions(
? createElement(
Menu.EmptyItem,
{ className: 'bn-suggestion-menu-item' },
'No projects found'
'No projects or collections found'
)
: props.items.map((item, index) =>
: props.items.flatMap((item, index) => [
...(item.group !== props.items[index - 1]?.group
? [
createElement(
Menu.Label,
{
key: item.group,
className: 'bn-suggestion-menu-label',
},
item.group
),
]
: []),
createElement(Menu.Item, {
key: item.subtext ?? item.title,
id: `bn-suggestion-menu-item-${index}`,
className: 'bn-suggestion-menu-item',
item,
isSelected: index === props.selectedIndex,
onClick: () => props.onItemClick?.(item),
})
)
}),
])
// These vendor components reject extra props, including the source
// attributes our devtools inject into JSX. Keep their props explicit.
return createElement(
Menu.Root,
{ id: 'bn-suggestion-menu', className: 'bn-suggestion-menu' },
createElement(
Menu.Label,
{ className: 'bn-suggestion-menu-label' },
'Projects'
),
items
)
}
Expand Down Expand Up @@ -92,53 +99,99 @@ export function ProjectMentionMenus({ editor }: { editor: ContentEditor }) {
},
[]
)
const getProjects = useCallback(
const getMentions = useCallback(
async (query: string): Promise<Array<DefaultReactSuggestionItem>> => {
const current = ++request.current
resolvedQuery.current = null
await new Promise((resolve) => setTimeout(resolve, 200))
if (current !== request.current) return []
try {
const { projects } = await queryClient.query({
...projectORPC.mentions.queryOptions({
input: { query: { q: query.trim().slice(0, 100) } },
const input = { query: { q: query.trim().slice(0, 100) } }
const [projectResult, collectionResult] = await Promise.allSettled([
queryClient.query({
...projectORPC.mentions.queryOptions({
input,
}),
staleTime: 30_000,
retry: false,
}),
staleTime: 30_000,
retry: false,
})
if (current === request.current) resolvedQuery.current = query
return projects.map((project) => {
return {
title: project.name,
subtext: project.tagline
? `/${project.slug} · ${project.tagline}`
: `/${project.slug}`,
group: 'Projects',
icon: (
<ProjectLogo
src={project.logo}
name={project.name}
className="size-6"
/>
),
onItemClick: () => {
editor.insertInlineContent([
{
type: 'projectMention',
props: { slug: project.slug, name: project.name },
},
' ',
])
},
}
})
queryClient.query({
...orpc.collection.mentions.queryOptions({ input }),
staleTime: 30_000,
retry: false,
}),
])
if (current !== request.current) return []
resolvedQuery.current = query
if (
projectResult.status === 'rejected' ||
collectionResult.status === 'rejected'
) {
toast.add({
type: 'error',
title: 'Some mentions could not be loaded',
description: 'Try typing the project or collection name again.',
})
}
const projects =
projectResult.status === 'fulfilled'
? projectResult.value.projects
: []
const collections =
collectionResult.status === 'fulfilled'
? collectionResult.value.collections
: []
return [
...projects.map((project) => {
return {
title: project.name,
subtext: project.tagline
? `/${project.slug} · ${project.tagline}`
: `/${project.slug}`,
group: 'Projects',
icon: (
<ProjectLogo
src={project.logo}
name={project.name}
className="size-6"
/>
),
onItemClick: () => {
editor.insertInlineContent([
{
type: 'projectMention',
props: { slug: project.slug, name: project.name },
},
' ',
])
},
}
}),
...collections.map((collection) => {
return {
title: collection.name,
subtext: `/collections/${collection.slug} · ${collection.description}`,
group: 'Collections',
icon: <IconFolders aria-hidden="true" />,
onItemClick: () => {
editor.insertInlineContent([
{
type: 'collectionMention',
props: { slug: collection.slug, name: collection.name },
},
' ',
])
},
}
}),
]
} catch {
if (current === request.current) {
resolvedQuery.current = query
toast.add({
type: 'error',
title: 'Could not load projects',
description: 'Try typing the project name again.',
title: 'Could not load mentions',
description: 'Try typing the project or collection name again.',
})
}
return []
Expand All @@ -151,8 +204,8 @@ export function ProjectMentionMenus({ editor }: { editor: ContentEditor }) {
<>
<SuggestionMenuController
triggerCharacter="@"
getItems={getProjects}
suggestionMenuComponent={ProjectSuggestions}
getItems={getMentions}
suggestionMenuComponent={MentionSuggestions}
/>
<SuggestionMenuController
triggerCharacter="/"
Expand All @@ -162,9 +215,9 @@ export function ProjectMentionMenus({ editor }: { editor: ContentEditor }) {
[
...getDefaultReactSlashMenuItems(editor),
{
title: 'Project mention',
subtext: 'Find and mention a listed project',
aliases: ['mention', 'project', 'reference'],
title: 'Project or collection mention',
subtext: 'Find and mention a listed project or collection',
aliases: ['mention', 'project', 'collection', 'reference'],
group: 'References',
icon: <IconAt />,
onItemClick: () => {
Expand Down
30 changes: 16 additions & 14 deletions apps/web/src/components/block-note/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,23 @@ import {
DEFAULT_CODE_BLOCK_LANGUAGE,
SUPPORTED_CODE_BLOCK_LANGUAGES,
} from '#/components/block-note/code-block'
import { CollectionMentionInline } from '#/components/block-note/collection-mention'
import { ProjectMentionInline } from '#/components/block-note/project-mention'
import { projectSlugFromPath } from '#/features/public-content/model'
import {
projectSlugFromPath,
collectionSlugFromPath,
} from '#/features/public-content/model'
import {
CODE_BLOCK_SHIKI_THEME,
createHighlighter,
withFontStyleHtmlStyles,
} from '#/utils/shiki.bundle'

export const blockNoteSchema = BlockNoteSchema.create().extend({
inlineContentSpecs: { projectMention: ProjectMentionInline },
inlineContentSpecs: {
projectMention: ProjectMentionInline,
collectionMention: CollectionMentionInline,
},
blockSpecs: {
codeBlock: BNCustomCodeBlock({
indentLineWithTab: true,
Expand Down Expand Up @@ -53,15 +60,14 @@ type ContentInline = InlineContent<
typeof blockNoteSchema.styleSchema
>

function restoreProjectMentions(
content: Array<ContentInline>
): Array<ContentInline> {
function restoreMentions(content: Array<ContentInline>): Array<ContentInline> {
return content.map((inline) => {
if (inline.type !== 'link') return inline
const slug = projectSlugFromPath(inline.href)
const collectionSlug = collectionSlugFromPath(inline.href)
const slug = collectionSlug ?? projectSlugFromPath(inline.href)
if (!slug) return inline
return {
type: 'projectMention',
type: collectionSlug ? 'collectionMention' : 'projectMention',
content: undefined,
props: {
slug,
Expand All @@ -72,22 +78,18 @@ function restoreProjectMentions(
}

// BlockNote's Markdown parser returns built-in links rather than custom inline
// content. Restore project atoms in every editable location after parsing.
// content. Restore mention atoms in every editable location after parsing.
export function parseContentMarkdown(editor: ContentEditor, markdown: string) {
const blocks = editor.tryParseMarkdownToBlocks(markdown)
const restore = (items: typeof blocks) => {
for (const block of items) {
if (Array.isArray(block.content)) {
block.content = restoreProjectMentions(block.content)
block.content = restoreMentions(block.content)
} else if (block.type === 'table') {
for (const row of block.content.rows) {
for (const cell of row.cells) {
const content = Array.isArray(cell) ? cell : cell.content
content.splice(
0,
content.length,
...restoreProjectMentions(content)
)
content.splice(0, content.length, ...restoreMentions(content))
}
}
}
Expand Down
Loading
Loading