mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 07:35:16 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5679657cbb |
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user