Compare commits

...

1 Commits

Author SHA1 Message Date
Nolann Biron 5679657cbb Support cards image definition 2025-07-28 13:06:41 +02:00
4 changed files with 364 additions and 124 deletions
@@ -1,18 +1,21 @@
import {
type ContentRef,
type DocumentTableViewCards,
type RevisionFile,
SiteInsightsLinkPosition,
type TableRecordValueImage,
} from '@gitbook/api';
import { LinkBox, LinkOverlay } from '@/components/primitives';
import { Image } from '@/components/utils';
import { resolveContentRef } from '@/lib/references';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import type { DocumentContext } from '@/components/DocumentView/DocumentView';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
import { RecordCardStyles } from './styles';
import { getRecordValue } from './utils';
import { getRecordValue, isImageDefition } from './utils';
export async function RecordCard(
props: TableViewProps<DocumentTableViewCards> & {
@@ -21,25 +24,26 @@ export async function RecordCard(
) {
const { view, record, context, block, isOffscreen } = props;
const coverFile = view.coverDefinition
? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0]
const coverRef = view.coverDefinition
? getRecordValue<string[] | TableRecordValueImage>(record[1], view.coverDefinition)
: null;
const cover = coverRef
? await getRecordCover({
value: coverRef,
context,
})
: null;
const targetRef = view.targetDefinition
? (record[1].values[view.targetDefinition] as ContentRef)
: null;
const [cover, target] = await Promise.all([
coverFile && context.contentContext
? resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext)
: null,
const target =
targetRef && context.contentContext
? resolveContentRef(targetRef, context.contentContext)
: null,
]);
? await resolveContentRef(targetRef, context.contentContext)
: null;
const coverIsSquareOrPortrait =
cover?.file?.dimensions &&
cover.file?.dimensions?.width / cover.file?.dimensions?.height <= 1;
const coverIsSquareOrPortrait = cover ? getCoverIsSquareOrPortrait(cover) : null;
const body = (
<div
@@ -48,9 +52,9 @@ export async function RecordCard(
'relative',
'grid',
'bg-tint-base',
'w-[calc(100%+2px)]',
'h-[calc(100%+2px)]',
'inset-[-1px]',
'w-full',
'h-full',
'inset-0',
'rounded',
'straight-corners:rounded-none',
'circular-corners:rounded-xl',
@@ -59,17 +63,21 @@ export async function RecordCard(
'[&_.heading>div]:text-[.8em]',
'md:[&_.heading>div]:text-[1em]',
'[&_.blocks:first-child_.heading]:pt-0', // Remove padding-top on first heading in card
'min-h-10',
// On mobile, check if we can display the cover responsively or not:
// - If the file has a landscape aspect ratio, we display it normally
// - If the file is square or portrait, we display it left with 40% of the card width
coverIsSquareOrPortrait
? [
'grid-cols-[40%,_1fr]',
'min-[432px]:grid-cols-none',
'min-[432px]:grid-rows-[auto,1fr]',
]
: 'grid-rows-[auto,1fr]'
// Only create rows when there are columns to display
view.columns.length > 0
? coverIsSquareOrPortrait
? [
'grid-cols-[40%,_1fr]',
'min-[432px]:grid-cols-none',
'min-[432px]:grid-rows-[auto,1fr]',
]
: 'grid-rows-[auto,1fr]'
: null
)}
>
{cover ? (
@@ -77,9 +85,17 @@ export async function RecordCard(
alt="Cover"
sources={{
light: {
src: cover.href,
size: cover.file?.dimensions,
src: cover.light.href,
size: cover.light.file?.dimensions,
},
...(cover.dark
? {
dark: {
src: cover.dark.href,
size: cover.dark.file?.dimensions,
},
}
: {}),
}}
sizes={[
{
@@ -88,59 +104,64 @@ export async function RecordCard(
]}
resize={context.contentContext?.imageResizer}
className={tcls(
'rounded-none',
'min-w-0',
'w-full',
'h-full',
'object-cover',
coverIsSquareOrPortrait
? ['min-[432px]:h-auto', 'min-[432px]:aspect-video']
? ['min-[432px]:aspect-video']
: ['h-auto', 'aspect-video']
)}
priority={isOffscreen ? 'lazy' : 'high'}
preload
/>
) : null}
<div
className={tcls(
'min-w-0',
'w-full',
'flex',
'flex-col',
'place-self-start',
'gap-3',
'p-4',
'text-sm',
target
? ['transition-colors', 'text-tint', 'group-hover:text-tint-strong']
: ['text-tint-strong']
)}
>
{view.columns.map((column) => {
const definition = block.data.definition[column];
{view.columns.length > 0 ? (
<div
className={tcls(
'min-w-0',
'w-full',
'flex',
'flex-col',
'place-self-start',
'gap-3',
'p-4',
'text-sm',
target
? ['transition-colors', 'text-tint', 'group-hover:text-tint-strong']
: ['text-tint-strong']
)}
>
{view.columns.map((column) => {
const definition = block.data.definition[column];
if (!definition) {
return null;
}
if (!definition) {
return null;
}
if (!view.hideColumnTitle && definition.title) {
const ariaLabelledBy = `${block.key}-${column}-title`;
return (
<div key={column} className="flex flex-col gap-1">
<div id={ariaLabelledBy} className="text-sm text-tint">
{definition.title}
if (!view.hideColumnTitle && definition.title) {
const ariaLabelledBy = `${block.key}-${column}-title`;
return (
<div key={column} className="flex flex-col gap-1">
<div id={ariaLabelledBy} className="text-sm text-tint">
{definition.title}
</div>
<RecordColumnValue
{...props}
column={column}
ariaLabelledBy={ariaLabelledBy}
/>
</div>
<RecordColumnValue
{...props}
column={column}
ariaLabelledBy={ariaLabelledBy}
/>
</div>
);
}
);
}
return <RecordColumnValue key={column} {...props} column={column} />;
})}
</div>
console.log(column, definition);
return <RecordColumnValue key={column} {...props} column={column} />;
})}
</div>
) : null}
</div>
);
@@ -149,7 +170,12 @@ export async function RecordCard(
// We don't use `Link` directly here because we could end up in a situation where
// a link is rendered inside a link, which is not allowed in HTML.
// It causes an hydration error in React.
<LinkBox href={target.href} classNames={['RecordCardStyles']}>
<LinkBox
data-hoverable={true}
href={target.href}
classNames={['RecordCardStyles']}
className="data-[hoverable=true]:hover:border-tint-12/5"
>
<LinkOverlay
href={target.href}
insights={{
@@ -167,3 +193,73 @@ export async function RecordCard(
return <div className={tcls(RecordCardStyles)}>{body}</div>;
}
/**
* Extract the cover from the record.
* It can be an image or a file definition.
*/
async function getRecordCover(props: {
value: TableRecordValueImage | string[];
context: DocumentContext;
}): Promise<{
light: ResolvedContentRef;
dark?: ResolvedContentRef | null;
} | null> {
const { value, context } = props;
if (!context.contentContext) {
return null;
}
if (Array.isArray(value) || typeof value === 'string') {
const resolved = await resolveContentRef(
{ kind: 'file', file: Array.isArray(value) ? value[0] : value },
context.contentContext
);
if (!resolved) {
return null;
}
return {
light: resolved,
};
}
// If the cover is an image, resolve the light and dark images
if (isImageDefition(value) && context.contentContext) {
const [light, dark] = await Promise.all([
resolveContentRef(value.src, context.contentContext),
value.srcDark ? resolveContentRef(value.srcDark, context.contentContext) : undefined,
]);
// If the light image is not resolved, we can't display the cover
if (!light) {
return null;
}
return {
light,
dark,
};
}
return null;
}
/**
* Check if the cover is square or portrait.
*/
function getCoverIsSquareOrPortrait(cover: {
light: ResolvedContentRef;
dark?: ResolvedContentRef | null;
}) {
const isLightSquareOrPortrait = (file: RevisionFile) => {
return file?.dimensions && file.dimensions.width / file.dimensions.height <= 1;
};
return {
light: cover.light.file ? isLightSquareOrPortrait(cover.light.file) : false,
dark: cover.dark?.file ? isLightSquareOrPortrait(cover.dark.file) : false,
};
}
@@ -3,24 +3,25 @@ import {
type ContentRefUser,
type DocumentBlockTable,
SiteInsightsLinkPosition,
type TableRecordValueImage,
} from '@gitbook/api';
import { Icon, IconStyle } from '@gitbook/icons';
import assertNever from 'assert-never';
import type { DocumentContext } from '@/components/DocumentView/DocumentView';
import { Checkbox } from '@/components/primitives';
import { StyledLink } from '@/components/primitives';
import { Image } from '@/components/utils';
import { getNodeFragmentByName } from '@/lib/document';
import { getSimplifiedContentType } from '@/lib/files';
import { resolveContentRef } from '@/lib/references';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { filterOutNullable } from '@/lib/typescript';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { FileIcon } from '../FileIcon';
import type { TableRecordKV } from './Table';
import { type VerticalAlignment, getColumnAlignment } from './utils';
import { type VerticalAlignment, getColumnAlignment, resolveTableImageValue } from './utils';
const alignmentMap: Record<'text-left' | 'text-center' | 'text-right', string> = {
'text-left': '[&_*]:text-left text-left',
@@ -166,57 +167,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
return (
<Tag className={tcls('text-base')} aria-labelledby={ariaLabelledBy}>
{files.filter(filterOutNullable).map((ref, index) => {
const contentType = ref.file
? getSimplifiedContentType(ref.file.contentType)
: null;
return (
<StyledLink
key={index}
href={ref.href}
target="_blank"
className="flex flex-row items-center gap-2"
insights={
ref.file
? {
type: 'link_click',
link: {
target: {
kind: 'file',
file: ref.file.id,
},
position: SiteInsightsLinkPosition.Content,
},
}
: undefined
}
>
{contentType === 'image' ? (
<Image
style={['max-h-[1lh]', 'h-[1lh]']}
alt={ref.text}
sizes={[{ width: 24 }]}
resize={context.contentContext?.imageResizer}
sources={{
light: {
src: ref.href,
size: {
width: 24,
height: 24,
},
},
}}
priority="lazy"
/>
) : (
<FileIcon
contentType={contentType}
className={tcls('size-4')}
/>
)}
{ref.text}
</StyledLink>
);
return <FileItem key={index} resolvedRef={ref} context={context} />;
})}
</Tag>
);
@@ -329,7 +280,152 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
</Tag>
);
}
case 'image': {
const resolved = await resolveTableImageValue({
value: value as TableRecordValueImage,
context,
});
if (!resolved) {
return null;
}
return (
<Tag className={tcls('text-base')} aria-labelledby={ariaLabelledBy}>
{resolved.light.file ? (
<>
<FileItem
resolvedRef={resolved.light}
context={context}
className={tcls('block', resolved.dark ? 'dark:hidden' : '')}
/>
{resolved.dark ? (
<FileItem
resolvedRef={resolved.dark}
context={context}
className={tcls('hidden', 'dark:block')}
/>
) : null}
</>
) : (
<StyledLink
href={resolved.light.href}
target="_blank"
className={tcls(
'flex',
'flex-row',
'items-center',
'gap-2',
'truncate',
'text-sm'
)}
>
<Image
style={['max-h-[1lh]', 'h-[1lh]', 'aspect-auto', 'object-cover']}
alt={resolved.light.href}
sizes={[{ width: 24 }]}
resize={context.contentContext?.imageResizer}
sources={{
light: {
src: resolved.light.href,
size: { width: 24, height: 24 },
},
...(resolved.dark
? {
dark: {
src: resolved.dark.href,
size: { width: 24, height: 24 },
},
}
: {}),
}}
/>
<span className="truncate">
<span
className={tcls(
'contents truncate',
resolved.dark ? 'dark:hidden' : ''
)}
>
{resolved.light.href}
</span>
{resolved.dark ? (
<span className={tcls('hidden truncate', 'dark:contents')}>
{resolved.dark?.href}
</span>
) : null}
</span>
</StyledLink>
)}
</Tag>
);
}
default:
assertNever(definition);
}
}
function FileItem(props: {
resolvedRef: ResolvedContentRef;
context: DocumentContext;
className?: string;
}) {
const { resolvedRef, context, className } = props;
const contentType = resolvedRef.file
? getSimplifiedContentType(resolvedRef.file.contentType)
: null;
return (
<StyledLink
href={resolvedRef.href}
target="_blank"
className={tcls(
'flex',
'flex-row',
'text-sm',
'items-center',
'gap-2',
'truncate',
className
)}
insights={
resolvedRef.file
? {
type: 'link_click',
link: {
target: {
kind: 'file',
file: resolvedRef.file.id,
},
position: SiteInsightsLinkPosition.Content,
},
}
: undefined
}
>
{contentType === 'image' ? (
<Image
style={['max-h-[1lh]', 'h-[1lh]', 'aspect-auto', 'object-cover']}
alt={resolvedRef.text}
sizes={[{ width: 24 }]}
resize={context.contentContext?.imageResizer}
sources={{
light: {
src: resolvedRef.href,
size: {
width: 24,
height: 24,
},
},
}}
priority="lazy"
/>
) : (
<FileIcon contentType={contentType} className={tcls('size-4', 'object-cover')} />
)}
<span className="truncate">{resolvedRef.text}</span>
</StyledLink>
);
}
@@ -7,20 +7,24 @@ export const RecordCardStyles = [
'shadow-tint-9/1',
'depth-flat:shadow-none',
'rounded',
'transition-all',
'straight-corners:rounded-none',
'circular-corners:rounded-xl',
'dark:shadow-transparent',
'border',
'border-solid',
'border-tint-12/2',
'before:pointer-events-none',
'before:grid-area-1-1',
'before:transition-shadow',
'before:w-full',
'before:h-full',
'before:rounded-[inherit]',
'before:ring-1',
'before:ring-tint-12/2',
'before:z-10',
'before:relative',
'hover:before:ring-tint-12/5',
'data-[hoverable=true]:hover:border-tint-12/5',
'data-[hoverable=true]:hover:-translate-y-1',
] as ClassValue;
@@ -1,10 +1,16 @@
import type { ContentRef, DocumentTableDefinition, DocumentTableRecord } from '@gitbook/api';
import type { DocumentContext } from '@/components/DocumentView/DocumentView';
import { resolveContentRef } from '@/lib/references';
import type {
DocumentTableDefinition,
DocumentTableRecord,
TableRecordValueImage,
} from '@gitbook/api';
import assertNever from 'assert-never';
/**
* Get the value for a column in a record.
*/
export function getRecordValue<T extends number | string | boolean | string[] | ContentRef>(
export function getRecordValue<T extends DocumentTableRecord['values'][string]>(
record: DocumentTableRecord,
definitionId: string
): T {
@@ -52,3 +58,41 @@ export function getColumnVerticalAlignment(column: DocumentTableDefinition): Ver
return 'self-center';
}
/**
* Check if a column definition is an image.
*/
export function isImageDefition(
record: DocumentTableRecord['values'][string]
): record is TableRecordValueImage {
return !!record && typeof record === 'object' && 'src' in record;
}
/**
* Get the image value for a column.
*/
export async function resolveTableImageValue(props: {
value: TableRecordValueImage | string[];
context: DocumentContext;
}) {
const { value, context } = props;
// If the cover is an image, resolve the light and dark images
if (isImageDefition(value) && context.contentContext) {
const [light, dark] = await Promise.all([
resolveContentRef(value.src, context.contentContext),
value.srcDark ? resolveContentRef(value.srcDark, context.contentContext) : undefined,
]);
// If the light image is not resolved, we can't display the cover
if (!light) {
return null;
}
return {
light,
dark,
};
}
}