Files
AppFlowy-Web/src/components/view-meta/ViewMetaPreview.tsx
2025-08-27 13:53:56 +08:00

297 lines
8.7 KiB
TypeScript

import React, { lazy, Suspense, useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CoverType, ViewIconType, ViewLayout, ViewMetaCover, ViewMetaIcon, ViewMetaProps } from '@/application/types';
import TitleEditable from '@/components/view-meta/TitleEditable';
import ViewCover from '@/components/view-meta/ViewCover';
import { CustomIconPopover } from '@/components/_shared/cutsom-icon';
import { notify } from '@/components/_shared/notify';
import PageIcon from '@/components/_shared/view-icon/PageIcon';
const AddIconCover = lazy(() => import('@/components/view-meta/AddIconCover'));
export function ViewMetaPreview({
icon: iconProp,
cover: coverProp,
name,
extra,
readOnly = true,
viewId,
updatePage,
onEnter,
maxWidth,
uploadFile,
layout,
onFocus,
updatePageIcon,
updatePageName,
}: ViewMetaProps) {
const [cover, setCover] = React.useState<ViewMetaCover | null>(coverProp || null);
const [icon, setIcon] = React.useState<ViewMetaIcon | null>(iconProp || null);
// Debug logging for TitleEditable visibility issues
console.log('[ViewMetaPreview] Props:', {
viewId,
readOnly,
name,
hasUpdatePageName: !!updatePageName,
timestamp: Date.now()
});
useEffect(() => {
setCover(coverProp || null);
}, [coverProp]);
useEffect(() => {
setIcon(iconProp || null);
}, [iconProp]);
const coverType = useMemo(() => {
if (cover && [CoverType.NormalColor, CoverType.GradientColor].includes(cover.type)) {
return 'color';
}
if (CoverType.BuildInImage === cover?.type) {
return 'built_in';
}
if (cover && [CoverType.CustomImage, CoverType.UpsplashImage].includes(cover.type)) {
return 'custom';
}
}, [cover]);
const coverValue = useMemo(() => {
if (coverType === CoverType.BuildInImage) {
return {
1: '/covers/m_cover_image_1.png',
2: '/covers/m_cover_image_2.png',
3: '/covers/m_cover_image_3.png',
4: '/covers/m_cover_image_4.png',
5: '/covers/m_cover_image_5.png',
6: '/covers/m_cover_image_6.png',
}[cover?.value as string];
}
return cover?.value;
}, [coverType, cover?.value]);
const { t } = useTranslation();
const [isHover, setIsHover] = React.useState(false);
const handleUpdateIcon = React.useCallback(
async (icon: { ty: ViewIconType; value: string }) => {
if (!updatePageIcon || !viewId) return;
setIcon(icon);
try {
await updatePageIcon(viewId, icon);
// eslint-disable-next-line
} catch (e: any) {
notify.error(e.message);
}
},
[updatePageIcon, viewId]
);
const handleUpdateName = React.useCallback(
async (newName: string) => {
if (!updatePageName || !viewId) return;
try {
if (name === newName) return;
await updatePageName(viewId, newName);
// eslint-disable-next-line
} catch (e: any) {
notify.error(e.message);
}
},
[name, updatePageName, viewId]
);
const handleUpdateCover = React.useCallback(
async (cover?: { type: CoverType; value: string }) => {
if (!updatePage || !viewId) return;
setCover(cover ? cover : null);
try {
await updatePage(viewId, {
icon: icon || {
ty: ViewIconType.Emoji,
value: '',
},
name: name || '',
extra: {
...extra,
cover: cover,
},
});
// eslint-disable-next-line
} catch (e: any) {
notify.error(e.message);
}
},
[extra, icon, name, updatePage, viewId]
);
const onUploadFile = useCallback(
async (file: File) => {
if (!uploadFile) return Promise.reject();
return uploadFile(file);
},
[uploadFile]
);
const ref = React.useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
const handleMouseEnter = () => {
setIsHover(true);
};
const handleMouseLeave = () => {
setIsHover(false);
};
if (el) {
el.addEventListener('mouseenter', handleMouseEnter);
el.addEventListener('mouseleave', handleMouseLeave);
}
return () => {
if (el) {
el.removeEventListener('mouseenter', handleMouseEnter);
el.removeEventListener('mouseleave', handleMouseLeave);
}
};
}, []);
return (
<div className={'flex w-full flex-col items-center'}>
{cover && (
<ViewCover
onUpdateCover={handleUpdateCover}
coverType={coverType}
coverValue={coverValue}
onRemoveCover={handleUpdateCover}
readOnly={readOnly}
layout={layout}
/>
)}
<div ref={ref} className={'relative flex w-full flex-col overflow-hidden'}>
<div
style={{
height: layout === ViewLayout.Document ? '40px' : '32px',
}}
className={'relative flex w-full justify-center max-sm:h-[32px]'}
>
{!readOnly && (
<Suspense>
<AddIconCover
visible={isHover}
hasIcon={!!icon?.value}
hasCover={!!cover?.value}
onUpdateIcon={handleUpdateIcon}
onAddCover={() => {
void handleUpdateCover({
type: CoverType.BuildInImage,
value: '1',
});
}}
maxWidth={maxWidth}
onUploadFile={onUploadFile}
/>
</Suspense>
)}
</div>
<div
style={{
marginBottom: layout === ViewLayout.Document ? '24px' : '16px',
}}
className={`relative flex w-full items-center justify-center overflow-visible`}
>
<h1
style={{
width: maxWidth || '100%',
fontSize: layout === ViewLayout.Document ? '2.5rem' : '26px',
}}
className={
'flex min-w-0 max-w-full gap-4 overflow-hidden whitespace-pre-wrap break-words break-all px-24 font-bold max-md:text-[26px] max-sm:px-6'
}
>
{icon?.value ? (
<CustomIconPopover
enable={!readOnly}
removeIcon={() => {
void handleUpdateIcon({
ty: ViewIconType.Emoji,
value: '',
});
}}
onSelectIcon={(icon) => {
if (icon.ty === ViewIconType.Icon) {
void handleUpdateIcon({
ty: ViewIconType.Icon,
value: JSON.stringify({
color: icon.color,
groupName: icon.value.split('/')[0],
iconName: icon.value.split('/')[1],
}),
});
return;
}
void handleUpdateIcon(icon);
}}
onUploadFile={onUploadFile}
>
<div
className={`view-icon flex h-[1.25em] w-[1.25em] items-center justify-center px-1.5 ${
readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-fill-content-hover '
}`}
>
<PageIcon
view={{
icon,
layout: ViewLayout.Document,
}}
className={'flex h-[90%] w-[90%] min-w-[36px] items-center justify-center'}
/>
</div>
</CustomIconPopover>
) : null}
{!readOnly && viewId ? (
<>
{console.log('[ViewMetaPreview] Rendering TitleEditable:', { viewId, readOnly, name })}
<TitleEditable
onFocus={onFocus}
viewId={viewId}
name={name || ''}
onUpdateName={handleUpdateName}
onEnter={onEnter}
/>
</>
) : (
<>
{console.log('[ViewMetaPreview] Rendering non-editable div:', { viewId, readOnly, name })}
<div
style={{
wordBreak: 'break-word',
}}
className={
'relative flex-1 cursor-text whitespace-pre-wrap break-words empty:before:text-text-tertiary empty:before:content-[attr(data-placeholder)] focus:outline-none'
}
data-placeholder={t('menuAppHeader.defaultNewPageName')}
contentEditable={false}
>
{name}
</div>
</>
)}
</h1>
</div>
</div>
</div>
);
}
export default ViewMetaPreview;