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 { Theme as ThemeType, Theme, ThemeMode } from '$app/interfaces';
|
||||||
import { createTheme } from '@mui/material/styles';
|
import { createTheme } from '@mui/material/styles';
|
||||||
import { getDesignTokens } from '$app/utils/mui';
|
import { getDesignTokens } from '$app/utils/mui';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function useUserSetting() {
|
export function useUserSetting() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
const currentUser = useAppSelector((state) => state.currentUser);
|
const currentUser = useAppSelector((state) => state.currentUser);
|
||||||
const userSettingController = useMemo(() => {
|
const userSettingController = useMemo(() => {
|
||||||
if (!currentUser?.id) return;
|
if (!currentUser?.id) return;
|
||||||
@ -35,8 +37,9 @@ export function useUserSetting() {
|
|||||||
language: language,
|
language: language,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
i18n.changeLanguage(language);
|
||||||
});
|
});
|
||||||
}, [dispatch, userSettingController]);
|
}, [i18n, dispatch, userSettingController]);
|
||||||
|
|
||||||
const { themeMode = ThemeMode.Light, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
|
const { themeMode = ThemeMode.Light, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
|
||||||
return state.currentUser.userSetting || {};
|
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(
|
const onEmojiSelect = useCallback(
|
||||||
(emoji: { native: string }) => {
|
(emoji: string) => {
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
void dispatch(
|
void dispatch(
|
||||||
updateNodeDataThunk({
|
updateNodeDataThunk({
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
controller,
|
controller,
|
||||||
data: {
|
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 TextBlock from '$app/components/document/TextBlock';
|
||||||
import NodeChildren from '$app/components/document/Node/NodeChildren';
|
import NodeChildren from '$app/components/document/Node/NodeChildren';
|
||||||
import { IconButton } from '@mui/material';
|
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 { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
|
||||||
import Popover from '@mui/material/Popover';
|
import Popover from '@mui/material/Popover';
|
||||||
|
import EmojiPicker from '$app/components/_shared/EmojiPicker';
|
||||||
|
|
||||||
export default function CalloutBlock({
|
export default function CalloutBlock({
|
||||||
node,
|
node,
|
||||||
@ -38,7 +37,7 @@ export default function CalloutBlock({
|
|||||||
horizontal: 'left',
|
horizontal: 'left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Picker searchPosition={'static'} locale={'en'} autoFocus data={emojiData} onEmojiSelect={onEmojiSelect} />
|
<EmojiPicker onEmojiSelect={onEmojiSelect} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</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 { 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) {
|
export function useDocumentTitle(id: string) {
|
||||||
const { node } = useSubscribeNode(id);
|
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 {
|
return {
|
||||||
node,
|
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 { useDocumentTitle } from './DocumentTitle.hooks';
|
||||||
import TextBlock from '../TextBlock';
|
import TextBlock from '../TextBlock';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import TitleButtonGroup from './TitleButtonGroup';
|
||||||
|
import DocumentTopPanel from './DocumentTopPanel';
|
||||||
|
|
||||||
export default function DocumentTitle({ id }: { id: string }) {
|
export default function DocumentTitle({ id }: { id: string }) {
|
||||||
const { node } = useDocumentTitle(id);
|
const { node, onUpdateCover, onUpdateIcon } = useDocumentTitle(id);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-block-id={node.id} className='doc-title relative mb-2 pt-[50px] text-4xl font-bold'>
|
<div className={'flex flex-col'} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
||||||
<TextBlock placeholder={t('document.title.placeholder')} childIds={[]} node={node} />
|
<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>
|
</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 React, { useCallback } from 'react';
|
||||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||||
import { useImageBlock } from './useImageBlock';
|
import { useImageBlock } from './useImageBlock';
|
||||||
import EditImage from '$app/components/document/ImageBlock/EditImage';
|
|
||||||
import { useBlockPopover } from '$app/components/document/_shared/BlockPopover/BlockPopover.hooks';
|
import { useBlockPopover } from '$app/components/document/_shared/BlockPopover/BlockPopover.hooks';
|
||||||
import ImagePlaceholder from '$app/components/document/ImageBlock/ImagePlaceholder';
|
import ImagePlaceholder from '$app/components/document/ImageBlock/ImagePlaceholder';
|
||||||
import ImageRender from '$app/components/document/ImageBlock/ImageRender';
|
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> }) {
|
function ImageBlock({ node }: { node: NestedBlock<BlockType.ImageBlock> }) {
|
||||||
const { url } = node.data;
|
const { url } = node.data;
|
||||||
const { displaySize, onResizeStart, src, alignSelf, loading, error } = useImageBlock(node);
|
const { displaySize, onResizeStart, src, alignSelf, loading, error } = useImageBlock(node);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { controller } = useSubscribeDocument();
|
||||||
|
const id = node.id;
|
||||||
|
|
||||||
const renderPopoverContent = useCallback(
|
const renderPopoverContent = useCallback(
|
||||||
({ onClose }: { onClose: () => void }) => {
|
({ 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({
|
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 { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import { getDeltaText } from '$app/utils/document/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';
|
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
|
||||||
|
|
||||||
export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
|
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 { 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
|
* 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,
|
canAddChild: true,
|
||||||
defaultData: {
|
defaultData: {
|
||||||
delta: [],
|
delta: [],
|
||||||
icon: 'bulb',
|
icon: randomEmoji(),
|
||||||
},
|
},
|
||||||
splitProps: {
|
splitProps: {
|
||||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||||
|
@ -82,7 +82,11 @@ export interface ImageBlockData {
|
|||||||
align: Align;
|
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
|
export type BlockData<Type> = Type extends BlockType.HeadingBlock
|
||||||
? HeadingBlockData
|
? 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) {
|
export function convertBlobToBase64(blob: Blob) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
@ -114,3 +114,15 @@ export function clone<T>(value: T): T {
|
|||||||
|
|
||||||
return result;
|
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 {
|
.MuiButtonBase-root.MuiIconButton-root {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiButtonBase-root.MuiButton-containedPrimary.MuiButton-contained:hover {
|
.MuiButtonBase-root.MuiButton-containedPrimary.MuiButton-contained:hover {
|
||||||
|
@ -27,6 +27,15 @@ div[role="textbox"] ::selection {
|
|||||||
@apply bg-transparent;
|
@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 {
|
.btn {
|
||||||
@apply rounded-xl border border-line-divider px-4 py-3;
|
@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;
|
border-color: var(--line-divider) !important;
|
||||||
color: var(--text-title) !important;
|
color: var(--text-title) !important;
|
||||||
box-shadow: var(--line-border) 0px 0px 0px 1px inset !important;
|
box-shadow: var(--line-border) 0px 0px 0px 1px inset !important;
|
||||||
}
|
}
|
||||||
|
@ -624,5 +624,23 @@
|
|||||||
"pink": "Pink",
|
"pink": "Pink",
|
||||||
"brown": "Brown",
|
"brown": "Brown",
|
||||||
"gray": "Gray"
|
"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…
x
Reference in New Issue
Block a user