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:
Kilu.He 2023-07-31 11:39:44 +08:00 committed by GitHub
parent f28c5d849c
commit eb77346e5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1273 additions and 113 deletions

View File

@ -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 || {};

View File

@ -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 };
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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,
},
})
);

View File

@ -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>

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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)];
};

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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({

View File

@ -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 }) {

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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,

View File

@ -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

View File

@ -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;
};

View File

@ -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();

View File

@ -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;
}

View File

@ -34,6 +34,7 @@
.MuiButtonBase-root.MuiIconButton-root {
border-radius: 4px;
padding: 2px;
box-shadow: none;
}
.MuiButtonBase-root.MuiButton-containedPrimary.MuiButton-contained:hover {

View File

@ -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;
}
}

View File

@ -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"
}
}
}