mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support add cover and icon in tauri document (#3069)
* feat: support add cover and icon * feat: emoji picker * feat: emoji picker
This commit is contained in:
parent
f28c5d849c
commit
eb77346e5a
@ -5,9 +5,11 @@ import { currentUserActions } from '$app_reducers/current-user/slice';
|
||||
import { Theme as ThemeType, Theme, ThemeMode } from '$app/interfaces';
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { getDesignTokens } from '$app/utils/mui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useUserSetting() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { i18n } = useTranslation();
|
||||
const currentUser = useAppSelector((state) => state.currentUser);
|
||||
const userSettingController = useMemo(() => {
|
||||
if (!currentUser?.id) return;
|
||||
@ -35,8 +37,9 @@ export function useUserSetting() {
|
||||
language: language,
|
||||
})
|
||||
);
|
||||
i18n.changeLanguage(language);
|
||||
});
|
||||
}, [dispatch, userSettingController]);
|
||||
}, [i18n, dispatch, userSettingController]);
|
||||
|
||||
const { themeMode = ThemeMode.Light, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
|
||||
return state.currentUser.userSetting || {};
|
||||
|
@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import emojiData, { EmojiMartData } from '@emoji-mart/data';
|
||||
import { PopoverProps } from '@mui/material/Popover';
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { chunkArray } from '$app/utils/tool';
|
||||
|
||||
export interface EmojiCategory {
|
||||
id: string;
|
||||
emojis: Emoji[];
|
||||
}
|
||||
|
||||
interface Emoji {
|
||||
id: string;
|
||||
name: string;
|
||||
native: string;
|
||||
}
|
||||
export function useLoadEmojiData({ skin }: { skin: number }) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [emojiCategories, setEmojiCategories] = useState<EmojiCategory[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const { emojis, categories } = emojiData as EmojiMartData;
|
||||
|
||||
const emojiCategories = categories
|
||||
.map((category) => {
|
||||
const { id, emojis: categoryEmojis } = category;
|
||||
|
||||
return {
|
||||
id,
|
||||
emojis: categoryEmojis
|
||||
.filter((emojiId) => {
|
||||
const emoji = emojis[emojiId];
|
||||
|
||||
if (!searchValue) return true;
|
||||
return filterSearchValue(emoji, searchValue);
|
||||
})
|
||||
.map((emojiId) => {
|
||||
const emoji = emojis[emojiId];
|
||||
const { id, name, skins } = emoji;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
native: skins[skin] ? skins[skin].native : skins[0].native,
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((category) => category.emojis.length > 0);
|
||||
|
||||
setEmojiCategories(emojiCategories);
|
||||
}, [skin, searchValue]);
|
||||
|
||||
return {
|
||||
emojiCategories,
|
||||
skin,
|
||||
setSearchValue,
|
||||
searchValue,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSelectSkinPopoverProps(): PopoverProps & {
|
||||
onOpen: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onClose: () => void;
|
||||
} {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | undefined>(undefined);
|
||||
const onOpen = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const onClose = useCallback(() => {
|
||||
setAnchorEl(undefined);
|
||||
}, []);
|
||||
const open = Boolean(anchorEl);
|
||||
const anchorOrigin = { vertical: 'bottom', horizontal: 'center' } as PopoverOrigin;
|
||||
const transformOrigin = { vertical: 'top', horizontal: 'center' } as PopoverOrigin;
|
||||
|
||||
return {
|
||||
anchorEl,
|
||||
onOpen,
|
||||
onClose,
|
||||
open,
|
||||
anchorOrigin,
|
||||
transformOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
function filterSearchValue(emoji: emojiData.Emoji, searchValue: string) {
|
||||
const { name, keywords } = emoji;
|
||||
const searchValueLowerCase = searchValue.toLowerCase();
|
||||
|
||||
return (
|
||||
name.toLowerCase().includes(searchValueLowerCase) ||
|
||||
(keywords && keywords.some((keyword) => keyword.toLowerCase().includes(searchValueLowerCase)))
|
||||
);
|
||||
}
|
||||
|
||||
export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: number) {
|
||||
const rows: {
|
||||
id: string;
|
||||
type: 'category' | 'emojis';
|
||||
emojis?: Emoji[];
|
||||
}[] = [];
|
||||
|
||||
emojiCategories.forEach((category) => {
|
||||
rows.push({
|
||||
id: category.id,
|
||||
type: 'category',
|
||||
});
|
||||
chunkArray(category.emojis, rowSize).forEach((chunk, index) => {
|
||||
rows.push({
|
||||
type: 'emojis',
|
||||
emojis: chunk,
|
||||
id: `${category.id}-${index}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function useVirtualizedCategories({ count }: { count: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const virtualize = useVirtualizer({
|
||||
count,
|
||||
getScrollElement: () => ref.current,
|
||||
estimateSize: () => {
|
||||
return 60;
|
||||
},
|
||||
overscan: 3,
|
||||
});
|
||||
|
||||
return { virtualize, ref };
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
EmojiCategory,
|
||||
getRowsWithCategories,
|
||||
useVirtualizedCategories,
|
||||
} from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconButton } from '@mui/material';
|
||||
|
||||
function EmojiPickerCategories({
|
||||
emojiCategories,
|
||||
onEmojiSelect,
|
||||
}: {
|
||||
emojiCategories: EmojiCategory[];
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => {
|
||||
return getRowsWithCategories(emojiCategories, 13);
|
||||
}, [emojiCategories]);
|
||||
|
||||
const { ref, virtualize } = useVirtualizedCategories({
|
||||
count: rows.length,
|
||||
});
|
||||
const virtualItems = virtualize.getVirtualItems();
|
||||
|
||||
const getCategoryName = useCallback(
|
||||
(id: string) => {
|
||||
const i18nName: Record<string, string> = {
|
||||
people: t('emoji.categories.people'),
|
||||
nature: t('emoji.categories.nature'),
|
||||
foods: t('emoji.categories.food'),
|
||||
activity: t('emoji.categories.activities'),
|
||||
places: t('emoji.categories.places'),
|
||||
objects: t('emoji.categories.objects'),
|
||||
symbols: t('emoji.categories.symbols'),
|
||||
flags: t('emoji.categories.flags'),
|
||||
};
|
||||
|
||||
return i18nName[id];
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={'mt-2 w-[416px] flex-1 items-center justify-center overflow-y-auto overflow-x-hidden'}>
|
||||
<div
|
||||
style={{
|
||||
height: virtualize.getTotalSize(),
|
||||
position: 'relative',
|
||||
}}
|
||||
className={'mx-1'}
|
||||
>
|
||||
{virtualItems.length ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItems[0].start || 0}px)`,
|
||||
}}
|
||||
>
|
||||
{virtualItems.map(({ index }) => {
|
||||
const item = rows[index];
|
||||
|
||||
return (
|
||||
<div data-index={index} ref={virtualize.measureElement} key={item.id} className={'flex flex-col'}>
|
||||
{item.type === 'category' ? (
|
||||
<div className={'p-2 text-sm font-medium text-text-caption'}>{getCategoryName(item.id)}</div>
|
||||
) : null}
|
||||
<div className={'flex'}>
|
||||
{item.emojis?.map((emoji) => {
|
||||
return (
|
||||
<div key={emoji.id} className={'flex h-[32px] w-[32px] items-center justify-center'}>
|
||||
<IconButton
|
||||
size={'small'}
|
||||
onClick={() => {
|
||||
onEmojiSelect(emoji.native);
|
||||
}}
|
||||
>
|
||||
{emoji.native}
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmojiPickerCategories;
|
@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { Box, IconButton } from '@mui/material';
|
||||
import { DeleteOutlineRounded, SearchOutlined } from '@mui/icons-material';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { randomEmoji } from '$app/utils/document/emoji';
|
||||
import ShuffleIcon from '@mui/icons-material/Shuffle';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { useSelectSkinPopoverProps } from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const skinTones = [
|
||||
{
|
||||
value: 0,
|
||||
label: '✋',
|
||||
},
|
||||
{
|
||||
label: '✋🏻',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: '✋🏼',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: '✋🏽',
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
label: '✋🏾',
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
label: '✋🏿',
|
||||
value: 5,
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
skin: number;
|
||||
onSkinSelect: (skin: number) => void;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) {
|
||||
const { onOpen, ...popoverProps } = useSelectSkinPopoverProps();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={'h-[45px] px-0.5'}>
|
||||
<div className={'search-input flex items-end'}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', marginRight: 2 }}>
|
||||
<SearchOutlined sx={{ color: 'action.active', mr: 1, my: 0.5 }} />
|
||||
<TextField
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value);
|
||||
}}
|
||||
label={t('search.label')}
|
||||
variant='standard'
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip title={t('emoji.random')}>
|
||||
<div className={'random-emoji-btn mr-2 rounded border border-line-divider'}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
const emoji = randomEmoji();
|
||||
|
||||
onEmojiSelect(emoji);
|
||||
}}
|
||||
>
|
||||
<ShuffleIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('emoji.selectSkinTone')}>
|
||||
<div className={'random-emoji-btn mr-2 rounded border border-line-divider'}>
|
||||
<IconButton size={'small'} className={'h-[25px] w-[25px]'} onClick={onOpen}>
|
||||
{skinTones[skin].label}
|
||||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('emoji.remove')}>
|
||||
<div className={'random-emoji-btn rounded border border-line-divider'}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
onEmojiSelect('');
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineRounded />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Popover {...popoverProps}>
|
||||
<div className={'flex items-center p-2'}>
|
||||
{skinTones.map((skinTone) => (
|
||||
<div className={'mx-0.5'} key={skinTone.value}>
|
||||
<IconButton
|
||||
style={{
|
||||
backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : 'transparent',
|
||||
}}
|
||||
size={'small'}
|
||||
onClick={() => {
|
||||
onSkinSelect(skinTone.value);
|
||||
popoverProps.onClose?.();
|
||||
}}
|
||||
>
|
||||
{skinTone.label}
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmojiPickerHeader;
|
@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useLoadEmojiData } from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks';
|
||||
|
||||
import EmojiPickerHeader from '$app/components/_shared/EmojiPicker/EmojiPickerHeader';
|
||||
import EmojiPickerCategories from '$app/components/_shared/EmojiPicker/EmojiPickerCategories';
|
||||
|
||||
interface Props {
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
}
|
||||
|
||||
function EmojiPickerComponent({ onEmojiSelect }: Props) {
|
||||
const [skin, setSkin] = useState(0);
|
||||
|
||||
const { emojiCategories, setSearchValue, searchValue } = useLoadEmojiData({
|
||||
skin,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
|
||||
<EmojiPickerHeader
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
skin={skin}
|
||||
onSkinSelect={setSkin}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
/>
|
||||
<EmojiPickerCategories onEmojiSelect={onEmojiSelect} emojiCategories={emojiCategories} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmojiPickerComponent;
|
@ -19,14 +19,14 @@ export function useCalloutBlock(nodeId: string) {
|
||||
}, []);
|
||||
|
||||
const onEmojiSelect = useCallback(
|
||||
(emoji: { native: string }) => {
|
||||
(emoji: string) => {
|
||||
if (!controller) return;
|
||||
void dispatch(
|
||||
updateNodeDataThunk({
|
||||
id: nodeId,
|
||||
controller,
|
||||
data: {
|
||||
icon: emoji.native,
|
||||
icon: emoji,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
@ -2,10 +2,9 @@ import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import TextBlock from '$app/components/document/TextBlock';
|
||||
import NodeChildren from '$app/components/document/Node/NodeChildren';
|
||||
import { IconButton } from '@mui/material';
|
||||
import emojiData from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import EmojiPicker from '$app/components/_shared/EmojiPicker';
|
||||
|
||||
export default function CalloutBlock({
|
||||
node,
|
||||
@ -38,7 +37,7 @@ export default function CalloutBlock({
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<Picker searchPosition={'static'} locale={'en'} autoFocus data={emojiData} onEmojiSelect={onEmojiSelect} />
|
||||
<EmojiPicker onEmojiSelect={onEmojiSelect} />
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -0,0 +1,59 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import EmojiPicker from '$app/components/_shared/EmojiPicker';
|
||||
|
||||
function DocumentIcon({
|
||||
icon,
|
||||
className,
|
||||
onUpdateIcon,
|
||||
}: {
|
||||
icon?: string;
|
||||
className?: string;
|
||||
onUpdateIcon: (icon: string) => void;
|
||||
}) {
|
||||
const [anchorPosition, setAnchorPosition] = useState<
|
||||
| undefined
|
||||
| {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
>(undefined);
|
||||
const open = Boolean(anchorPosition);
|
||||
const onOpen = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
setAnchorPosition({
|
||||
top: rect.top + rect.height,
|
||||
left: rect.left,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
onUpdateIcon(emoji);
|
||||
setAnchorPosition(undefined);
|
||||
},
|
||||
[onUpdateIcon]
|
||||
);
|
||||
|
||||
if (!icon) return null;
|
||||
return (
|
||||
<>
|
||||
<div className={`absolute bottom-0 left-0 pt-[20px] ${className}`}>
|
||||
<div onClick={onOpen} className={'h-full w-full cursor-pointer rounded text-6xl hover:text-7xl'}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<Popover
|
||||
open={open}
|
||||
anchorReference='anchorPosition'
|
||||
anchorPosition={anchorPosition}
|
||||
onClose={() => setAnchorPosition(undefined)}
|
||||
>
|
||||
<EmojiPicker onEmojiSelect={onEmojiSelect} />
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentIcon;
|
@ -1,7 +1,47 @@
|
||||
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
|
||||
import { useCallback } from 'react';
|
||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
|
||||
export function useDocumentTitle(id: string) {
|
||||
const { node } = useSubscribeNode(id);
|
||||
const { controller } = useSubscribeDocument();
|
||||
const dispatch = useAppDispatch();
|
||||
const onUpdateIcon = useCallback(
|
||||
(icon: string) => {
|
||||
dispatch(
|
||||
updateNodeDataThunk({
|
||||
id,
|
||||
data: {
|
||||
icon,
|
||||
},
|
||||
controller,
|
||||
})
|
||||
);
|
||||
},
|
||||
[controller, dispatch, id]
|
||||
);
|
||||
|
||||
const onUpdateCover = useCallback(
|
||||
(coverType: 'image' | 'color' | '', cover: string) => {
|
||||
dispatch(
|
||||
updateNodeDataThunk({
|
||||
id,
|
||||
data: {
|
||||
cover,
|
||||
coverType,
|
||||
},
|
||||
controller,
|
||||
})
|
||||
);
|
||||
},
|
||||
[controller, dispatch, id]
|
||||
);
|
||||
|
||||
return {
|
||||
node,
|
||||
onUpdateCover,
|
||||
onUpdateIcon,
|
||||
};
|
||||
}
|
||||
|
@ -0,0 +1,44 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import DocumentCover from '$app/components/document/DocumentTitle/cover/DocumentCover';
|
||||
import DocumentIcon from '$app/components/document/DocumentTitle/DocumentIcon';
|
||||
|
||||
const heightCls = {
|
||||
cover: 'h-[220px]',
|
||||
icon: 'h-[80px]',
|
||||
coverAndIcon: 'h-[250px]',
|
||||
none: 'h-0',
|
||||
};
|
||||
|
||||
function DocumentTopPanel({
|
||||
node,
|
||||
onUpdateCover,
|
||||
onUpdateIcon,
|
||||
}: {
|
||||
node: NestedBlock<BlockType.PageBlock>;
|
||||
onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
|
||||
onUpdateIcon: (icon: string) => void;
|
||||
}) {
|
||||
const { cover, coverType, icon } = node.data;
|
||||
|
||||
const className = useMemo(() => {
|
||||
if (cover && icon) return heightCls.coverAndIcon;
|
||||
if (cover) return heightCls.cover;
|
||||
if (icon) return heightCls.icon;
|
||||
return heightCls.none;
|
||||
}, [cover, icon]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: icon || cover ? 'block' : 'none',
|
||||
}}
|
||||
className={`relative ${className}`}
|
||||
>
|
||||
<DocumentCover onUpdateCover={onUpdateCover} className={heightCls.cover} cover={cover} coverType={coverType} />
|
||||
<DocumentIcon onUpdateIcon={onUpdateIcon} className={heightCls.icon} icon={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentTopPanel;
|
@ -0,0 +1,47 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EmojiEmotionsOutlined, ImageOutlined } from '@mui/icons-material';
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import { randomColor } from '$app/components/document/DocumentTitle/cover/config';
|
||||
import { randomEmoji } from '$app/utils/document/emoji';
|
||||
|
||||
interface Props {
|
||||
node: NestedBlock<BlockType.PageBlock>;
|
||||
onUpdateCover: (coverType: 'image' | 'color', cover: string) => void;
|
||||
onUpdateIcon: (icon: string) => void;
|
||||
}
|
||||
function TitleButtonGroup({ onUpdateIcon, onUpdateCover, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const showAddIcon = !node.data.icon;
|
||||
const showAddCover = !node.data.cover;
|
||||
|
||||
const onAddIcon = useCallback(() => {
|
||||
const emoji = randomEmoji();
|
||||
|
||||
onUpdateIcon(emoji);
|
||||
}, [onUpdateIcon]);
|
||||
|
||||
const onAddCover = useCallback(() => {
|
||||
const color = randomColor();
|
||||
|
||||
onUpdateCover('color', color);
|
||||
}, [onUpdateCover]);
|
||||
|
||||
return (
|
||||
<div className={'flex items-center py-2'}>
|
||||
{showAddIcon && (
|
||||
<Button onClick={onAddIcon} color={'inherit'} startIcon={<EmojiEmotionsOutlined />}>
|
||||
{t('document.plugins.cover.addIcon')}
|
||||
</Button>
|
||||
)}
|
||||
{showAddCover && (
|
||||
<Button onClick={onAddCover} color={'inherit'} startIcon={<ImageOutlined />}>
|
||||
{t('document.plugins.cover.addCover')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TitleButtonGroup;
|
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from './config';
|
||||
|
||||
function ChangeColors({ cover, onChange }: { cover: string; onChange: (color: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col'}>
|
||||
<div className={'p-2 pb-4 text-text-caption'}>{t('document.plugins.cover.colors')}</div>
|
||||
<div className={'flex flex-wrap'}>
|
||||
{colors.map((color) => (
|
||||
<div
|
||||
onClick={() => onChange(color)}
|
||||
key={color}
|
||||
style={{ backgroundColor: color }}
|
||||
className={`m-1 flex h-[20px] w-[20px] cursor-pointer items-center justify-center rounded-[50%]`}
|
||||
>
|
||||
{cover === color && (
|
||||
<div
|
||||
style={{
|
||||
borderColor: '#fff',
|
||||
backgroundColor: color,
|
||||
}}
|
||||
className={'h-[16px] w-[calc(16px)] rounded-[50%] border-[2px] border-solid'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangeColors;
|
@ -0,0 +1,72 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { DeleteOutlineRounded } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ButtonGroup } from '@mui/material';
|
||||
import Button from '@mui/material/Button';
|
||||
import ChangeCoverPopover from '$app/components/document/DocumentTitle/cover/ChangeCoverPopover';
|
||||
|
||||
function ChangeCoverButton({
|
||||
visible,
|
||||
cover,
|
||||
coverType,
|
||||
onUpdateCover,
|
||||
}: {
|
||||
visible: boolean;
|
||||
cover: string;
|
||||
coverType: 'image' | 'color';
|
||||
onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [anchorPosition, setAnchorPosition] = useState<undefined | { top: number; left: number }>(undefined);
|
||||
const open = Boolean(anchorPosition);
|
||||
const onClose = useCallback(() => {
|
||||
setAnchorPosition(undefined);
|
||||
}, []);
|
||||
const onOpen = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
setAnchorPosition({
|
||||
top: rect.top + rect.height,
|
||||
left: rect.left + rect.width + 40,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onDeleteCover = useCallback(() => {
|
||||
onUpdateCover('', '');
|
||||
}, [onUpdateCover]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible && (
|
||||
<div className={'absolute bottom-4 right-6 flex text-[0.7rem]'}>
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className={
|
||||
'flex items-center rounded-md border border-line-divider bg-bg-body p-1 px-2 opacity-70 hover:opacity-100'
|
||||
}
|
||||
>
|
||||
{t('document.plugins.cover.changeCover')}
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
'ml-2 flex items-center rounded-md border border-line-divider bg-bg-body p-1 opacity-70 hover:opacity-100'
|
||||
}
|
||||
onClick={onDeleteCover}
|
||||
>
|
||||
<DeleteOutlineRounded />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ChangeCoverPopover
|
||||
cover={cover}
|
||||
coverType={coverType}
|
||||
open={open}
|
||||
anchorPosition={anchorPosition}
|
||||
onClose={onClose}
|
||||
onUpdateCover={onUpdateCover}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangeCoverButton;
|
@ -0,0 +1,63 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Popover, { PopoverActions } from '@mui/material/Popover';
|
||||
import ChangeColors from '$app/components/document/DocumentTitle/cover/ChangeColors';
|
||||
import ChangeImages from '$app/components/document/DocumentTitle/cover/ChangeImages';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
|
||||
function ChangeCoverPopover({
|
||||
open,
|
||||
anchorPosition,
|
||||
onClose,
|
||||
coverType,
|
||||
cover,
|
||||
onUpdateCover,
|
||||
}: {
|
||||
open: boolean;
|
||||
anchorPosition?: { top: number; left: number };
|
||||
onClose: () => void;
|
||||
coverType: 'image' | 'color';
|
||||
cover: string;
|
||||
onUpdateCover: (coverType: 'image' | 'color', cover: string) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
anchorReference={'anchorPosition'}
|
||||
anchorPosition={anchorPosition}
|
||||
onClose={onClose}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
height: 'auto',
|
||||
overflow: 'visible',
|
||||
},
|
||||
elevation: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
boxShadow:
|
||||
'0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
className={'flex flex-col rounded-md bg-bg-body p-4 '}
|
||||
ref={ref}
|
||||
>
|
||||
<ChangeColors
|
||||
onChange={(color) => {
|
||||
onUpdateCover('color', color);
|
||||
}}
|
||||
cover={cover}
|
||||
/>
|
||||
<ChangeImages cover={cover} onChange={(url) => onUpdateCover('image', url)} />
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangeCoverPopover;
|
@ -0,0 +1,80 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import GalleryList from '$app/components/document/DocumentTitle/cover/GalleryList';
|
||||
import Button from '@mui/material/Button';
|
||||
import { readCoverImageUrls, readImage, writeCoverImageUrls } from '$app/utils/document/image';
|
||||
import { Log } from '$app/utils/log';
|
||||
import { Image } from '$app/components/document/DocumentTitle/cover/GalleryItem';
|
||||
|
||||
function ChangeImages({ cover, onChange }: { onChange: (url: string) => void; cover: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [images, setImages] = useState<Image[]>([]);
|
||||
const loadImageUrls = useCallback(async () => {
|
||||
try {
|
||||
const { images } = await readCoverImageUrls();
|
||||
const newImages = [];
|
||||
|
||||
for (const image of images) {
|
||||
try {
|
||||
const src = await readImage(image.url);
|
||||
|
||||
newImages.push({ ...image, src });
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
setImages(newImages);
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
}
|
||||
}, [setImages]);
|
||||
|
||||
const onAddImage = useCallback(
|
||||
async (url: string) => {
|
||||
const { images } = await readCoverImageUrls();
|
||||
|
||||
await writeCoverImageUrls([...images, { url }]);
|
||||
await loadImageUrls();
|
||||
},
|
||||
[loadImageUrls]
|
||||
);
|
||||
|
||||
const onDelete = useCallback(
|
||||
async (image: Image) => {
|
||||
const { images } = await readCoverImageUrls();
|
||||
const newImages = images.filter((i) => i.url !== image.url);
|
||||
|
||||
await writeCoverImageUrls(newImages);
|
||||
await loadImageUrls();
|
||||
},
|
||||
[loadImageUrls]
|
||||
);
|
||||
|
||||
const onClearAll = useCallback(async () => {
|
||||
await writeCoverImageUrls([]);
|
||||
await loadImageUrls();
|
||||
}, [loadImageUrls]);
|
||||
|
||||
useEffect(() => {
|
||||
loadImageUrls();
|
||||
}, [loadImageUrls]);
|
||||
|
||||
return (
|
||||
<div className={'flex w-[500px] flex-col'}>
|
||||
<div className={'flex justify-between pb-2 pl-2 pt-4 text-text-caption'}>
|
||||
<div>{t('document.plugins.cover.images')}</div>
|
||||
<Button onClick={onClearAll}>{t('document.plugins.cover.clearAll')}</Button>
|
||||
</div>
|
||||
<GalleryList
|
||||
images={images}
|
||||
onDelete={onDelete}
|
||||
onAddImage={onAddImage}
|
||||
onSelected={(image) => onChange(image.url)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangeImages;
|
@ -0,0 +1,85 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import ChangeCoverButton from '$app/components/document/DocumentTitle/cover/ChangeCoverButton';
|
||||
import { readImage } from '$app/utils/document/image';
|
||||
|
||||
function DocumentCover({
|
||||
cover,
|
||||
coverType,
|
||||
className,
|
||||
onUpdateCover,
|
||||
}: {
|
||||
cover?: string;
|
||||
coverType?: 'image' | 'color';
|
||||
className?: string;
|
||||
onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
|
||||
}) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [leftOffset, setLeftOffset] = useState(0);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [coverSrc, setCoverSrc] = useState<string | undefined>();
|
||||
const calcLeftOffset = useCallback((bodyOffsetLeft: number) => {
|
||||
const docTitle = document.querySelector('.doc-title') as HTMLElement;
|
||||
|
||||
if (!docTitle) {
|
||||
setLeftOffset(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const titleOffsetLeft = docTitle.getBoundingClientRect().left;
|
||||
|
||||
setLeftOffset(titleOffsetLeft - bodyOffsetLeft);
|
||||
}, []);
|
||||
|
||||
const handleWidthChange: ResizeObserverCallback = useCallback(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const { width } = entry.contentRect;
|
||||
|
||||
setWidth(width);
|
||||
const left = entry.target.getBoundingClientRect().left;
|
||||
|
||||
calcLeftOffset(left);
|
||||
});
|
||||
},
|
||||
[calcLeftOffset]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(handleWidthChange);
|
||||
const docPage = document.getElementById('appflowy-block-doc') as HTMLElement;
|
||||
|
||||
observer.observe(docPage);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [handleWidthChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (coverType === 'image' && cover) {
|
||||
void (async () => {
|
||||
const src = await readImage(cover);
|
||||
|
||||
setCoverSrc(src);
|
||||
})();
|
||||
}
|
||||
}, [cover, coverType]);
|
||||
|
||||
if (!cover || !coverType) return null;
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
left: -leftOffset,
|
||||
width,
|
||||
}}
|
||||
className={`absolute top-0 w-full overflow-hidden ${className}`}
|
||||
>
|
||||
{coverType === 'image' && <img src={coverSrc} className={'h-full w-full object-cover'} />}
|
||||
{coverType === 'color' && <div className={'h-full w-full'} style={{ backgroundColor: cover }} />}
|
||||
<ChangeCoverButton onUpdateCover={onUpdateCover} visible={hover} cover={cover} coverType={coverType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(DocumentCover);
|
@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react';
|
||||
import { DeleteOutlineRounded } from '@mui/icons-material';
|
||||
import ImageListItem from '@mui/material/ImageListItem';
|
||||
|
||||
export interface Image {
|
||||
url: string;
|
||||
src?: string;
|
||||
}
|
||||
function GalleryItem({ image, onSelected, onDelete }: { image: Image; onSelected: () => void; onDelete: () => void }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
return (
|
||||
<ImageListItem
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
className={'flex items-center justify-center '}
|
||||
key={image.url}
|
||||
>
|
||||
<div className={'flex h-[80px] w-[120px] cursor-pointer items-center justify-center overflow-hidden rounded'}>
|
||||
<img
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
onClick={onSelected}
|
||||
src={`${image.src}`}
|
||||
alt={image.url}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: hover ? 'block' : 'none',
|
||||
}}
|
||||
className={'absolute right-2 top-2'}
|
||||
>
|
||||
<button className={'rounded bg-bg-body opacity-80 hover:opacity-100'} onClick={() => onDelete()}>
|
||||
<DeleteOutlineRounded />
|
||||
</button>
|
||||
</div>
|
||||
</ImageListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default GalleryItem;
|
@ -0,0 +1,62 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import ImageList from '@mui/material/ImageList';
|
||||
import ImageListItem from '@mui/material/ImageListItem';
|
||||
import { AddOutlined } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import ImageEdit from '$app/components/document/_shared/UploadImage/ImageEdit';
|
||||
import GalleryItem, { Image } from '$app/components/document/DocumentTitle/cover/GalleryItem';
|
||||
|
||||
interface Props {
|
||||
onSelected: (image: Image) => void;
|
||||
images: Image[];
|
||||
onDelete: (image: Image) => Promise<void>;
|
||||
onAddImage: (url: string) => Promise<void>;
|
||||
}
|
||||
function GalleryList({ images, onSelected, onDelete, onAddImage }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const onExitEdit = useCallback(() => {
|
||||
setShowEdit(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ImageList className={'max-h-[172px] w-full overflow-auto'} cols={4}>
|
||||
<ImageListItem>
|
||||
<div
|
||||
className={
|
||||
'm-1 flex h-[80px] w-[120px] cursor-pointer items-center justify-center rounded border border-fill-default bg-content-blue-50 text-fill-default hover:bg-content-blue-100'
|
||||
}
|
||||
onClick={() => setShowEdit(true)}
|
||||
>
|
||||
<AddOutlined />
|
||||
</div>
|
||||
</ImageListItem>
|
||||
{images.map((image) => {
|
||||
return (
|
||||
<GalleryItem
|
||||
key={image.url}
|
||||
image={image}
|
||||
onSelected={() => onSelected(image)}
|
||||
onDelete={() => onDelete(image)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ImageList>
|
||||
<Dialog open={showEdit} onClose={onExitEdit} fullWidth>
|
||||
<DialogTitle>{t('button.upload')}</DialogTitle>
|
||||
<ImageEdit
|
||||
onSubmitUrl={async (url) => {
|
||||
await onAddImage(url);
|
||||
onExitEdit();
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default GalleryList;
|
@ -0,0 +1,5 @@
|
||||
export const colors = ['#e1fbff', '#defff1', '#ddffd6', '#f5ffdc', '#fff2cd', '#ffefe3', '#ffe7ee', '#e8e0ff'];
|
||||
|
||||
export const randomColor = () => {
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
};
|
@ -1,16 +1,30 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useDocumentTitle } from './DocumentTitle.hooks';
|
||||
import TextBlock from '../TextBlock';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TitleButtonGroup from './TitleButtonGroup';
|
||||
import DocumentTopPanel from './DocumentTopPanel';
|
||||
|
||||
export default function DocumentTitle({ id }: { id: string }) {
|
||||
const { node } = useDocumentTitle(id);
|
||||
const { node, onUpdateCover, onUpdateIcon } = useDocumentTitle(id);
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
return (
|
||||
<div data-block-id={node.id} className='doc-title relative mb-2 pt-[50px] text-4xl font-bold'>
|
||||
<TextBlock placeholder={t('document.title.placeholder')} childIds={[]} node={node} />
|
||||
<div className={'flex flex-col'} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
||||
<DocumentTopPanel onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} node={node} />
|
||||
<div
|
||||
style={{
|
||||
opacity: hover ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<TitleButtonGroup node={node} onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} />
|
||||
</div>
|
||||
<div data-block-id={node.id} className='doc-title relative text-4xl font-bold'>
|
||||
<TextBlock placeholder={t('document.title.placeholder')} childIds={[]} node={node} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,96 +0,0 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Button, TextField, Tabs, Tab, Box } from '@mui/material';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import UploadImage from '$app/components/document/_shared/UploadImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
enum TAB_KEYS {
|
||||
UPLOAD = 'upload',
|
||||
LINK = 'link',
|
||||
}
|
||||
|
||||
function EditImage({ id, url, onClose }: { id: string; url: string; onClose: () => void }) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { controller } = useSubscribeDocument();
|
||||
const [linkVal, setLinkVal] = useState<string>(url);
|
||||
const [tabKey, setTabKey] = useState<TAB_KEYS>(TAB_KEYS.UPLOAD);
|
||||
const handleChange = useCallback((_: React.SyntheticEvent, newValue: TAB_KEYS) => {
|
||||
setTabKey(newValue);
|
||||
}, []);
|
||||
|
||||
const handleConfirmUrl = useCallback(
|
||||
(url: string) => {
|
||||
if (!url) return;
|
||||
dispatch(
|
||||
updateNodeDataThunk({
|
||||
id,
|
||||
data: {
|
||||
url,
|
||||
},
|
||||
controller,
|
||||
})
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
[onClose, dispatch, id, controller]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'w-[540px]'}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabKey} onChange={handleChange}>
|
||||
<Tab label={t('document.imageBlock.upload.label')} value={TAB_KEYS.UPLOAD} />
|
||||
|
||||
<Tab label={t('document.imageBlock.url.label')} value={TAB_KEYS.LINK} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<TabPanel value={tabKey} index={TAB_KEYS.UPLOAD}>
|
||||
<UploadImage onChange={handleConfirmUrl} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel className={'flex flex-col p-3'} value={tabKey} index={TAB_KEYS.LINK}>
|
||||
<TextField
|
||||
value={linkVal}
|
||||
onChange={(e) => setLinkVal(e.target.value)}
|
||||
variant='outlined'
|
||||
label={t('document.imageBlock.url.label')}
|
||||
autoFocus={true}
|
||||
style={{
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
placeholder={t('document.imageBlock.url.placeholder')}
|
||||
/>
|
||||
<Button onClick={() => handleConfirmUrl(linkVal)} variant='contained'>
|
||||
{t('button.upload')}
|
||||
</Button>
|
||||
</TabPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditImage;
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: TAB_KEYS;
|
||||
value: TAB_KEYS;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role='tabpanel'
|
||||
hidden={value !== index}
|
||||
id={`image-tabpanel-${index}`}
|
||||
aria-labelledby={`image-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && children}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,20 +1,44 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import { useImageBlock } from './useImageBlock';
|
||||
import EditImage from '$app/components/document/ImageBlock/EditImage';
|
||||
import { useBlockPopover } from '$app/components/document/_shared/BlockPopover/BlockPopover.hooks';
|
||||
import ImagePlaceholder from '$app/components/document/ImageBlock/ImagePlaceholder';
|
||||
import ImageRender from '$app/components/document/ImageBlock/ImageRender';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
||||
import ImageEdit from '$app/components/document/_shared/UploadImage/ImageEdit';
|
||||
|
||||
function ImageBlock({ node }: { node: NestedBlock<BlockType.ImageBlock> }) {
|
||||
const { url } = node.data;
|
||||
const { displaySize, onResizeStart, src, alignSelf, loading, error } = useImageBlock(node);
|
||||
const dispatch = useAppDispatch();
|
||||
const { controller } = useSubscribeDocument();
|
||||
const id = node.id;
|
||||
|
||||
const renderPopoverContent = useCallback(
|
||||
({ onClose }: { onClose: () => void }) => {
|
||||
return <EditImage onClose={onClose} id={node.id} url={url} />;
|
||||
const onSubmitUrl = (url: string) => {
|
||||
if (!url) return;
|
||||
dispatch(
|
||||
updateNodeDataThunk({
|
||||
id,
|
||||
data: {
|
||||
url,
|
||||
},
|
||||
controller,
|
||||
})
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'w-[540px]'}>
|
||||
<ImageEdit url={url} onSubmitUrl={onSubmitUrl} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[node.id, url]
|
||||
[controller, dispatch, id, url]
|
||||
);
|
||||
|
||||
const { anchorElRef, contextHolder, openPopover } = useBlockPopover({
|
||||
|
@ -6,7 +6,6 @@ import { turnToBlockThunk } from '$app_reducers/document/async-actions';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import Delta from 'quill-delta';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
import { rangeActions, rectSelectionActions } from '$app_reducers/document/slice';
|
||||
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
|
||||
|
||||
export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
|
||||
|
@ -0,0 +1,53 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TAB_KEYS, TabPanel } from './TabPanel';
|
||||
import { Box, Button, Tab, Tabs, TextField } from '@mui/material';
|
||||
import UploadImage from './UploadImage';
|
||||
|
||||
interface Props {
|
||||
onSubmitUrl: (url: string) => void;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
function ImageEdit({ onSubmitUrl, url }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [linkVal, setLinkVal] = useState<string>(url || '');
|
||||
const [tabKey, setTabKey] = useState<TAB_KEYS>(TAB_KEYS.UPLOAD);
|
||||
const handleChange = useCallback((_: React.SyntheticEvent, newValue: TAB_KEYS) => {
|
||||
setTabKey(newValue);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={'h-full w-full'}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabKey} onChange={handleChange}>
|
||||
<Tab label={t('document.imageBlock.upload.label')} value={TAB_KEYS.UPLOAD} />
|
||||
|
||||
<Tab label={t('document.imageBlock.url.label')} value={TAB_KEYS.LINK} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<TabPanel value={tabKey} index={TAB_KEYS.UPLOAD}>
|
||||
<UploadImage onChange={onSubmitUrl} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel className={'flex flex-col p-3'} value={tabKey} index={TAB_KEYS.LINK}>
|
||||
<TextField
|
||||
value={linkVal}
|
||||
onChange={(e) => setLinkVal(e.target.value)}
|
||||
variant='outlined'
|
||||
label={t('document.imageBlock.url.label')}
|
||||
autoFocus={true}
|
||||
style={{
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
placeholder={t('document.imageBlock.url.placeholder')}
|
||||
/>
|
||||
<Button onClick={() => onSubmitUrl(linkVal)} variant='contained'>
|
||||
{t('button.upload')}
|
||||
</Button>
|
||||
</TabPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageEdit;
|
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import Popover, { PopoverProps } from '@mui/material/Popover';
|
||||
import ImageEdit from './ImageEdit';
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
|
||||
interface Props extends PopoverProps {
|
||||
onSubmitUrl: (url: string) => void;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
function ImageEditPopover({ onSubmitUrl, url, ...props }: Props) {
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<ImageEdit onSubmitUrl={onSubmitUrl} url={url} />
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageEditPopover;
|
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
export enum TAB_KEYS {
|
||||
UPLOAD = 'upload',
|
||||
LINK = 'link',
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: TAB_KEYS;
|
||||
value: TAB_KEYS;
|
||||
}
|
||||
|
||||
export function TabPanel(props: TabPanelProps & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role='tabpanel'
|
||||
hidden={value !== index}
|
||||
id={`image-tabpanel-${index}`}
|
||||
aria-labelledby={`image-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && children}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { Align, BlockConfig, BlockType, SplitRelationship } from '$app/interfaces/document';
|
||||
import { randomEmoji } from '$app/utils/document/emoji';
|
||||
|
||||
/**
|
||||
* If the block type is not in the config, it will be thrown an error in development env
|
||||
@ -69,7 +70,7 @@ export const blockConfig: Record<string, BlockConfig> = {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
icon: 'bulb',
|
||||
icon: randomEmoji(),
|
||||
},
|
||||
splitProps: {
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
|
@ -82,7 +82,11 @@ export interface ImageBlockData {
|
||||
align: Align;
|
||||
}
|
||||
|
||||
export type PageBlockData = TextBlockData;
|
||||
export interface PageBlockData extends TextBlockData {
|
||||
cover?: string;
|
||||
icon?: string;
|
||||
coverType?: 'image' | 'color';
|
||||
}
|
||||
|
||||
export type BlockData<Type> = Type extends BlockType.HeadingBlock
|
||||
? HeadingBlockData
|
||||
|
@ -0,0 +1,9 @@
|
||||
import emojiData, { EmojiMartData } from '@emoji-mart/data';
|
||||
|
||||
export const randomEmoji = () => {
|
||||
const emojis = (emojiData as EmojiMartData).emojis;
|
||||
const keys = Object.keys(emojis);
|
||||
const randomKey = keys[Math.floor(Math.random() * keys.length)];
|
||||
|
||||
return emojis[randomKey].skins[0].native;
|
||||
};
|
@ -14,6 +14,47 @@ export async function readImage(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCoverImageUrls(): Promise<{
|
||||
images: { url: string }[];
|
||||
}> {
|
||||
const { BaseDirectory, readTextFile, exists } = await import('@tauri-apps/api/fs');
|
||||
|
||||
try {
|
||||
const existDir = await exists('cover/image_urls.json', { dir: BaseDirectory.AppLocalData });
|
||||
|
||||
if (!existDir) {
|
||||
return {
|
||||
images: [],
|
||||
};
|
||||
}
|
||||
|
||||
const data = await readTextFile('cover/image_urls.json', { dir: BaseDirectory.AppLocalData });
|
||||
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeCoverImageUrls(images: { url: string }[]) {
|
||||
const { BaseDirectory, createDir, exists, writeTextFile } = await import('@tauri-apps/api/fs');
|
||||
|
||||
const fileName = 'cover/image_urls.json';
|
||||
const jsonString = JSON.stringify({ images });
|
||||
|
||||
try {
|
||||
const existDir = await exists('cover', { dir: BaseDirectory.AppLocalData });
|
||||
|
||||
if (!existDir) {
|
||||
await createDir('cover', { dir: BaseDirectory.AppLocalData });
|
||||
}
|
||||
|
||||
await writeTextFile(fileName, jsonString, { dir: BaseDirectory.AppLocalData });
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
export function convertBlobToBase64(blob: Blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
@ -114,3 +114,15 @@ export function clone<T>(value: T): T {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function chunkArray<T>(array: T[], chunkSize: number) {
|
||||
const chunks = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < array.length) {
|
||||
chunks.push(array.slice(i, i + chunkSize));
|
||||
i += chunkSize;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
@ -34,6 +34,7 @@
|
||||
.MuiButtonBase-root.MuiIconButton-root {
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.MuiButtonBase-root.MuiButton-containedPrimary.MuiButton-contained:hover {
|
||||
|
@ -27,6 +27,15 @@ div[role="textbox"] ::selection {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
:root[data-dark-mode=true] body {
|
||||
scrollbar-color: #fff var(--bg-body);
|
||||
}
|
||||
|
||||
body {
|
||||
scrollbar-track-color: var(--bg-body);
|
||||
scrollbar-shadow-color: var(--bg-body);
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply rounded-xl border border-line-divider px-4 py-3;
|
||||
}
|
||||
@ -62,4 +71,4 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
||||
border-color: var(--line-divider) !important;
|
||||
color: var(--text-title) !important;
|
||||
box-shadow: var(--line-border) 0px 0px 0px 1px inset !important;
|
||||
}
|
||||
}
|
||||
|
@ -624,5 +624,23 @@
|
||||
"pink": "Pink",
|
||||
"brown": "Brown",
|
||||
"gray": "Gray"
|
||||
},
|
||||
"emoji": {
|
||||
"filter": "Filter",
|
||||
"random": "Random",
|
||||
"selectSkinTone": "Select skin tone",
|
||||
"remove": "Remove emoji",
|
||||
"categories": {
|
||||
"smileys": "Smileys & Emotion",
|
||||
"people": "People & Body",
|
||||
"animals": "Animals & Nature",
|
||||
"food": "Food & Drink",
|
||||
"activities": "Activities",
|
||||
"places": "Travel & Places",
|
||||
"objects": "Objects",
|
||||
"symbols": "Symbols",
|
||||
"flags": "Flags",
|
||||
"nature": "Nature"
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user